feat(provisioner): Aurelio workstation provisioner with OS-family detection and profiles
- Add .aurelio/provisioner/ package with OS family detection (debian, rhel, arch, macos, wsl)
- Define toolchain profiles: embedded, backend, frontend, hardware, full
- Generate self-contained shell scripts per OS family and profile
- Add unit tests for detection and rendering
- Integrate provisioner into savearth-workspace MCP server
- Support ?profile= query parameter on /bootstrap/{os} endpoint
- Update README and dashboard provisioning panel
This commit is contained in:
parent
33c990b61e
commit
a2e40dd556
16 changed files with 1206 additions and 204 deletions
|
|
@ -42,10 +42,14 @@ The server can generate bootstrap scripts that prepare a fresh machine for savea
|
|||
After cloning `aws-iot-core-poc`, run:
|
||||
|
||||
```bash
|
||||
# Embedded/firmware profile (default)
|
||||
bash scripts/bootstrap-workstation.sh my-laptop
|
||||
|
||||
# Or pick a different toolchain profile
|
||||
bash scripts/bootstrap-workstation.sh --profile backend my-laptop
|
||||
```
|
||||
|
||||
This installs dependencies, clones the other savearth repos, initializes submodules, and registers the machine.
|
||||
This detects your OS family (Debian, RHEL/Fedora, Arch, macOS, WSL), installs the right dependencies, clones the other savearth repos, initializes submodules, and registers the machine.
|
||||
|
||||
### One-click bootstrap from the dashboard
|
||||
|
||||
|
|
@ -56,15 +60,19 @@ Generate a pre-authorized bootstrap script via the MCP tool:
|
|||
"name": "generate_bootstrap_script",
|
||||
"arguments": {
|
||||
"machine_name": "my-laptop",
|
||||
"os_type": "fedora"
|
||||
"os_type": "fedora",
|
||||
"profile": "embedded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported `os_type` values: `fedora`, `rhel`, `debian`, `ubuntu`, `arch`, `macos`, `wsl`.
|
||||
Supported `profile` values: `embedded`, `backend`, `frontend`, `hardware`, `full`.
|
||||
|
||||
Or download directly:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://savearth-workspace.portugalfuturista.org/bootstrap/fedora?machine=my-laptop -o bootstrap.sh
|
||||
curl -fsSL "https://savearth-workspace.portugalfuturista.org/bootstrap/fedora?machine=my-laptop&profile=embedded" -o bootstrap.sh
|
||||
bash bootstrap.sh
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,30 @@ from starlette.routing import Mount, Route
|
|||
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# Allow importing the Aurelio provisioner both from repo and deployed layouts.
|
||||
_PROVISIONER_SEARCH_PATHS = [
|
||||
_THIS_DIR / "provisioner",
|
||||
_THIS_DIR.parent.parent / "provisioner",
|
||||
]
|
||||
for _pp in _PROVISIONER_SEARCH_PATHS:
|
||||
if _pp.exists() and str(_pp.parent) not in sys.path:
|
||||
sys.path.insert(0, str(_pp.parent))
|
||||
break
|
||||
|
||||
try:
|
||||
from provisioner import (
|
||||
OsFamily,
|
||||
ProvisionerError,
|
||||
VALID_PROFILES,
|
||||
detect_os,
|
||||
render_bootstrap_script,
|
||||
)
|
||||
except ImportError as _provisioner_import_err: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"Could not import Aurelio provisioner. Ensure the provisioner package is "
|
||||
"available next to server.py or at replica-omnisciente/.aurelio/provisioner."
|
||||
) from _provisioner_import_err
|
||||
|
||||
SAVEARTH_PROJECT_DIR = Path(os.environ.get("SAVEARTH_PROJECT_DIR", "/opt/savearth"))
|
||||
SAVEARTH_MCP_URL = os.environ.get("SAVEARTH_MCP_URL", "http://192.168.0.212:8080/sse")
|
||||
SAVEARTH_LOGO_URL = os.environ.get(
|
||||
|
|
@ -310,200 +334,42 @@ def _collect_tool_versions() -> Dict[str, Any]:
|
|||
return versions
|
||||
|
||||
|
||||
def _render_bootstrap_script(os_type: str, machine_name: str, token: str) -> str:
|
||||
"""Render a bootstrap script for the requested OS."""
|
||||
machine_id = _machine_id(machine_name)
|
||||
public_url = SAVEARTH_WORKSPACE_PUBLIC_URL.rstrip("/")
|
||||
def _render_bootstrap_script(os_type: str, machine_name: str, token: str, profile: str = "embedded") -> str:
|
||||
"""Render a bootstrap script using the Aurelio provisioner.
|
||||
|
||||
if os_type == "fedora":
|
||||
return f"""#!/usr/bin/env bash
|
||||
# savearth workstation bootstrap for Fedora
|
||||
# Machine: {machine_name}
|
||||
# Generated: {_now()}
|
||||
set -euo pipefail
|
||||
os_type maps to OS family: debian, rhel, arch, macos, wsl, fedora (treated as rhel).
|
||||
"""
|
||||
from provisioner.detect import OsFamily, OsInfo
|
||||
|
||||
MACHINE_NAME="{machine_name}"
|
||||
MACHINE_ID="{machine_id}"
|
||||
TOKEN="{token}"
|
||||
PUBLIC_URL="{public_url}"
|
||||
SAVEARTH_DIR="${{SAVEARTH_DIR:-$HOME/savearth}}"
|
||||
ESP_IDF_VERSION="v5.5.4"
|
||||
os_type = os_type.lower()
|
||||
family_map = {
|
||||
"fedora": OsFamily.RHEL,
|
||||
"rhel": OsFamily.RHEL,
|
||||
"rocky": OsFamily.RHEL,
|
||||
"debian": OsFamily.DEBIAN,
|
||||
"ubuntu": OsFamily.DEBIAN,
|
||||
"arch": OsFamily.ARCH,
|
||||
"macos": OsFamily.MACOS,
|
||||
"darwin": OsFamily.MACOS,
|
||||
"wsl": OsFamily.WSL,
|
||||
}
|
||||
family = family_map.get(os_type)
|
||||
if family is None:
|
||||
# Fall back to auto-detection if the caller passes an unknown string.
|
||||
info = detect_os()
|
||||
family = info.family
|
||||
distro = info.distro
|
||||
else:
|
||||
distro = os_type
|
||||
|
||||
echo "🔧 Bootstrapping savearth workstation: $MACHINE_NAME"
|
||||
|
||||
# ─── 1. System packages ───────────────────────────────────────────────
|
||||
echo "Installing base packages..."
|
||||
sudo dnf update -y
|
||||
sudo dnf install -y \\
|
||||
git git-lfs curl wget python3 python3-pip python3-venv \\
|
||||
cmake ninja-build ccache flex bison gperf \\
|
||||
libusb1 dfu-util minicom picocom \\
|
||||
udev docker docker-compose
|
||||
|
||||
sudo usermod -aG docker "$USER" || true
|
||||
sudo systemctl enable --now docker || true
|
||||
|
||||
# ─── 2. Node.js / npx for MCP remote clients ──────────────────────────
|
||||
if ! command -v npx &>/dev/null; then
|
||||
echo "Installing Node.js..."
|
||||
sudo dnf install -y nodejs
|
||||
fi
|
||||
|
||||
# ─── 3. ESP-IDF ───────────────────────────────────────────────────────
|
||||
mkdir -p "$HOME/esp"
|
||||
if [ ! -d "$HOME/esp/v5.5.4/esp-idf" ]; then
|
||||
echo "Cloning ESP-IDF $ESP_IDF_VERSION..."
|
||||
git clone -b "$ESP_IDF_VERSION" --recursive \\
|
||||
https://github.com/espressif/esp-idf.git "$HOME/esp/v5.5.4/esp-idf"
|
||||
cd "$HOME/esp/v5.5.4/esp-idf"
|
||||
./install.sh esp32s3
|
||||
fi
|
||||
echo 'export IDF_PATH="$HOME/esp/v5.5.4/esp-idf"' >> "$HOME/.bashrc"
|
||||
echo 'source "$IDF_PATH/export.sh" > /dev/null 2>&1' >> "$HOME/.bashrc"
|
||||
|
||||
# ─── 4. GitHub SSH auth ───────────────────────────────────────────────
|
||||
if [ ! -f "$HOME/.ssh/id_ed25519" ]; then
|
||||
echo "Generating SSH key for GitHub..."
|
||||
mkdir -p "$HOME/.ssh"
|
||||
ssh-keygen -t ed25519 -C "$MACHINE_NAME@savearth" -f "$HOME/.ssh/id_ed25519" -N ""
|
||||
echo "Add this key to GitHub:"
|
||||
cat "$HOME/.ssh/id_ed25519.pub"
|
||||
read -rp "Press Enter after adding the key to GitHub..."
|
||||
fi
|
||||
|
||||
# ─── 5. Clone savearth repositories ───────────────────────────────────
|
||||
mkdir -p "$SAVEARTH_DIR"
|
||||
cd "$SAVEARTH_DIR"
|
||||
|
||||
clone_or_pull() {{
|
||||
local repo="$1"
|
||||
local dir="$2"
|
||||
if [ -d "$dir/.git" ]; then
|
||||
echo "Pulling $repo..."
|
||||
git -C "$dir" pull
|
||||
else
|
||||
echo "Cloning $repo..."
|
||||
git clone "git@github.com:$repo.git" "$dir"
|
||||
fi
|
||||
}}
|
||||
|
||||
clone_or_pull SavearthTech/aws-iot-core-poc aws-iot-core-poc
|
||||
clone_or_pull SavearthTech/savearth-iot-infrastructure savearth-iot-infrastructure
|
||||
clone_or_pull SavearthTech/savearth-hw-project savearth-hw-project
|
||||
clone_or_pull SavearthTech/hardware-devicesFirmwareTest hardware-devicesFirmwareTest
|
||||
|
||||
# ─── 6. Initialize central-brain submodules ───────────────────────────
|
||||
for dir in aws-iot-core-poc savearth-iot-infrastructure savearth-hw-project hardware-devicesFirmwareTest; do
|
||||
echo "Initializing submodule in $dir..."
|
||||
git -C "$dir" submodule update --init --recursive replica-omnisciente
|
||||
done
|
||||
|
||||
# ─── 7. Register this machine with savearth-workspace ─────────────────
|
||||
echo "Registering machine..."
|
||||
curl -sS -X POST "$PUBLIC_URL/api/register" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{{"machine_name":"$MACHINE_NAME","os":"fedora","token":"$TOKEN"}}' \\
|
||||
-o /tmp/register.json || true
|
||||
cat /tmp/register.json 2>/dev/null || echo "Registration skipped (server may require auth)."
|
||||
|
||||
# ─── 8. Pull latest brain snapshot if available ───────────────────────
|
||||
echo "Checking for brain snapshots..."
|
||||
curl -sS "$PUBLIC_URL/api/snapshots/latest?machine_id=$MACHINE_ID" \\
|
||||
-H "Authorization: Bearer $TOKEN" \\
|
||||
-o /tmp/latest_snapshot.json || true
|
||||
|
||||
if [ -s /tmp/latest_snapshot.json ]; then
|
||||
SNAPSHOT_URL=$(python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('download_url',''))" < /tmp/latest_snapshot.json)
|
||||
if [ -n "$SNAPSHOT_URL" ]; then
|
||||
echo "Restoring brain snapshot..."
|
||||
curl -sS -H "Authorization: Bearer $TOKEN" "$SNAPSHOT_URL" -o /tmp/brain.tar.gz
|
||||
for dir in aws-iot-core-poc savearth-iot-infrastructure savearth-hw-project hardware-devicesFirmwareTest; do
|
||||
[ -d "$dir/replica-omnisciente/.aurelio/brain" ] && \\
|
||||
tar xzf /tmp/brain.tar.gz -C "$dir/replica-omnisciente/.aurelio" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── 9. Global Aurelio config ─────────────────────────────────────────
|
||||
mkdir -p "$HOME/.aurelio"
|
||||
cat > "$HOME/.aurelio/config.toml" << 'TOML'
|
||||
[sync]
|
||||
enabled = true
|
||||
endpoint = "https://mcp.portugalfuturista.org"
|
||||
interval_seconds = 300
|
||||
auto_push = true
|
||||
|
||||
[identity]
|
||||
name = "savearth Developer Workstation"
|
||||
realm = "smart-device-firmware"
|
||||
|
||||
[models]
|
||||
default_local = "qwen2.5-coder:14b"
|
||||
default_cloud = "gemini-2.5-pro"
|
||||
ollama_url = "http://127.0.0.1:11434"
|
||||
|
||||
[brain]
|
||||
auto_save = true
|
||||
artifact_types = ["task", "implementation_plan", "walkthrough", "analysis"]
|
||||
TOML
|
||||
|
||||
echo ""
|
||||
echo "✅ Bootstrap complete. Next steps:"
|
||||
echo " 1. Start a new shell or run: source ~/.bashrc"
|
||||
echo " 2. cd $SAVEARTH_DIR/aws-iot-core-poc"
|
||||
echo " 3. python3 tools/scripts/build_tool.py env"
|
||||
echo " 4. Open dashboard: $PUBLIC_URL/dashboard"
|
||||
"""
|
||||
|
||||
# Generic Linux fallback (similar to Fedora without dnf specifics)
|
||||
return f"""#!/usr/bin/env bash
|
||||
# savearth workstation bootstrap for {os_type}
|
||||
# Machine: {machine_name}
|
||||
# Generated: {_now()}
|
||||
set -euo pipefail
|
||||
|
||||
MACHINE_NAME="{machine_name}"
|
||||
MACHINE_ID="{machine_id}"
|
||||
TOKEN="{token}"
|
||||
PUBLIC_URL="{public_url}"
|
||||
SAVEARTH_DIR="${{SAVEARTH_DIR:-$HOME/savearth}}"
|
||||
|
||||
echo "🔧 Bootstrapping savearth workstation: $MACHINE_NAME"
|
||||
echo "OS: {os_type}"
|
||||
echo ""
|
||||
echo "Please ensure the following are installed manually:"
|
||||
echo " - git, python3, python3-pip, python3-venv"
|
||||
echo " - cmake, ninja-build, ccache"
|
||||
echo " - docker, docker-compose"
|
||||
echo " - ESP-IDF v5.5.4 at ~/esp/v5.5.4/esp-idf"
|
||||
echo " - Node.js + npx"
|
||||
echo ""
|
||||
read -rp "Press Enter when ready..."
|
||||
|
||||
# Clone repositories
|
||||
mkdir -p "$SAVEARTH_DIR"
|
||||
cd "$SAVEARTH_DIR"
|
||||
for repo in SavearthTech/aws-iot-core-poc:aws-iot-core-poc \\
|
||||
SavearthTech/savearth-iot-infrastructure:savearth-iot-infrastructure \\
|
||||
SavearthTech/savearth-hw-project:savearth-hw-project \\
|
||||
SavearthTech/hardware-devicesFirmwareTest:hardware-devicesFirmwareTest; do
|
||||
r="${{repo%%:*}}"
|
||||
d="${{repo##*:}}"
|
||||
if [ -d "$d/.git" ]; then
|
||||
git -C "$d" pull
|
||||
else
|
||||
git clone "git@github.com:$r.git" "$d"
|
||||
fi
|
||||
git -C "$d" submodule update --init --recursive replica-omnisciente
|
||||
done
|
||||
|
||||
# Register machine
|
||||
curl -sS -X POST "$PUBLIC_URL/api/register" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{{"machine_name":"$MACHINE_NAME","os":"{os_type}","token":"$TOKEN"}}' || true
|
||||
|
||||
echo "✅ Bootstrap complete. cd $SAVEARTH_DIR/aws-iot-core-poc"
|
||||
"""
|
||||
os_info = OsInfo(family=family, distro=distro, version="", is_wsl=family == OsFamily.WSL)
|
||||
return render_bootstrap_script(
|
||||
os_info=os_info,
|
||||
machine_name=machine_name,
|
||||
profile=profile,
|
||||
token=token,
|
||||
public_url=SAVEARTH_WORKSPACE_PUBLIC_URL,
|
||||
)
|
||||
|
||||
|
||||
# ─── Project Data Loading ────────────────────────────────────────────
|
||||
|
|
@ -815,9 +681,10 @@ DASHBOARD_TEMPLATE = """<!DOCTYPE html>
|
|||
</table>
|
||||
{% else %}
|
||||
<p>No workstations registered yet.</p>
|
||||
<p>To provision a new Fedora laptop, run:</p>
|
||||
<pre style="background:#0f172a;padding:1rem;border-radius:8px;overflow:auto;">curl -fsSL {{ public_url }}/bootstrap/fedora?machine=my-laptop -o bootstrap.sh
|
||||
<p>Provision a new machine (auto-detects Debian, RHEL/Fedora, Arch, macOS, WSL):</p>
|
||||
<pre style="background:#0f172a;padding:1rem;border-radius:8px;overflow:auto;">curl -fsSL "{{ public_url }}/bootstrap/fedora?machine=my-laptop&profile=embedded" -o bootstrap.sh
|
||||
bash bootstrap.sh</pre>
|
||||
<p>Available profiles: <code>embedded</code>, <code>backend</code>, <code>frontend</code>, <code>hardware</code>, <code>full</code>.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -962,17 +829,28 @@ async def get_dashboard_url() -> str:
|
|||
name="generate_bootstrap_script",
|
||||
annotations={"title": "Generate Workstation Bootstrap Script", "readOnlyHint": True},
|
||||
)
|
||||
async def generate_bootstrap_script(machine_name: str, os_type: str = "fedora") -> str:
|
||||
"""Generate a bootstrap script for a new workstation. os_type: fedora or generic."""
|
||||
async def generate_bootstrap_script(
|
||||
machine_name: str,
|
||||
os_type: str = "fedora",
|
||||
profile: str = "embedded",
|
||||
) -> str:
|
||||
"""Generate a bootstrap script for a new workstation.
|
||||
|
||||
os_type: fedora, rhel, debian, ubuntu, arch, macos, wsl (default: fedora).
|
||||
profile: embedded, backend, frontend, hardware, full (default: embedded).
|
||||
"""
|
||||
if not machine_name or not re.match(r"^[a-zA-Z0-9_-]+$", machine_name):
|
||||
return "Invalid machine_name. Use only letters, numbers, hyphens, and underscores."
|
||||
os_type = os_type.lower()
|
||||
if os_type not in ("fedora", "generic"):
|
||||
return "Unsupported os_type. Use 'fedora' or 'generic'."
|
||||
profile = profile.lower()
|
||||
if profile not in VALID_PROFILES:
|
||||
return f"Unsupported profile '{profile}'. Valid: {', '.join(sorted(VALID_PROFILES))}."
|
||||
token = _generate_token(machine_name)
|
||||
script = _render_bootstrap_script(os_type, machine_name, token)
|
||||
try:
|
||||
script = _render_bootstrap_script(os_type, machine_name, token, profile)
|
||||
except ProvisionerError as exc:
|
||||
return f"Provisioning error: {exc}"
|
||||
return (
|
||||
f"# Bootstrap script for {machine_name} ({os_type})\n"
|
||||
f"# Bootstrap script for {machine_name} ({os_type}, {profile})\n"
|
||||
f"# Save this to bootstrap.sh and run: bash bootstrap.sh\n\n"
|
||||
f"{script}"
|
||||
)
|
||||
|
|
@ -1208,12 +1086,21 @@ async def bootstrap_script_handler(request: Request) -> PlainTextResponse:
|
|||
"""Public endpoint to download a bootstrap script for a machine."""
|
||||
os_type = request.path_params.get("os", "fedora")
|
||||
machine_name = request.query_params.get("machine")
|
||||
profile = request.query_params.get("profile", "embedded")
|
||||
if not machine_name:
|
||||
return PlainTextResponse("Missing ?machine=<name>", status_code=400)
|
||||
if not re.match(r"^[a-zA-Z0-9_-]+$", machine_name):
|
||||
return PlainTextResponse("Invalid machine name", status_code=400)
|
||||
if profile not in VALID_PROFILES:
|
||||
return PlainTextResponse(
|
||||
f"Invalid profile '{profile}'. Valid: {', '.join(sorted(VALID_PROFILES))}.",
|
||||
status_code=400,
|
||||
)
|
||||
token = _generate_token(machine_name)
|
||||
script = _render_bootstrap_script(os_type, machine_name, token)
|
||||
try:
|
||||
script = _render_bootstrap_script(os_type, machine_name, token, profile)
|
||||
except ProvisionerError as exc:
|
||||
return PlainTextResponse(f"Provisioning error: {exc}", status_code=400)
|
||||
return PlainTextResponse(
|
||||
script,
|
||||
media_type="text/x-shellscript",
|
||||
|
|
|
|||
25
.aurelio/provisioner/__init__.py
Normal file
25
.aurelio/provisioner/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Aurelio workstation provisioner.
|
||||
|
||||
Generate self-contained shell scripts that prepare a developer workstation
|
||||
for the savearth ecosystem. Supports multiple OS families and toolchain profiles.
|
||||
"""
|
||||
|
||||
from .detect import OsFamily, OsInfo, detect_os, os_family_name
|
||||
from .profiles import VALID_PROFILES, get_profile
|
||||
from .renderer import (
|
||||
ProvisionerError,
|
||||
render_bootstrap_script,
|
||||
render_for_current_machine,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OsFamily",
|
||||
"OsInfo",
|
||||
"VALID_PROFILES",
|
||||
"ProvisionerError",
|
||||
"detect_os",
|
||||
"get_profile",
|
||||
"os_family_name",
|
||||
"render_bootstrap_script",
|
||||
"render_for_current_machine",
|
||||
]
|
||||
103
.aurelio/provisioner/detect.py
Normal file
103
.aurelio/provisioner/detect.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""OS-family detection for the Aurelio workstation provisioner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class OsFamily(str, Enum):
|
||||
DEBIAN = "debian"
|
||||
RHEL = "rhel"
|
||||
ARCH = "arch"
|
||||
MACOS = "macos"
|
||||
WSL = "wsl"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OsInfo:
|
||||
family: OsFamily
|
||||
distro: str
|
||||
version: str
|
||||
is_wsl: bool
|
||||
|
||||
@property
|
||||
def is_linux(self) -> bool:
|
||||
return self.family in (OsFamily.DEBIAN, OsFamily.RHEL, OsFamily.ARCH)
|
||||
|
||||
|
||||
def _read_os_release() -> dict[str, str]:
|
||||
"""Parse /etc/os-release into a dict."""
|
||||
data: dict[str, str] = {}
|
||||
for path in (Path("/etc/os-release"), Path("/usr/lib/os-release")):
|
||||
if path.exists():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
data[key] = value.strip('"')
|
||||
break
|
||||
return data
|
||||
|
||||
|
||||
def _is_wsl() -> bool:
|
||||
"""Detect Windows Subsystem for Linux."""
|
||||
if platform.system() != "Linux":
|
||||
return False
|
||||
proc_version = Path("/proc/version")
|
||||
if proc_version.exists() and "microsoft" in proc_version.read_text(encoding="utf-8").lower():
|
||||
return True
|
||||
return Path("/mnt/c/Windows").exists()
|
||||
|
||||
|
||||
def _map_linux_distro(os_release: dict[str, str]) -> OsFamily:
|
||||
"""Map an /etc/os-release ID to our OS family."""
|
||||
distro_id = os_release.get("ID", "").lower()
|
||||
like = os_release.get("ID_LIKE", "").lower()
|
||||
|
||||
debian_ids = {"debian", "ubuntu", "linuxmint", "pop", "elementary", "zorin", "kali", "raspbian"}
|
||||
rhel_ids = {"fedora", "rhel", "rocky", "almalinux", "centos", "ol", "amzn"}
|
||||
arch_ids = {"arch", "manjaro", "endeavouros", "garuda"}
|
||||
|
||||
if distro_id in debian_ids or "debian" in like or "ubuntu" in like:
|
||||
return OsFamily.DEBIAN
|
||||
if distro_id in rhel_ids or "fedora" in like or "rhel" in like or "centos" in like:
|
||||
return OsFamily.RHEL
|
||||
if distro_id in arch_ids or "arch" in like:
|
||||
return OsFamily.ARCH
|
||||
|
||||
return OsFamily.UNKNOWN
|
||||
|
||||
|
||||
def detect_os() -> OsInfo:
|
||||
"""Detect the workstation OS family and distribution."""
|
||||
system = platform.system()
|
||||
is_wsl = _is_wsl()
|
||||
|
||||
if system == "Darwin":
|
||||
version = platform.mac_ver()[0]
|
||||
return OsInfo(OsFamily.MACOS, "macos", version, is_wsl)
|
||||
|
||||
if system == "Linux":
|
||||
os_release = _read_os_release()
|
||||
family = _map_linux_distro(os_release)
|
||||
distro = os_release.get("ID", "linux")
|
||||
version = os_release.get("VERSION_ID", "")
|
||||
|
||||
if is_wsl:
|
||||
return OsInfo(OsFamily.WSL, distro, version, True)
|
||||
return OsInfo(family, distro, version, False)
|
||||
|
||||
if system == "Windows":
|
||||
return OsInfo(OsFamily.UNKNOWN, "windows", platform.release(), False)
|
||||
|
||||
return OsInfo(OsFamily.UNKNOWN, system.lower(), platform.release(), False)
|
||||
|
||||
|
||||
def os_family_name() -> str:
|
||||
"""Convenience: return the detected OS family as a string."""
|
||||
return detect_os().family.value
|
||||
209
.aurelio/provisioner/packages.py
Normal file
209
.aurelio/provisioner/packages.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""Package lists per OS family for the Aurelio workstation provisioner.
|
||||
|
||||
Keep these in sync with the container Dockerfiles in the firmware repo:
|
||||
- tools/containers/debian/Dockerfile
|
||||
- tools/containers/rockylinux/Dockerfile
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from .detect import OsFamily
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageManager:
|
||||
"""Describes how a given OS family installs packages."""
|
||||
|
||||
name: str
|
||||
update_cmd: str
|
||||
install_cmd: str
|
||||
group_cmd: str | None = None
|
||||
|
||||
|
||||
PACKAGE_MANAGERS: dict[OsFamily, PackageManager] = {
|
||||
OsFamily.DEBIAN: PackageManager(
|
||||
name="apt-get",
|
||||
update_cmd="sudo apt-get update",
|
||||
install_cmd="sudo apt-get install -y --no-install-recommends",
|
||||
),
|
||||
OsFamily.RHEL: PackageManager(
|
||||
name="dnf",
|
||||
update_cmd="sudo dnf update -y",
|
||||
install_cmd="sudo dnf install -y",
|
||||
group_cmd="sudo dnf group install -y",
|
||||
),
|
||||
OsFamily.ARCH: PackageManager(
|
||||
name="pacman",
|
||||
update_cmd="sudo pacman -Sy",
|
||||
install_cmd="sudo pacman -S --noconfirm",
|
||||
),
|
||||
OsFamily.MACOS: PackageManager(
|
||||
name="brew",
|
||||
update_cmd="brew update",
|
||||
install_cmd="brew install",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Base packages required on every workstation.
|
||||
BASE_PACKAGES: dict[OsFamily, list[str]] = {
|
||||
OsFamily.DEBIAN: [
|
||||
"git",
|
||||
"git-lfs",
|
||||
"curl",
|
||||
"wget",
|
||||
"ca-certificates",
|
||||
"python3",
|
||||
"python3-pip",
|
||||
"python3-venv",
|
||||
"python3-setuptools",
|
||||
"cmake",
|
||||
"ninja-build",
|
||||
"ccache",
|
||||
"flex",
|
||||
"bison",
|
||||
"gperf",
|
||||
"libffi-dev",
|
||||
"libssl-dev",
|
||||
"dfu-util",
|
||||
"libusb-1.0-0",
|
||||
"unzip",
|
||||
"xz-utils",
|
||||
"minicom",
|
||||
"picocom",
|
||||
"udev",
|
||||
],
|
||||
OsFamily.RHEL: [
|
||||
"git",
|
||||
"wget",
|
||||
"flex",
|
||||
"bison",
|
||||
"gperf",
|
||||
"python3",
|
||||
"python3-pip",
|
||||
"python3-setuptools",
|
||||
"python3-devel",
|
||||
"cmake",
|
||||
"ninja-build",
|
||||
"ccache",
|
||||
"libffi-devel",
|
||||
"openssl-devel",
|
||||
"ca-certificates",
|
||||
"curl",
|
||||
"unzip",
|
||||
"xz",
|
||||
"minicom",
|
||||
"picocom",
|
||||
"gcc",
|
||||
"gcc-c++",
|
||||
"make",
|
||||
"libusb1",
|
||||
"systemd-udev",
|
||||
"dfu-util",
|
||||
],
|
||||
OsFamily.ARCH: [
|
||||
"git",
|
||||
"wget",
|
||||
"flex",
|
||||
"bison",
|
||||
"gperf",
|
||||
"python",
|
||||
"python-pip",
|
||||
"cmake",
|
||||
"ninja",
|
||||
"ccache",
|
||||
"libffi",
|
||||
"openssl",
|
||||
"dfu-util",
|
||||
"libusb",
|
||||
"minicom",
|
||||
"picocom",
|
||||
"udev",
|
||||
],
|
||||
OsFamily.MACOS: [
|
||||
"git",
|
||||
"python",
|
||||
"cmake",
|
||||
"ninja",
|
||||
"ccache",
|
||||
"dfu-util",
|
||||
"minicom",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Profile-specific native packages.
|
||||
PROFILE_PACKAGES: dict[str, dict[OsFamily, list[str]]] = {
|
||||
"embedded": {
|
||||
OsFamily.DEBIAN: [],
|
||||
OsFamily.RHEL: [],
|
||||
OsFamily.ARCH: [],
|
||||
OsFamily.MACOS: [],
|
||||
},
|
||||
"backend": {
|
||||
OsFamily.DEBIAN: ["nodejs", "npm"],
|
||||
OsFamily.RHEL: ["nodejs", "npm"],
|
||||
OsFamily.ARCH: ["nodejs", "npm"],
|
||||
OsFamily.MACOS: ["node"],
|
||||
},
|
||||
"frontend": {
|
||||
OsFamily.DEBIAN: ["nodejs", "npm"],
|
||||
OsFamily.RHEL: ["nodejs", "npm"],
|
||||
OsFamily.ARCH: ["nodejs", "npm"],
|
||||
OsFamily.MACOS: ["node"],
|
||||
},
|
||||
"hardware": {
|
||||
# KiCad and related EDA tooling (best-effort; users may need nightly/stable PPA).
|
||||
OsFamily.DEBIAN: ["kicad"],
|
||||
OsFamily.RHEL: [],
|
||||
OsFamily.ARCH: ["kicad"],
|
||||
OsFamily.MACOS: ["kicad"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Python packages installed via pip regardless of OS.
|
||||
PYTHON_PACKAGES: dict[str, list[str]] = {
|
||||
"embedded": ["pyserial", "esptool", "pytest", "pyyaml"],
|
||||
"backend": ["boto3", "requests", "pyyaml"],
|
||||
"frontend": [],
|
||||
"hardware": [],
|
||||
}
|
||||
|
||||
|
||||
def packages_for_profile(family: OsFamily, profile: str) -> list[str]:
|
||||
"""Return the native package list for a given OS family and profile."""
|
||||
base = list(BASE_PACKAGES.get(family, []))
|
||||
if profile == "full":
|
||||
for prof in ("embedded", "backend", "frontend", "hardware"):
|
||||
base.extend(PROFILE_PACKAGES.get(prof, {}).get(family, []))
|
||||
# Remove duplicates while preserving order.
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for pkg in base:
|
||||
if pkg not in seen:
|
||||
seen.add(pkg)
|
||||
deduped.append(pkg)
|
||||
return deduped
|
||||
return base + PROFILE_PACKAGES.get(profile, {}).get(family, [])
|
||||
|
||||
|
||||
def python_packages_for_profile(profile: str) -> list[str]:
|
||||
"""Return pip packages for a profile (or all profiles for 'full')."""
|
||||
if profile == "full":
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for pkgs in PYTHON_PACKAGES.values():
|
||||
for pkg in pkgs:
|
||||
if pkg not in seen:
|
||||
seen.add(pkg)
|
||||
result.append(pkg)
|
||||
return result
|
||||
return list(PYTHON_PACKAGES.get(profile, []))
|
||||
|
||||
|
||||
def package_manager_for(family: OsFamily) -> PackageManager | None:
|
||||
return PACKAGE_MANAGERS.get(family)
|
||||
62
.aurelio/provisioner/profiles.py
Normal file
62
.aurelio/provisioner/profiles.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Toolchain profile definitions for the Aurelio workstation provisioner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
"""A workstation provisioning profile."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
needs_esp_idf: bool = False
|
||||
needs_docker: bool = False
|
||||
needs_node: bool = False
|
||||
needs_github_ssh: bool = True
|
||||
clones_repos: bool = True
|
||||
|
||||
|
||||
PROFILES: dict[str, Profile] = {
|
||||
"embedded": Profile(
|
||||
name="embedded",
|
||||
description="Firmware development for ESP32-S3 (ESP-IDF, cross-compilation, flashing)",
|
||||
needs_esp_idf=True,
|
||||
needs_docker=True,
|
||||
needs_node=True,
|
||||
),
|
||||
"backend": Profile(
|
||||
name="backend",
|
||||
description="IoT backend, AWS infrastructure, Lambdas, telemetry pipelines",
|
||||
needs_docker=True,
|
||||
needs_node=True,
|
||||
),
|
||||
"frontend": Profile(
|
||||
name="frontend",
|
||||
description="Web dashboards and frontend tooling",
|
||||
needs_node=True,
|
||||
),
|
||||
"hardware": Profile(
|
||||
name="hardware",
|
||||
description="PCB design, KiCad, BOM management, EDA workflows",
|
||||
needs_docker=False,
|
||||
needs_node=False,
|
||||
),
|
||||
"full": Profile(
|
||||
name="full",
|
||||
description="Everything: embedded, backend, frontend, and hardware tooling",
|
||||
needs_esp_idf=True,
|
||||
needs_docker=True,
|
||||
needs_node=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
VALID_PROFILES = set(PROFILES.keys())
|
||||
|
||||
|
||||
def get_profile(name: str) -> Profile:
|
||||
if name not in PROFILES:
|
||||
raise ValueError(f"Unknown profile '{name}'. Valid profiles: {', '.join(VALID_PROFILES)}")
|
||||
return PROFILES[name]
|
||||
141
.aurelio/provisioner/renderer.py
Normal file
141
.aurelio/provisioner/renderer.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Render bootstrap shell scripts for the Aurelio workstation provisioner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from .detect import OsFamily, OsInfo, detect_os
|
||||
from .packages import package_manager_for, packages_for_profile, python_packages_for_profile
|
||||
from .profiles import VALID_PROFILES, get_profile
|
||||
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
|
||||
class ProvisionerError(Exception):
|
||||
"""Raised when provisioning requirements cannot be satisfied."""
|
||||
|
||||
|
||||
_machine_id = lambda name: hashlib.sha256(name.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
def _create_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _jinja_env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(_TEMPLATES_DIR),
|
||||
autoescape=select_autoescape(),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def render_bootstrap_script(
|
||||
os_info: OsInfo | None = None,
|
||||
machine_name: str = "",
|
||||
profile: str = "embedded",
|
||||
token: str = "",
|
||||
public_url: str = "https://savearth-workspace.portugalfuturista.org",
|
||||
esp_idf_version: str = "v5.5.4",
|
||||
) -> str:
|
||||
"""Render a self-contained bootstrap shell script.
|
||||
|
||||
Args:
|
||||
os_info: Detected OS information. If None, detects automatically.
|
||||
machine_name: Unique machine name. Required.
|
||||
profile: Toolchain profile (embedded, backend, frontend, hardware, full).
|
||||
token: One-time registration token. Generated if empty.
|
||||
public_url: Public savearth-workspace base URL.
|
||||
esp_idf_version: ESP-IDF branch/tag to install.
|
||||
|
||||
Returns:
|
||||
A shell script as a string.
|
||||
|
||||
Raises:
|
||||
ProvisionerError: If the OS family is unsupported or profile is invalid.
|
||||
"""
|
||||
if not machine_name:
|
||||
raise ProvisionerError("machine_name is required")
|
||||
|
||||
if profile not in VALID_PROFILES:
|
||||
raise ProvisionerError(
|
||||
f"Unknown profile '{profile}'. Valid: {', '.join(sorted(VALID_PROFILES))}"
|
||||
)
|
||||
|
||||
info = os_info or detect_os()
|
||||
family = info.family
|
||||
|
||||
if family == OsFamily.UNKNOWN:
|
||||
raise ProvisionerError(
|
||||
f"Unsupported operating system: {info.distro} {info.version}"
|
||||
)
|
||||
|
||||
prof = get_profile(profile)
|
||||
pkg_manager = package_manager_for(family)
|
||||
if pkg_manager is None and family not in (OsFamily.WSL,):
|
||||
raise ProvisionerError(f"No package manager defined for OS family {family.value}")
|
||||
|
||||
base_packages = packages_for_profile(family, profile)
|
||||
profile_packages: list[str] = []
|
||||
if family in (OsFamily.DEBIAN, OsFamily.RHEL, OsFamily.ARCH):
|
||||
# base_packages already includes profile packages from packages_for_profile,
|
||||
# so split them for nicer script output.
|
||||
base_only = packages_for_profile(family, "embedded") if profile != "embedded" else base_packages
|
||||
profile_only = [p for p in base_packages if p not in base_only]
|
||||
base_packages = base_only
|
||||
profile_packages = profile_only
|
||||
elif family == OsFamily.MACOS:
|
||||
profile_packages = []
|
||||
|
||||
context = {
|
||||
"os_family": family.value,
|
||||
"distro": info.distro,
|
||||
"version": info.version,
|
||||
"is_wsl": info.is_wsl,
|
||||
"machine_name": machine_name,
|
||||
"machine_id": _machine_id(machine_name),
|
||||
"token": token or _create_token(),
|
||||
"public_url": public_url.rstrip("/"),
|
||||
"esp_idf_version": esp_idf_version,
|
||||
"profile": profile,
|
||||
"generated_at": _now(),
|
||||
"package_manager": pkg_manager,
|
||||
"base_packages": base_packages,
|
||||
"profile_packages": profile_packages,
|
||||
"python_packages": python_packages_for_profile(profile),
|
||||
"needs_esp_idf": prof.needs_esp_idf,
|
||||
"needs_docker": prof.needs_docker,
|
||||
"needs_node": prof.needs_node,
|
||||
"needs_github_ssh": prof.needs_github_ssh,
|
||||
"clones_repos": prof.clones_repos,
|
||||
"node_install": "", # overridden in per-family templates
|
||||
}
|
||||
|
||||
env = _jinja_env()
|
||||
template_name = f"{family.value}.sh.j2"
|
||||
template = env.get_template(template_name)
|
||||
return template.render(context)
|
||||
|
||||
|
||||
def render_for_current_machine(
|
||||
machine_name: str = "",
|
||||
profile: str = "embedded",
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Convenience: render a script for the machine this function runs on."""
|
||||
info = detect_os()
|
||||
if not machine_name:
|
||||
import socket
|
||||
machine_name = socket.gethostname()
|
||||
return render_bootstrap_script(os_info=info, machine_name=machine_name, profile=profile, **kwargs)
|
||||
14
.aurelio/provisioner/templates/arch.sh.j2
Normal file
14
.aurelio/provisioner/templates/arch.sh.j2
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.sh.j2" %}
|
||||
|
||||
{% block post_package %}
|
||||
{% if needs_docker %}
|
||||
if ! command -v docker &>/dev/null; then
|
||||
info "Installing Docker..."
|
||||
sudo pacman -S --noconfirm docker docker-compose || warn "Docker install failed; install manually."
|
||||
sudo usermod -aG docker "$USER" || true
|
||||
sudo systemctl enable --now docker || true
|
||||
fi
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% set node_install = "sudo pacman -S --noconfirm nodejs npm" %}
|
||||
202
.aurelio/provisioner/templates/base.sh.j2
Normal file
202
.aurelio/provisioner/templates/base.sh.j2
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
#!/usr/bin/env bash
|
||||
# Aurelio workstation bootstrap
|
||||
# OS family: {{ os_family }}
|
||||
# Distribution: {{ distro }}
|
||||
# Profile: {{ profile }}
|
||||
# Machine: {{ machine_name }}
|
||||
# Generated: {{ generated_at }}
|
||||
set -euo pipefail
|
||||
|
||||
MACHINE_NAME="{{ machine_name }}"
|
||||
MACHINE_ID="{{ machine_id }}"
|
||||
TOKEN="{{ token }}"
|
||||
PUBLIC_URL="{{ public_url }}"
|
||||
SAVEARTH_DIR="${SAVEARTH_DIR:-$HOME/savearth}"
|
||||
ESP_IDF_VERSION="{{ esp_idf_version }}"
|
||||
PROFILE="{{ profile }}"
|
||||
|
||||
log() { echo -e "\033[0;32m[✓]\033[0m $*"; }
|
||||
warn() { echo -e "\033[1;33m[!]\033[0m $*"; }
|
||||
err() { echo -e "\033[0;31m[✗]\033[0m $*" >&2; }
|
||||
info() { echo -e "\033[0;34m[i]\033[0m $*"; }
|
||||
|
||||
echo "🔧 Bootstrapping savearth workstation: $MACHINE_NAME"
|
||||
echo " OS family: {{ os_family }}"
|
||||
echo " Distribution: {{ distro }}"
|
||||
echo " Profile: $PROFILE"
|
||||
|
||||
{% block pre_package %}{% endblock %}
|
||||
|
||||
# ─── 1. System packages ───────────────────────────────────────────────
|
||||
info "Updating package index..."
|
||||
{{ package_manager.update_cmd }}
|
||||
|
||||
info "Installing base system packages..."
|
||||
{{ package_manager.install_cmd }} {{ base_packages | join(' ') }}
|
||||
|
||||
{% if profile_packages %}
|
||||
info "Installing profile packages for {{ profile }}..."
|
||||
{{ package_manager.install_cmd }} {{ profile_packages | join(' ') }}
|
||||
{% endif %}
|
||||
|
||||
{% block post_package %}{% endblock %}
|
||||
|
||||
{% if needs_node %}
|
||||
# ─── 2. Node.js / npx for MCP remote clients ──────────────────────────
|
||||
if ! command -v npx &>/dev/null; then
|
||||
info "Installing Node.js..."
|
||||
{{ node_install }}
|
||||
fi
|
||||
{% endif %}
|
||||
|
||||
{% if needs_docker %}
|
||||
# ─── 3. Docker ────────────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker not found. Please install Docker for your distribution."
|
||||
warn "See: https://docs.docker.com/engine/install/"
|
||||
fi
|
||||
{% endif %}
|
||||
|
||||
{% if needs_esp_idf %}
|
||||
# ─── 4. ESP-IDF ───────────────────────────────────────────────────────
|
||||
install_esp_idf() {
|
||||
local idf_dir="$HOME/esp/${ESP_IDF_VERSION}/esp-idf"
|
||||
if [ -d "$idf_dir" ]; then
|
||||
log "ESP-IDF already present at $idf_dir"
|
||||
else
|
||||
info "Installing ESP-IDF ${ESP_IDF_VERSION}..."
|
||||
mkdir -p "$HOME/esp/${ESP_IDF_VERSION}"
|
||||
git clone -b "${ESP_IDF_VERSION}" --recursive \
|
||||
https://github.com/espressif/esp-idf.git "$idf_dir"
|
||||
cd "$idf_dir"
|
||||
./install.sh esp32s3
|
||||
fi
|
||||
if ! grep -q "IDF_PATH=\"$idf_dir\"" "$HOME/.bashrc" 2>/dev/null; then
|
||||
echo "export IDF_PATH=\"$idf_dir\"" >> "$HOME/.bashrc"
|
||||
echo 'source "$IDF_PATH/export.sh" > /dev/null 2>&1' >> "$HOME/.bashrc"
|
||||
fi
|
||||
export IDF_PATH="$idf_dir"
|
||||
}
|
||||
install_esp_idf
|
||||
{% endif %}
|
||||
|
||||
# ─── 5. GitHub SSH auth ───────────────────────────────────────────────
|
||||
setup_github() {
|
||||
if [ ! -f "$HOME/.ssh/id_ed25519" ]; then
|
||||
info "Generating SSH key for GitHub..."
|
||||
mkdir -p "$HOME/.ssh"
|
||||
ssh-keygen -t ed25519 -C "$MACHINE_NAME@savearth" -f "$HOME/.ssh/id_ed25519" -N ""
|
||||
eval "$(ssh-agent -s)" || true
|
||||
ssh-add "$HOME/.ssh/id_ed25519" 2>/dev/null || true
|
||||
fi
|
||||
if [ -f "$HOME/.ssh/id_ed25519.pub" ]; then
|
||||
log "GitHub SSH public key:"
|
||||
cat "$HOME/.ssh/id_ed25519.pub"
|
||||
warn "If this key is not yet added to GitHub, add it now: https://github.com/settings/keys"
|
||||
if [ -t 0 ]; then
|
||||
read -rp "Press Enter after verifying the key is added to GitHub..."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
setup_github
|
||||
|
||||
{% if clones_repos %}
|
||||
# ─── 6. Clone savearth repositories ───────────────────────────────────
|
||||
clone_or_pull() {
|
||||
local repo="$1"
|
||||
local dir="$2"
|
||||
if [ -d "$dir/.git" ]; then
|
||||
log "Pulling $repo..."
|
||||
git -C "$dir" pull
|
||||
else
|
||||
log "Cloning $repo..."
|
||||
git clone "git@github.com:$repo.git" "$dir"
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p "$SAVEARTH_DIR"
|
||||
cd "$SAVEARTH_DIR"
|
||||
|
||||
clone_or_pull SavearthTech/aws-iot-core-poc aws-iot-core-poc
|
||||
clone_or_pull SavearthTech/savearth-iot-infrastructure savearth-iot-infrastructure
|
||||
clone_or_pull SavearthTech/savearth-hw-project savearth-hw-project
|
||||
clone_or_pull SavearthTech/hardware-devicesFirmwareTest hardware-devicesFirmwareTest
|
||||
|
||||
# ─── 7. Initialize central-brain submodules ───────────────────────────
|
||||
for dir in aws-iot-core-poc savearth-iot-infrastructure savearth-hw-project hardware-devicesFirmwareTest; do
|
||||
log "Initializing replica-omnisciente submodule in $dir..."
|
||||
git -C "$dir" submodule update --init --recursive replica-omnisciente || warn "Submodule init failed for $dir"
|
||||
done
|
||||
{% endif %}
|
||||
|
||||
# ─── 8. Python tooling dependencies ───────────────────────────────────
|
||||
{% if python_packages %}
|
||||
info "Installing Python tooling dependencies..."
|
||||
python3 -m pip install --user --upgrade {{ python_packages | join(' ') }} || warn "Some pip packages failed to install"
|
||||
{% endif %}
|
||||
|
||||
# ─── 9. Global Aurelio config ─────────────────────────────────────────
|
||||
mkdir -p "$HOME/.aurelio"
|
||||
if [ ! -f "$HOME/.aurelio/config.toml" ]; then
|
||||
log "Writing ~/.aurelio/config.toml..."
|
||||
cat > "$HOME/.aurelio/config.toml" << 'TOML'
|
||||
[sync]
|
||||
enabled = true
|
||||
endpoint = "https://mcp.portugalfuturista.org"
|
||||
interval_seconds = 300
|
||||
auto_push = true
|
||||
|
||||
[identity]
|
||||
name = "savearth Developer Workstation"
|
||||
realm = "smart-device-firmware"
|
||||
|
||||
[models]
|
||||
default_local = "qwen2.5-coder:14b"
|
||||
default_cloud = "gemini-2.5-pro"
|
||||
ollama_url = "http://127.0.0.1:11434"
|
||||
|
||||
[brain]
|
||||
auto_save = true
|
||||
artifact_types = ["task", "implementation_plan", "walkthrough", "analysis"]
|
||||
TOML
|
||||
else
|
||||
info "~/.aurelio/config.toml already exists; skipping."
|
||||
fi
|
||||
|
||||
# ─── 10. Register machine and pull brain snapshot ─────────────────────
|
||||
info "Registering workstation with savearth-workspace..."
|
||||
curl -sS -X POST "$PUBLIC_URL/api/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"machine_name":"'$MACHINE_NAME'","os":"{{ os_family }}","token":"'$TOKEN'"}' \
|
||||
-o /tmp/register.json || true
|
||||
if [ -s /tmp/register.json ]; then
|
||||
cat /tmp/register.json
|
||||
else
|
||||
warn "Online registration skipped (server may require auth)."
|
||||
fi
|
||||
|
||||
info "Checking for brain snapshots..."
|
||||
curl -sS "$PUBLIC_URL/api/snapshots/latest?machine_id=$MACHINE_ID" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-o /tmp/latest_snapshot.json || true
|
||||
|
||||
if [ -s /tmp/latest_snapshot.json ]; then
|
||||
SNAPSHOT_URL=$(python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('download_url',''))" < /tmp/latest_snapshot.json)
|
||||
if [ -n "$SNAPSHOT_URL" ]; then
|
||||
log "Restoring brain snapshot..."
|
||||
curl -sS -H "Authorization: Bearer $TOKEN" "$SNAPSHOT_URL" -o /tmp/brain.tar.gz
|
||||
for dir in aws-iot-core-poc savearth-iot-infrastructure savearth-hw-project hardware-devicesFirmwareTest; do
|
||||
[ -d "$dir/replica-omnisciente/.aurelio/brain" ] && \
|
||||
tar xzf /tmp/brain.tar.gz -C "$dir/replica-omnisciente/.aurelio" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Done ─────────────────────────────────────────────────────────────
|
||||
log "Bootstrap complete for machine: $MACHINE_NAME"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Start a new shell or run: source ~/.bashrc"
|
||||
echo " 2. cd $SAVEARTH_DIR/aws-iot-core-poc"
|
||||
echo " 3. python3 tools/scripts/build_tool.py env"
|
||||
echo " 4. Open dashboard: $PUBLIC_URL/dashboard"
|
||||
17
.aurelio/provisioner/templates/debian.sh.j2
Normal file
17
.aurelio/provisioner/templates/debian.sh.j2
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{% extends "base.sh.j2" %}
|
||||
|
||||
{% block post_package %}
|
||||
{% if needs_docker %}
|
||||
if ! command -v docker &>/dev/null; then
|
||||
info "Installing Docker (best-effort via convenience script)..."
|
||||
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh || true
|
||||
if [ -f /tmp/get-docker.sh ]; then
|
||||
sh /tmp/get-docker.sh || warn "Docker install script failed; install manually."
|
||||
sudo usermod -aG docker "$USER" || true
|
||||
sudo systemctl enable --now docker || true
|
||||
fi
|
||||
fi
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% set node_install = "sudo apt-get install -y nodejs npm" %}
|
||||
20
.aurelio/provisioner/templates/macos.sh.j2
Normal file
20
.aurelio/provisioner/templates/macos.sh.j2
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.sh.j2" %}
|
||||
|
||||
{% block pre_package %}
|
||||
if ! command -v brew &>/dev/null; then
|
||||
err "Homebrew is required for macOS provisioning."
|
||||
err "Install from https://brew.sh and re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
{% endblock %}
|
||||
|
||||
{% block post_package %}
|
||||
{% if needs_docker %}
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker not found. Install Docker Desktop for Mac manually."
|
||||
warn "See: https://docs.docker.com/desktop/setup/install/mac-install/"
|
||||
fi
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% set node_install = "brew install node" %}
|
||||
14
.aurelio/provisioner/templates/rhel.sh.j2
Normal file
14
.aurelio/provisioner/templates/rhel.sh.j2
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.sh.j2" %}
|
||||
|
||||
{% block post_package %}
|
||||
{% if needs_docker %}
|
||||
if ! command -v docker &>/dev/null; then
|
||||
info "Installing Docker..."
|
||||
sudo dnf install -y docker docker-compose || warn "Docker install failed; install manually."
|
||||
sudo usermod -aG docker "$USER" || true
|
||||
sudo systemctl enable --now docker || true
|
||||
fi
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% set node_install = "sudo dnf install -y nodejs" %}
|
||||
215
.aurelio/provisioner/templates/wsl.sh.j2
Normal file
215
.aurelio/provisioner/templates/wsl.sh.j2
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
#!/usr/bin/env bash
|
||||
# Aurelio workstation bootstrap for Windows Subsystem for Linux (WSL)
|
||||
# Underlying distribution: {{ distro }}
|
||||
# Profile: {{ profile }}
|
||||
# Machine: {{ machine_name }}
|
||||
# Generated: {{ generated_at }}
|
||||
set -euo pipefail
|
||||
|
||||
MACHINE_NAME="{{ machine_name }}"
|
||||
MACHINE_ID="{{ machine_id }}"
|
||||
TOKEN="{{ token }}"
|
||||
PUBLIC_URL="{{ public_url }}"
|
||||
SAVEARTH_DIR="${SAVEARTH_DIR:-$HOME/savearth}"
|
||||
ESP_IDF_VERSION="{{ esp_idf_version }}"
|
||||
PROFILE="{{ profile }}"
|
||||
|
||||
log() { echo -e "\033[0;32m[✓]\033[0m $*"; }
|
||||
warn() { echo -e "\033[1;33m[!]\033[0m $*"; }
|
||||
err() { echo -e "\033[0;31m[✗]\033[0m $*" >&2; }
|
||||
info() { echo -e "\033[0;34m[i]\033[0m $*"; }
|
||||
|
||||
echo "🔧 Bootstrapping savearth WSL workstation: $MACHINE_NAME"
|
||||
echo " Underlying distribution: {{ distro }}"
|
||||
echo " Profile: $PROFILE"
|
||||
echo ""
|
||||
echo "⚠️ WSL note: USB serial devices (/dev/ttyACM*) are not directly accessible."
|
||||
echo " Install usbipd-win on Windows and attach devices when flashing:"
|
||||
echo " https://learn.microsoft.com/en-us/windows/wsl/connect-usb"
|
||||
echo ""
|
||||
|
||||
# Detect package manager from the underlying distro
|
||||
if command -v apt-get &>/dev/null; then
|
||||
PKG_UPDATE="sudo apt-get update"
|
||||
PKG_INSTALL="sudo apt-get install -y --no-install-recommends"
|
||||
NODE_INSTALL="sudo apt-get install -y nodejs npm"
|
||||
DOCKER_INSTALL="curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && sh /tmp/get-docker.sh"
|
||||
elif command -v dnf &>/dev/null; then
|
||||
PKG_UPDATE="sudo dnf update -y"
|
||||
PKG_INSTALL="sudo dnf install -y"
|
||||
NODE_INSTALL="sudo dnf install -y nodejs"
|
||||
DOCKER_INSTALL="sudo dnf install -y docker docker-compose"
|
||||
else
|
||||
err "Unsupported WSL distribution: {{ distro }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── 1. System packages ───────────────────────────────────────────────
|
||||
info "Updating package index..."
|
||||
$PKG_UPDATE
|
||||
info "Installing base system packages..."
|
||||
$PKG_INSTALL {{ base_packages | join(' ') }}
|
||||
{% if profile_packages %}
|
||||
info "Installing profile packages for {{ profile }}..."
|
||||
$PKG_INSTALL {{ profile_packages | join(' ') }}
|
||||
{% endif %}
|
||||
|
||||
{% if needs_node %}
|
||||
# ─── 2. Node.js / npx ─────────────────────────────────────────────────
|
||||
if ! command -v npx &>/dev/null; then
|
||||
info "Installing Node.js..."
|
||||
$NODE_INSTALL
|
||||
fi
|
||||
{% endif %}
|
||||
|
||||
{% if needs_docker %}
|
||||
# ─── 3. Docker ────────────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
info "Installing Docker..."
|
||||
$DOCKER_INSTALL || warn "Docker install failed; install manually."
|
||||
fi
|
||||
{% endif %}
|
||||
|
||||
{% if needs_esp_idf %}
|
||||
# ─── 4. ESP-IDF ───────────────────────────────────────────────────────
|
||||
install_esp_idf() {
|
||||
local idf_dir="$HOME/esp/${ESP_IDF_VERSION}/esp-idf"
|
||||
if [ -d "$idf_dir" ]; then
|
||||
log "ESP-IDF already present at $idf_dir"
|
||||
else
|
||||
info "Installing ESP-IDF ${ESP_IDF_VERSION}..."
|
||||
mkdir -p "$HOME/esp/${ESP_IDF_VERSION}"
|
||||
git clone -b "${ESP_IDF_VERSION}" --recursive \
|
||||
https://github.com/espressif/esp-idf.git "$idf_dir"
|
||||
cd "$idf_dir"
|
||||
./install.sh esp32s3
|
||||
fi
|
||||
if ! grep -q "IDF_PATH=\"$idf_dir\"" "$HOME/.bashrc" 2>/dev/null; then
|
||||
echo "export IDF_PATH=\"$idf_dir\"" >> "$HOME/.bashrc"
|
||||
echo 'source "$IDF_PATH/export.sh" > /dev/null 2>&1' >> "$HOME/.bashrc"
|
||||
fi
|
||||
export IDF_PATH="$idf_dir"
|
||||
}
|
||||
install_esp_idf
|
||||
{% endif %}
|
||||
|
||||
# ─── 5. GitHub SSH auth ───────────────────────────────────────────────
|
||||
setup_github() {
|
||||
if [ ! -f "$HOME/.ssh/id_ed25519" ]; then
|
||||
info "Generating SSH key for GitHub..."
|
||||
mkdir -p "$HOME/.ssh"
|
||||
ssh-keygen -t ed25519 -C "$MACHINE_NAME@savearth" -f "$HOME/.ssh/id_ed25519" -N ""
|
||||
eval "$(ssh-agent -s)" || true
|
||||
ssh-add "$HOME/.ssh/id_ed25519" 2>/dev/null || true
|
||||
fi
|
||||
if [ -f "$HOME/.ssh/id_ed25519.pub" ]; then
|
||||
log "GitHub SSH public key:"
|
||||
cat "$HOME/.ssh/id_ed25519.pub"
|
||||
warn "If this key is not yet added to GitHub, add it now: https://github.com/settings/keys"
|
||||
if [ -t 0 ]; then
|
||||
read -rp "Press Enter after verifying the key is added to GitHub..."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
setup_github
|
||||
|
||||
{% if clones_repos %}
|
||||
# ─── 6. Clone savearth repositories ───────────────────────────────────
|
||||
clone_or_pull() {
|
||||
local repo="$1"
|
||||
local dir="$2"
|
||||
if [ -d "$dir/.git" ]; then
|
||||
log "Pulling $repo..."
|
||||
git -C "$dir" pull
|
||||
else
|
||||
log "Cloning $repo..."
|
||||
git clone "git@github.com:$repo.git" "$dir"
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p "$SAVEARTH_DIR"
|
||||
cd "$SAVEARTH_DIR"
|
||||
|
||||
clone_or_pull SavearthTech/aws-iot-core-poc aws-iot-core-poc
|
||||
clone_or_pull SavearthTech/savearth-iot-infrastructure savearth-iot-infrastructure
|
||||
clone_or_pull SavearthTech/savearth-hw-project savearth-hw-project
|
||||
clone_or_pull SavearthTech/hardware-devicesFirmwareTest hardware-devicesFirmwareTest
|
||||
|
||||
# ─── 7. Initialize central-brain submodules ───────────────────────────
|
||||
for dir in aws-iot-core-poc savearth-iot-infrastructure savearth-hw-project hardware-devicesFirmwareTest; do
|
||||
log "Initializing replica-omnisciente submodule in $dir..."
|
||||
git -C "$dir" submodule update --init --recursive replica-omnisciente || warn "Submodule init failed for $dir"
|
||||
done
|
||||
{% endif %}
|
||||
|
||||
# ─── 8. Python tooling dependencies ───────────────────────────────────
|
||||
{% if python_packages %}
|
||||
info "Installing Python tooling dependencies..."
|
||||
python3 -m pip install --user --upgrade {{ python_packages | join(' ') }} || warn "Some pip packages failed to install"
|
||||
{% endif %}
|
||||
|
||||
# ─── 9. Global Aurelio config ─────────────────────────────────────────
|
||||
mkdir -p "$HOME/.aurelio"
|
||||
if [ ! -f "$HOME/.aurelio/config.toml" ]; then
|
||||
log "Writing ~/.aurelio/config.toml..."
|
||||
cat > "$HOME/.aurelio/config.toml" << 'TOML'
|
||||
[sync]
|
||||
enabled = true
|
||||
endpoint = "https://mcp.portugalfuturista.org"
|
||||
interval_seconds = 300
|
||||
auto_push = true
|
||||
|
||||
[identity]
|
||||
name = "savearth Developer Workstation"
|
||||
realm = "smart-device-firmware"
|
||||
|
||||
[models]
|
||||
default_local = "qwen2.5-coder:14b"
|
||||
default_cloud = "gemini-2.5-pro"
|
||||
ollama_url = "http://127.0.0.1:11434"
|
||||
|
||||
[brain]
|
||||
auto_save = true
|
||||
artifact_types = ["task", "implementation_plan", "walkthrough", "analysis"]
|
||||
TOML
|
||||
else
|
||||
info "~/.aurelio/config.toml already exists; skipping."
|
||||
fi
|
||||
|
||||
# ─── 10. Register machine and pull brain snapshot ─────────────────────
|
||||
info "Registering workstation with savearth-workspace..."
|
||||
curl -sS -X POST "$PUBLIC_URL/api/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"machine_name":"'$MACHINE_NAME'","os":"wsl","token":"'$TOKEN'"}' \
|
||||
-o /tmp/register.json || true
|
||||
if [ -s /tmp/register.json ]; then
|
||||
cat /tmp/register.json
|
||||
else
|
||||
warn "Online registration skipped (server may require auth)."
|
||||
fi
|
||||
|
||||
info "Checking for brain snapshots..."
|
||||
curl -sS "$PUBLIC_URL/api/snapshots/latest?machine_id=$MACHINE_ID" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-o /tmp/latest_snapshot.json || true
|
||||
|
||||
if [ -s /tmp/latest_snapshot.json ]; then
|
||||
SNAPSHOT_URL=$(python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('download_url',''))" < /tmp/latest_snapshot.json)
|
||||
if [ -n "$SNAPSHOT_URL" ]; then
|
||||
log "Restoring brain snapshot..."
|
||||
curl -sS -H "Authorization: Bearer $TOKEN" "$SNAPSHOT_URL" -o /tmp/brain.tar.gz
|
||||
for dir in aws-iot-core-poc savearth-iot-infrastructure savearth-hw-project hardware-devicesFirmwareTest; do
|
||||
[ -d "$dir/replica-omnisciente/.aurelio/brain" ] && \
|
||||
tar xzf /tmp/brain.tar.gz -C "$dir/replica-omnisciente/.aurelio" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Done ─────────────────────────────────────────────────────────────
|
||||
log "Bootstrap complete for machine: $MACHINE_NAME"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Start a new shell or run: source ~/.bashrc"
|
||||
echo " 2. cd $SAVEARTH_DIR/aws-iot-core-poc"
|
||||
echo " 3. python3 tools/scripts/build_tool.py env"
|
||||
echo " 4. Open dashboard: $PUBLIC_URL/dashboard"
|
||||
0
.aurelio/provisioner/tests/__init__.py
Normal file
0
.aurelio/provisioner/tests/__init__.py
Normal file
29
.aurelio/provisioner/tests/test_detect.py
Normal file
29
.aurelio/provisioner/tests/test_detect.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Tests for OS family detection."""
|
||||
|
||||
import pytest
|
||||
|
||||
from provisioner.detect import OsFamily, OsInfo, _map_linux_distro, _read_os_release
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"os_id, id_like, expected",
|
||||
[
|
||||
("ubuntu", "debian", OsFamily.DEBIAN),
|
||||
("debian", "", OsFamily.DEBIAN),
|
||||
("linuxmint", "debian ubuntu", OsFamily.DEBIAN),
|
||||
("fedora", "", OsFamily.RHEL),
|
||||
("rocky", "rhel centos fedora", OsFamily.RHEL),
|
||||
("rhel", "", OsFamily.RHEL),
|
||||
("arch", "", OsFamily.ARCH),
|
||||
("manjaro", "arch", OsFamily.ARCH),
|
||||
("gentoo", "", OsFamily.UNKNOWN),
|
||||
],
|
||||
)
|
||||
def test_map_linux_distro(os_id: str, id_like: str, expected: OsFamily):
|
||||
release = {"ID": os_id, "ID_LIKE": id_like}
|
||||
assert _map_linux_distro(release) == expected
|
||||
|
||||
|
||||
def test_os_info_is_linux():
|
||||
assert OsInfo(OsFamily.DEBIAN, "ubuntu", "24.04", False).is_linux
|
||||
assert not OsInfo(OsFamily.MACOS, "macos", "14", False).is_linux
|
||||
56
.aurelio/provisioner/tests/test_renderer.py
Normal file
56
.aurelio/provisioner/tests/test_renderer.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Tests for bootstrap script rendering."""
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from provisioner import ProvisionerError, render_bootstrap_script
|
||||
from provisioner.detect import OsFamily, OsInfo
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"family, profile",
|
||||
[
|
||||
(OsFamily.DEBIAN, "embedded"),
|
||||
(OsFamily.RHEL, "embedded"),
|
||||
(OsFamily.ARCH, "embedded"),
|
||||
(OsFamily.MACOS, "embedded"),
|
||||
(OsFamily.WSL, "embedded"),
|
||||
(OsFamily.DEBIAN, "full"),
|
||||
(OsFamily.RHEL, "backend"),
|
||||
(OsFamily.RHEL, "hardware"),
|
||||
],
|
||||
)
|
||||
def test_rendered_script_is_valid_bash(family: OsFamily, profile: str):
|
||||
os_info = OsInfo(family, family.value, "test", family == OsFamily.WSL)
|
||||
script = render_bootstrap_script(
|
||||
os_info=os_info,
|
||||
machine_name="test-laptop",
|
||||
profile=profile,
|
||||
token="test-token",
|
||||
)
|
||||
assert script.startswith("#!/usr/bin/env bash")
|
||||
result = subprocess.run(["bash", "-n"], input=script, text=True, capture_output=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_invalid_profile_raises():
|
||||
os_info = OsInfo(OsFamily.DEBIAN, "debian", "", False)
|
||||
with pytest.raises(ProvisionerError):
|
||||
render_bootstrap_script(os_info=os_info, machine_name="x", profile="not-a-profile")
|
||||
|
||||
|
||||
def test_empty_machine_name_raises():
|
||||
os_info = OsInfo(OsFamily.DEBIAN, "debian", "", False)
|
||||
with pytest.raises(ProvisionerError):
|
||||
render_bootstrap_script(os_info=os_info, machine_name="", profile="embedded")
|
||||
|
||||
|
||||
def test_family_package_lists_are_present():
|
||||
from provisioner.packages import BASE_PACKAGES, PROFILE_PACKAGES
|
||||
|
||||
for family in (OsFamily.DEBIAN, OsFamily.RHEL, OsFamily.ARCH, OsFamily.MACOS):
|
||||
assert BASE_PACKAGES.get(family), f"Missing base packages for {family}"
|
||||
for profile in ("embedded", "backend", "frontend", "hardware"):
|
||||
assert profile in PROFILE_PACKAGES, f"Missing profile {profile}"
|
||||
assert family in PROFILE_PACKAGES[profile], f"Missing {family} in {profile}"
|
||||
Loading…
Reference in a new issue