Compare commits

...
8 Commits
5 changed files with 199 additions and 94 deletions

No files matched your search

+80 -10
View File
@@ -87,17 +87,87 @@ async def run_backup_task(
remote_commands = [ remote_commands = [
f'echo "=== STEP 1: Creating Hot-Snapshot on Remote VPS ({vps_host}) ==="', f'echo "=== STEP 1: Creating Hot-Snapshot on Remote VPS ({vps_host}) ==="',
f'mkdir -p "{remote_stage}/pangolin"', f'mkdir -p "{remote_stage}/pangolin"',
f'echo "--- Staging Pangolin files from {p_dir} ---"', f'''
f'if [ -d "{p_dir}" ]; then rsync -av --exclude="*.db" --exclude="*.db-wal" --exclude="*.db-shm" "{p_dir}/" "{remote_stage}/pangolin/"; fi', eval P_DIR="{p_dir}"
f'echo "--- Performing zero-lock hot-backup of SQLite databases on VPS ---"', P_DIR="${{P_DIR/#~/$HOME}}"
f"""
for db in $(find "{p_dir}" -name "*.db" -o -name "*.sqlite" 2>/dev/null); do # Auto-discovery if configured directory does not exist or is empty
rel=$(realpath --relative-to="{p_dir}" "$db") if [ ! -d "$P_DIR" ] || [ -z "$(ls -A "$P_DIR" 2>/dev/null)" ]; then
mkdir -p "{remote_stage}/pangolin/$(dirname "$rel")" echo "Notice: Configured path $P_DIR is empty or does not exist."
echo "Hot-backing up $rel..." echo "Searching VPS for Pangolin Docker compose stack..."
sqlite3 "$db" ".backup '{remote_stage}/pangolin/$rel'" 2>/dev/null || cp -v "$db" "{remote_stage}/pangolin/$rel" DETECTED=$(docker inspect --format '{{{{index .Config.Labels "com.docker.compose.project.working_dir"}}}}' $(docker ps -aq --filter "name=pangolin" 2>/dev/null) 2>/dev/null | head -n 1)
if [ -z "$DETECTED" ] || [ ! -d "$DETECTED" ]; then
for candidate in "/opt/pangolin" "$HOME/pangolin" "/root/pangolin" "$HOME/config" "/root/config" "$HOME" "/root"; do
if [ -d "$candidate" ] && [ -n "$(ls -A "$candidate" 2>/dev/null)" ]; then
DETECTED="$candidate"
break
fi
done done
""" fi
if [ -n "$DETECTED" ] && [ -d "$DETECTED" ]; then
echo "--> Auto-discovered active Pangolin directory at: $DETECTED"
P_DIR="$DETECTED"
fi
fi
# Standard HHF Pangolin stack items
BACKUP_ITEMS=("docker-compose.yml" "compose.yaml" "docker-compose.yaml" ".env" "config" "crowdsec" "traefik")
echo "--- Staging Pangolin files from $P_DIR ---"
if [ -d "$P_DIR" ]; then
if [ -f "$P_DIR/docker-compose.yml" ] || [ -d "$P_DIR/config" ]; then
echo "Detected Pangolin stack root at $P_DIR"
for item in "${{BACKUP_ITEMS[@]}}"; do
if [ -e "$P_DIR/$item" ]; then
echo "Staging $item..."
mkdir -p "{remote_stage}/pangolin/$(dirname "$item")"
cp -a "$P_DIR/$item" "{remote_stage}/pangolin/$item" 2>/dev/null || true
fi
done
else
echo "Staging contents of $P_DIR into {remote_stage}/pangolin/config/..."
mkdir -p "{remote_stage}/pangolin/config"
rsync -av --exclude="*.db" --exclude="*.db-wal" --exclude="*.db-shm" "$P_DIR/" "{remote_stage}/pangolin/config/"
# Check parent folder for companion compose and env files
PARENT_DIR="$(dirname "$P_DIR")"
for extra in docker-compose.yml compose.yaml docker-compose.yaml .env; do
if [ -f "$PARENT_DIR/$extra" ]; then
echo "--- Staging companion $extra from $PARENT_DIR ---"
cp "$PARENT_DIR/$extra" "{remote_stage}/pangolin/" 2>/dev/null || true
fi
done
fi
fi
echo "--- Performing zero-lock hot-backup of SQLite databases on VPS ---"
if [ -d "$P_DIR/config" ]; then
TARGET_BASE="{remote_stage}/pangolin/config"
SEARCH_BASE="$P_DIR/config"
else
TARGET_BASE="{remote_stage}/pangolin"
SEARCH_BASE="$P_DIR"
fi
for db in $(find "$SEARCH_BASE" -maxdepth 4 -name "*.db" -o -name "*.sqlite" 2>/dev/null); do
rel=$(realpath --relative-to="$SEARCH_BASE" "$db")
mkdir -p "$TARGET_BASE/$(dirname "$rel")"
echo "Hot-backing up SQLite database: $rel..."
sqlite3 "$db" ".backup '$TARGET_BASE/$rel'" 2>/dev/null || cp -v "$db" "$TARGET_BASE/$rel"
done
# Clean dangling lock/WAL files in staging archive
find "" -name "*.db-wal" -delete -o -name "*.db-shm" -delete 2>/dev/null || true
# Backup any named Docker volumes attached to pangolin containers
for vol in $(docker inspect --format '{{{{range .Mounts}}}}{{{{if eq .Type "volume"}}}}{{{{.Source}}}}{{{{"\\n"}}}}{{{{end}}}}{{{{end}}}}' $(docker ps -aq --filter "name=pangolin" 2>/dev/null) 2>/dev/null); do
if [ -d "$vol" ] && [ -n "$(ls -A "$vol" 2>/dev/null)" ]; then
vname=$(basename "$vol")
echo "--- Staging Docker Volume: $vname from $vol ---"
mkdir -p "{remote_stage}/pangolin/volumes/$vname"
cp -a "$vol/." "{remote_stage}/pangolin/volumes/$vname/" 2>/dev/null || true
fi
done
'''
] ]
if scope in ["vps_config", "full"]: if scope in ["vps_config", "full"]:
+56 -9
View File
@@ -31,11 +31,29 @@ async def get_pangolin_stack_status() -> Dict[str, Any]:
# Query Remote VPS for files, DB, and Docker status # Query Remote VPS for files, DB, and Docker status
check_script = f""" check_script = f"""
[ -f "{p_dir}/docker-compose.yml" ] || [ -f "{p_dir}/compose.yaml" ] && echo "COMPOSE_EXISTS" || echo "COMPOSE_NO" eval P_DIR="{p_dir}"
[ -f "{p_dir}/config/config.yml" ] || [ -f "{p_dir}/config.yml" ] && echo "CONFIG_EXISTS" || echo "CONFIG_NO" P_DIR="${{P_DIR/#~/$HOME}}"
if [ ! -d "$P_DIR" ] || [ -z "$(ls -A "$P_DIR" 2>/dev/null)" ]; then
DETECTED=$(docker inspect --format '{{{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}}}' $(docker ps -aq --filter "name=pangolin" 2>/dev/null) 2>/dev/null | head -n 1)
if [ -z "$DETECTED" ] || [ ! -d "$DETECTED" ]; then
for candidate in "$HOME/config" "/root/config" "$HOME/pangolin" "/root/pangolin" "$HOME" "/opt/pangolin" /home/*/pangolin /var/lib/pangolin /srv/pangolin /etc/pangolin; do
if [ -d "$candidate" ] && [ -n "$(ls -A "$candidate" 2>/dev/null)" ]; then
DETECTED="$candidate"
break
fi
done
fi
if [ -n "$DETECTED" ] && [ -d "$DETECTED" ]; then
P_DIR="$DETECTED"
fi
fi
echo "RESOLVED_DIR:$P_DIR"
[ -f "$P_DIR/docker-compose.yml" ] || [ -f "$P_DIR/compose.yaml" ] && echo "COMPOSE_EXISTS" || echo "COMPOSE_NO"
[ -f "$P_DIR/config/config.yml" ] || [ -f "$P_DIR/config.yml" ] && echo "CONFIG_EXISTS" || echo "CONFIG_NO"
# Check DB # Check DB
db=$(find "{p_dir}" -name "*.db" -o -name "*.sqlite" 2>/dev/null | head -n 1) db=$(find "$P_DIR" -name "*.db" -o -name "*.sqlite" 2>/dev/null | head -n 1)
if [ -n "$db" ]; then if [ -n "$db" ]; then
echo "DB_PATH:$db" echo "DB_PATH:$db"
wc -c "$db" 2>/dev/null | awk '{{print $1}}' wc -c "$db" 2>/dev/null | awk '{{print $1}}'
@@ -45,7 +63,7 @@ else
fi fi
# Docker containers # Docker containers
docker ps -a --format '{{json .}}' 2>/dev/null || true docker ps -a --format '{{{{json .}}}}' 2>/dev/null || true
""" """
ret, stdout, stderr = await run_remote_quick(check_script, settings) ret, stdout, stderr = await run_remote_quick(check_script, settings)
@@ -54,7 +72,7 @@ docker ps -a --format '{{json .}}' 2>/dev/null || true
"vps_configured": True, "vps_configured": True,
"connected": False, "connected": False,
"installed": False, "installed": False,
"path": p_dir, "path": resolved_path,
"compose_exists": False, "compose_exists": False,
"config_exists": False, "config_exists": False,
"database": {"found": False, "path": None, "size_bytes": 0}, "database": {"found": False, "path": None, "size_bytes": 0},
@@ -66,6 +84,12 @@ docker ps -a --format '{{json .}}' 2>/dev/null || true
} }
lines = stdout.splitlines() lines = stdout.splitlines()
resolved_path = p_dir
for l in lines:
if l.startswith("RESOLVED_DIR:"):
resolved_path = l.split(":", 1)[1].strip()
break
compose_exists = any("COMPOSE_EXISTS" in l for l in lines) compose_exists = any("COMPOSE_EXISTS" in l for l in lines)
config_exists = any("CONFIG_EXISTS" in l for l in lines) config_exists = any("CONFIG_EXISTS" in l for l in lines)
@@ -107,7 +131,7 @@ docker ps -a --format '{{json .}}' 2>/dev/null || true
"vps_configured": True, "vps_configured": True,
"connected": True, "connected": True,
"installed": compose_exists, "installed": compose_exists,
"path": p_dir, "path": resolved_path,
"compose_exists": compose_exists, "compose_exists": compose_exists,
"config_exists": config_exists, "config_exists": config_exists,
"database": { "database": {
@@ -133,16 +157,39 @@ async def trigger_pangolin_update() -> Dict[str, Any]:
# Remote update script executed on the VPS # Remote update script executed on the VPS
remote_script = f""" remote_script = f"""
eval P_DIR="{p_dir}"
P_DIR="${{P_DIR/#~/$HOME}}"
# Locate compose directory
if [ -f "$P_DIR/docker-compose.yml" ] || [ -f "$P_DIR/compose.yaml" ]; then
COMPOSE_DIR="$P_DIR"
elif [ -f "$(dirname "$P_DIR")/docker-compose.yml" ] || [ -f "$(dirname "$P_DIR")/compose.yaml" ]; then
COMPOSE_DIR="$(dirname "$P_DIR")"
else
COMPOSE_DIR="$P_DIR"
fi
echo "=== STEP 1: Connecting to Pangolin VPS ({vps_host}) ===" echo "=== STEP 1: Connecting to Pangolin VPS ({vps_host}) ==="
cd "{p_dir}" || exit 1 echo "Operating Directory: $COMPOSE_DIR"
cd "$COMPOSE_DIR" || exit 1
# Detect docker compose version
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DC="docker-compose"
else
echo "ERROR: Neither 'docker compose' nor 'docker-compose' found on VPS."
exit 1
fi
echo "" echo ""
echo "=== STEP 2: Pulling latest images on VPS ===" echo "=== STEP 2: Pulling latest images on VPS ==="
docker compose pull $DC pull
echo "" echo ""
echo "=== STEP 3: Applying stack updates & recreating containers ===" echo "=== STEP 3: Applying stack updates & recreating containers ==="
docker compose up -d --remove-orphans $DC up -d --remove-orphans
echo "" echo ""
echo "=== STEP 4: Verifying running container status ===" echo "=== STEP 4: Verifying running container status ==="
+53 -66
View File
@@ -4,7 +4,7 @@ import shutil
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
from backend.config import load_settings, DEFAULT_KEY_FILE, DEFAULT_PUB_KEY_FILE from backend.config import load_settings, DEFAULT_KEY_FILE, DEFAULT_PUB_KEY_FILE, DATA_DIR
def get_public_key() -> str: def get_public_key() -> str:
if DEFAULT_PUB_KEY_FILE.exists(): if DEFAULT_PUB_KEY_FILE.exists():
@@ -40,9 +40,12 @@ def build_ssh_prefix(settings: Optional[Dict[str, Any]] = None) -> List[str]:
raw_key_path = settings.get("vps_ssh_key_path", str(DEFAULT_KEY_FILE)) raw_key_path = settings.get("vps_ssh_key_path", str(DEFAULT_KEY_FILE))
key_path = resolve_key_path(raw_key_path) key_path = resolve_key_path(raw_key_path)
password = settings.get("vps_ssh_password", "") password = settings.get("vps_ssh_password", "")
known_hosts = str(DATA_DIR / "known_hosts")
ssh_opts = [ ssh_opts = [
"-T",
"-o", "StrictHostKeyChecking=accept-new", "-o", "StrictHostKeyChecking=accept-new",
"-o", f"UserKnownHostsFile={known_hosts}",
"-o", "ConnectTimeout=8", "-o", "ConnectTimeout=8",
"-o", "ServerAliveInterval=15", "-o", "ServerAliveInterval=15",
"-p", port "-p", port
@@ -69,10 +72,12 @@ def build_scp_prefix(settings: Optional[Dict[str, Any]] = None) -> List[str]:
raw_key_path = settings.get("vps_ssh_key_path", str(DEFAULT_KEY_FILE)) raw_key_path = settings.get("vps_ssh_key_path", str(DEFAULT_KEY_FILE))
key_path = resolve_key_path(raw_key_path) key_path = resolve_key_path(raw_key_path)
password = settings.get("vps_ssh_password", "") password = settings.get("vps_ssh_password", "")
known_hosts = str(DATA_DIR / "known_hosts")
scp_opts = [ scp_opts = [
"-P", port, "-P", port,
"-o", "StrictHostKeyChecking=accept-new", "-o", "StrictHostKeyChecking=accept-new",
"-o", f"UserKnownHostsFile={known_hosts}",
"-o", "ConnectTimeout=10" "-o", "ConnectTimeout=10"
] ]
@@ -99,79 +104,61 @@ async def run_remote_quick(cmd: str, settings: Optional[Dict[str, Any]] = None)
stdout, stderr = await proc.communicate() stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode('utf-8', errors='replace'), stderr.decode('utf-8', errors='replace') return proc.returncode, stdout.decode('utf-8', errors='replace'), stderr.decode('utf-8', errors='replace')
except Exception as e: except Exception as e:
return -1, "", str(e) return 1, "", str(e)
async def test_vps_connection(custom_settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: async def test_vps_connection(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
s = custom_settings or load_settings() if not settings:
host = s.get("vps_host", "").strip() settings = load_settings()
host = settings.get("vps_host", "").strip()
if not host: if not host:
return { return {"success": False, "error": "No VPS host configured. Please provide IP or hostname."}
"success": False,
"connected": False,
"error": "No VPS Host / IP configured. Please specify the VPS address in Settings."
}
# Run comprehensive diagnostic script on remote VPS # 1. Test basic SSH connectivity & gather OS summary
diag_script = """ cmd_ping = 'echo "PANGOLIN_PONG" && uname -srm && cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d \'"\''
echo "===PING_OK===" code, stdout, stderr = await run_remote_quick(cmd_ping, settings)
uname -n
cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '"' if code != 0:
uname -r err_msg = stderr.strip() or "Connection timed out or failed."
uptime -p 2>/dev/null || uptime if "Permission denied" in err_msg:
which docker >/dev/null 2>&1 && echo "DOCKER_FOUND" || echo "DOCKER_NOT_FOUND" err_msg += " (SSH Authentication failed. Check key in ~/.ssh/authorized_keys or password)"
docker ps >/dev/null 2>&1 && echo "DOCKER_RUNNING" || echo "DOCKER_STOPPED" return {"success": False, "error": err_msg}
lines = stdout.strip().split("\n")
os_name = lines[2] if len(lines) > 2 else (lines[1] if len(lines) > 1 else "Linux")
kernel = lines[1] if len(lines) > 1 else ""
# 2. Check Docker availability on VPS
code_docker, stdout_docker, _ = await run_remote_quick('docker --version', settings)
docker_installed = (code_docker == 0)
docker_version = stdout_docker.strip() if docker_installed else "Not detected"
# 3. Check Pangolin Directory
pangolin_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip() or "/opt/pangolin"
chk_dir_cmd = f"""
eval P_DIR="{pangolin_dir}"
P_DIR="${{P_DIR/#~/$HOME}}"
[ -d "$P_DIR" ] && echo "DIR_EXISTS" || echo "DIR_MISSING"
""" """
ret, stdout, stderr = await run_remote_quick(diag_script, s) _, stdout_dir, _ = await run_remote_quick(chk_dir_cmd, settings)
pangolin_dir_found = "DIR_EXISTS" in stdout_dir
if ret != 0 or "===PING_OK===" not in stdout: # 4. Check SQLite database status in Pangolin directory
err = stderr.strip() or "Connection failed. Please check Host, Port, and SSH credentials." chk_db_cmd = f"""
if "Permission denied" in err: eval P_DIR="{pangolin_dir}"
err = "Permission denied (SSH authentication failed). Please ensure your SSH public key or password is correct on the VPS." P_DIR="${{P_DIR/#~/$HOME}}"
elif "Connection timed out" in err or "No route to host" in err: find "$P_DIR" -name "*.db" -o -name "*.sqlite" 2>/dev/null
err = f"Could not reach {host}:{s.get('vps_port', 22)}. Verify firewall, IP address, and SSH port."
return {
"success": False,
"connected": False,
"error": err
}
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
hostname = lines[1] if len(lines) > 1 else host
os_name = lines[2] if len(lines) > 2 else "Linux"
kernel = lines[3] if len(lines) > 3 else ""
uptime_str = lines[4] if len(lines) > 4 else ""
docker_found = "DOCKER_FOUND" in stdout
docker_running = "DOCKER_RUNNING" in stdout
# Check Pangolin path on VPS
p_dir = s.get("vps_pangolin_dir", "/opt/pangolin")
p_script = f"""
[ -d "{p_dir}" ] && echo "DIR_OK" || echo "DIR_MISSING"
[ -f "{p_dir}/docker-compose.yml" ] || [ -f "{p_dir}/compose.yaml" ] && echo "COMPOSE_OK" || echo "COMPOSE_MISSING"
find "{p_dir}" -name "*.db" -o -name "*.sqlite" 2>/dev/null | head -n 1
""" """
_, p_out, _ = await run_remote_quick(p_script, s) _, stdout_db, _ = await run_remote_quick(chk_db_cmd, settings)
p_lines = [l.strip() for l in p_out.splitlines() if l.strip()] dbs_found = [p.strip() for p in stdout_db.strip().split("\n") if p.strip()]
dir_ok = "DIR_OK" in p_lines
compose_ok = "COMPOSE_OK" in p_lines
db_file = None
for l in p_lines:
if l.endswith((".db", ".sqlite")):
db_file = l
break
return { return {
"success": True, "success": True,
"connected": True, "os_name": os_name,
"hostname": hostname,
"os": os_name,
"kernel": kernel, "kernel": kernel,
"uptime": uptime_str, "docker_installed": docker_installed,
"docker_installed": docker_found, "docker_version": docker_version,
"docker_running": docker_running, "pangolin_dir_found": pangolin_dir_found,
"pangolin_dir_exists": dir_ok, "sqlite_databases": dbs_found,
"compose_file_exists": compose_ok, "message": "SSH Connection successful! Remote host is ready for backups and updates."
"database_file": db_file,
"message": f"Successfully connected to {hostname} ({host}) via SSH!"
} }
+4 -3
View File
@@ -5,7 +5,8 @@ set -e
DATA_PATH="${DATA_DIR:-/data}" DATA_PATH="${DATA_DIR:-/data}"
BACKUP_PATH="${BACKUP_DIR:-/backups}" BACKUP_PATH="${BACKUP_DIR:-/backups}"
mkdir -p "$DATA_PATH" "$BACKUP_PATH" mkdir -p "$DATA_PATH" "$BACKUP_PATH" "$DATA_PATH/.ssh"
export HOME="$DATA_PATH"
# Set secure permissions on SSH private key if it exists # Set secure permissions on SSH private key if it exists
if [ -f "$DATA_PATH/id_ed25519" ]; then if [ -f "$DATA_PATH/id_ed25519" ]; then
@@ -22,9 +23,9 @@ if [ "$PUID" -ne 0 ] && [ "$PGID" -ne 0 ]; then
groupadd -g "$PGID" pangolin 2>/dev/null || true groupadd -g "$PGID" pangolin 2>/dev/null || true
fi fi
# Create or update user # Create or update user with persistent home in DATA_PATH
if ! getent passwd pangolin >/dev/null 2>&1; then if ! getent passwd pangolin >/dev/null 2>&1; then
useradd -u "$PUID" -g "$PGID" -d /app -s /bin/bash pangolin 2>/dev/null || true useradd -u "$PUID" -g "$PGID" -d "$DATA_PATH" -s /bin/bash pangolin 2>/dev/null || true
fi fi
# Ensure ownership of persistent data and backup volumes # Ensure ownership of persistent data and backup volumes
+6 -6
View File
@@ -1,19 +1,19 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<Container version="2"> <Container version="2">
<Name>PangolinSentinel</Name> <Name>PangolinSentinel</Name>
<Repository>pangolin-sentinel:latest</Repository> <Repository>fluffyranger/pangolin-sentinel:latest</Repository>
<Registry>https://hub.docker.com/</Registry> <Registry>https://hub.docker.com/r/fluffyranger/pangolin-sentinel</Registry>
<Network>bridge</Network> <Network>bridge</Network>
<MyIP/> <MyIP/>
<Shell>bash</Shell> <Shell>bash</Shell>
<Privileged>false</Privileged> <Privileged>false</Privileged>
<Support/> <Support>https://github.com/FluffyRanger/PangolinSentinel/issues</Support>
<Project>https://github.com/fosrl/pangolin</Project> <Project>https://github.com/FluffyRanger/PangolinSentinel</Project>
<Overview>Pangolin Sentinel is a dedicated Unraid-to-VPS backup and update manager. Pull zero-lock hot backups of your remote Pangolin Reverse Proxy stack (SQLite databases, Traefik SSL certs, configurations) directly into your Unraid array, trigger remote stack updates, and monitor remote VPS OS packages and metrics over secure SSH.</Overview> <Overview>Pangolin Sentinel is a dedicated Unraid-to-VPS backup and update manager. Pull zero-lock hot backups of your remote Pangolin Reverse Proxy stack (SQLite databases, Traefik SSL certs, configurations) directly into your Unraid array, trigger remote stack updates, and monitor remote VPS OS packages and metrics over secure SSH.</Overview>
<Category>Backup: Tools: Network:Management</Category> <Category>Backup: Tools: Network:Management</Category>
<WebUI>http://[IP]:[PORT:8888]/</WebUI> <WebUI>http://[IP]:[PORT:8888]/</WebUI>
<TemplateURL/> <TemplateURL>https://raw.githubusercontent.com/FluffyRanger/PangolinSentinel/main/pangolin-sentinel-unraid.xml</TemplateURL>
<Icon>http://[IP]:[PORT:8888]/assets/icon.png</Icon> <Icon>https://raw.githubusercontent.com/FluffyRanger/PangolinSentinel/main/icon.png</Icon>
<ExtraParams/> <ExtraParams/>
<PostArgs/> <PostArgs/>
<CPUset/> <CPUset/>