2026-03-30 14:17:50 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
2026-03-30 15:41:08 +08:00
|
|
|
|
Proxmox VM 任务控制器
|
|
|
|
|
|
小主机待机监听 → 接受命令 → 启动VM → 执行任务 → 汇报进展 → 关闭VM
|
2026-03-30 14:17:50 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import time
|
|
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import subprocess
|
2026-03-30 15:41:08 +08:00
|
|
|
|
import requests
|
2026-03-30 14:17:50 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Dict, Any, Optional
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
# 配置
|
2026-03-30 14:17:50 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class Config:
|
|
|
|
|
|
proxmox_host: str
|
2026-03-30 15:41:08 +08:00
|
|
|
|
proxmox_user: str
|
|
|
|
|
|
proxmox_token: str
|
2026-03-30 14:17:50 +08:00
|
|
|
|
vm_id: int
|
2026-03-30 15:41:08 +08:00
|
|
|
|
ssh_user: str
|
|
|
|
|
|
ssh_key_path: str
|
|
|
|
|
|
command_dir: str = "/var/task-commands"
|
|
|
|
|
|
status_dir: str = "/var/task-status"
|
|
|
|
|
|
check_interval: int = 10 # 检查间隔(秒)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
class ProxmoxController:
|
2026-03-30 14:17:50 +08:00
|
|
|
|
def __init__(self, config: Config):
|
|
|
|
|
|
self.config = config
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.base_url = f"https://{config.proxmox_host}:8006/api2/json"
|
2026-03-30 14:17:50 +08:00
|
|
|
|
self.headers = {
|
2026-03-30 15:41:08 +08:00
|
|
|
|
"Authorization": f"PVEAPIToken={config.proxmox_user}={config.token}"
|
2026-03-30 14:17:50 +08:00
|
|
|
|
}
|
2026-03-30 15:41:08 +08:00
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
|
|
self.logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
def vm_status(self) -> Dict[str, Any]:
|
|
|
|
|
|
"""获取VM状态"""
|
|
|
|
|
|
url = f"{self.base_url}/nodes/proxmox/qemu/{self.config.vm_id}/status/current"
|
2026-03-30 14:17:50 +08:00
|
|
|
|
try:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
resp = requests.get(url, headers=self.headers, verify=False, timeout=10)
|
|
|
|
|
|
return resp.json()
|
2026-03-30 14:17:50 +08:00
|
|
|
|
except Exception as e:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.error(f"获取VM状态失败: {e}")
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
def start_vm(self) -> bool:
|
|
|
|
|
|
"""启动VM"""
|
|
|
|
|
|
self.logger.info(f"正在启动VM {self.config.vm_id}")
|
|
|
|
|
|
url = f"{self.base_url}/nodes/proxmox/qemu/{self.config.vm_id}/status/start"
|
2026-03-30 14:17:50 +08:00
|
|
|
|
try:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
resp = requests.post(url, headers=self.headers, verify=False, timeout=30)
|
|
|
|
|
|
if resp.status_code == 200:
|
|
|
|
|
|
self.logger.info("VM启动命令已发送")
|
2026-03-30 14:17:50 +08:00
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.error(f"启动VM失败: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def stop_vm(self) -> bool:
|
|
|
|
|
|
"""关闭VM"""
|
|
|
|
|
|
self.logger.info(f"正在关闭VM {self.config.vm_id}")
|
|
|
|
|
|
url = f"{self.base_url}/nodes/proxmox/qemu/{self.config.vm_id}/status/stop"
|
2026-03-30 14:17:50 +08:00
|
|
|
|
try:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
resp = requests.post(url, headers=self.headers, verify=False, timeout=30)
|
|
|
|
|
|
if resp.status_code == 200:
|
|
|
|
|
|
self.logger.info("VM关闭命令已发送")
|
|
|
|
|
|
return True
|
2026-03-30 14:17:50 +08:00
|
|
|
|
except Exception as e:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.error(f"关闭VM失败: {e}")
|
|
|
|
|
|
return False
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
def ssh_execute(self, command: str) -> tuple[int, str, str]:
|
|
|
|
|
|
"""在VM内执行命令"""
|
|
|
|
|
|
ssh_cmd = [
|
|
|
|
|
|
"ssh",
|
|
|
|
|
|
"-i", self.config.ssh_key_path,
|
|
|
|
|
|
"-o", "StrictHostKeyChecking=no",
|
|
|
|
|
|
"-o", "ConnectTimeout=30",
|
|
|
|
|
|
f"{self.config.ssh_user}@localhost",
|
|
|
|
|
|
command
|
|
|
|
|
|
]
|
2026-03-30 14:17:50 +08:00
|
|
|
|
try:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=120)
|
|
|
|
|
|
return result.returncode, result.stdout, result.stderr
|
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
return -1, "", "命令执行超时"
|
2026-03-30 14:17:50 +08:00
|
|
|
|
except Exception as e:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
return -1, "", str(e)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
def wait_for_vm_ready(self, timeout: int = 120) -> bool:
|
|
|
|
|
|
"""等待VM启动完成并可以SSH连接"""
|
2026-03-30 14:17:50 +08:00
|
|
|
|
start_time = time.time()
|
2026-03-30 15:41:08 +08:00
|
|
|
|
while time.time() - start_time < timeout:
|
|
|
|
|
|
status = self.vm_status()
|
|
|
|
|
|
if status.get("status") == "running":
|
|
|
|
|
|
# 测试SSH连接
|
|
|
|
|
|
ret, _, _ = self.ssh_execute("echo 'VM ready'")
|
|
|
|
|
|
if ret == 0:
|
|
|
|
|
|
self.logger.info("VM已就绪")
|
2026-03-30 14:17:50 +08:00
|
|
|
|
return True
|
|
|
|
|
|
time.sleep(5)
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.error("VM启动超时")
|
2026-03-30 14:17:50 +08:00
|
|
|
|
return False
|
2026-03-30 15:41:08 +08:00
|
|
|
|
|
|
|
|
|
|
def process_command(self, cmd_file: Path) -> bool:
|
2026-03-30 14:17:50 +08:00
|
|
|
|
"""处理单个命令文件"""
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.info(f"处理命令文件: {cmd_file}")
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
# 读取命令
|
2026-03-30 14:17:50 +08:00
|
|
|
|
try:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
with open(cmd_file, 'r') as f:
|
|
|
|
|
|
task_data = json.load(f)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
except Exception as e:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.error(f"读取命令文件失败: {e}")
|
|
|
|
|
|
return False
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
task_id = task_data.get("task_id", "unknown")
|
|
|
|
|
|
command = task_data.get("command", "")
|
|
|
|
|
|
|
|
|
|
|
|
# 更新状态
|
|
|
|
|
|
self.update_status(task_id, "starting", "准备启动VM")
|
|
|
|
|
|
|
|
|
|
|
|
# 启动VM
|
|
|
|
|
|
if not self.start_vm():
|
|
|
|
|
|
self.update_status(task_id, "failed", "启动VM失败")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 等待VM就绪
|
|
|
|
|
|
if not self.wait_for_vm_ready():
|
|
|
|
|
|
self.update_status(task_id, "failed", "VM启动超时")
|
|
|
|
|
|
self.stop_vm()
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
self.update_status(task_id, "running", "VM已启动,开始执行任务")
|
|
|
|
|
|
|
|
|
|
|
|
# 执行命令
|
|
|
|
|
|
ret, stdout, stderr = self.ssh_execute(command)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
if ret == 0:
|
|
|
|
|
|
self.update_status(task_id, "success", "任务执行成功", stdout)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.update_status(task_id, "failed", "任务执行失败", stderr)
|
|
|
|
|
|
|
|
|
|
|
|
# 关闭VM
|
|
|
|
|
|
self.stop_vm()
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
# 移动命令文件到完成目录
|
|
|
|
|
|
done_dir = cmd_file.parent / "done"
|
|
|
|
|
|
done_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
cmd_file.rename(done_dir / cmd_file.name)
|
|
|
|
|
|
|
|
|
|
|
|
return ret == 0
|
|
|
|
|
|
|
|
|
|
|
|
def update_status(self, task_id: str, status: str, message: str, output: str = ""):
|
|
|
|
|
|
"""更新任务状态"""
|
|
|
|
|
|
status_file = Path(self.config.status_dir) / f"{task_id}.json"
|
|
|
|
|
|
status_file.parent.mkdir(exist_ok=True)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
status_data = {
|
|
|
|
|
|
"task_id": task_id,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"message": message,
|
|
|
|
|
|
"output": output,
|
|
|
|
|
|
"timestamp": time.time()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
with open(status_file, 'w') as f:
|
|
|
|
|
|
json.dump(status_data, f, indent=2)
|
|
|
|
|
|
|
2026-03-30 14:17:50 +08:00
|
|
|
|
def run(self):
|
|
|
|
|
|
"""主运行循环"""
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.info("Proxmox任务控制器启动")
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
# 创建必要目录
|
|
|
|
|
|
Path(self.config.command_dir).mkdir(exist_ok=True)
|
|
|
|
|
|
Path(self.config.status_dir).mkdir(exist_ok=True)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
while True:
|
2026-03-30 14:17:50 +08:00
|
|
|
|
try:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
# 检查新命令文件
|
|
|
|
|
|
cmd_files = sorted(Path(self.config.command_dir).glob("*.json"))
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
for cmd_file in cmd_files:
|
|
|
|
|
|
self.process_command(cmd_file)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
# 等待下次检查
|
|
|
|
|
|
time.sleep(self.config.check_interval)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
|
|
|
|
|
except KeyboardInterrupt:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.info("收到终止信号,退出")
|
|
|
|
|
|
break
|
2026-03-30 14:17:50 +08:00
|
|
|
|
except Exception as e:
|
2026-03-30 15:41:08 +08:00
|
|
|
|
self.logger.error(f"主循环错误: {e}")
|
|
|
|
|
|
time.sleep(self.config.check_interval)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
|
|
|
|
|
|
def main():
|
2026-03-30 15:41:08 +08:00
|
|
|
|
# 从环境变量加载配置
|
|
|
|
|
|
config = Config(
|
|
|
|
|
|
proxmox_host=os.getenv("PROXMOX_HOST", "localhost"),
|
|
|
|
|
|
proxmox_user=os.getenv("PROXMOX_USER", "root@pam"),
|
|
|
|
|
|
proxmox_token=os.getenv("PROXMOX_TOKEN", ""),
|
|
|
|
|
|
vm_id=int(os.getenv("VM_ID", "100")),
|
|
|
|
|
|
ssh_user=os.getenv("SSH_USER", "ubuntu"),
|
|
|
|
|
|
ssh_key_path=os.getenv("SSH_KEY_PATH", "~/.ssh/id_rsa"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not config.proxmox_token:
|
|
|
|
|
|
print("错误: 请设置 PROXMOX_TOKEN 环境变量")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
controller = ProxmoxController(config)
|
2026-03-30 14:17:50 +08:00
|
|
|
|
controller.run()
|
|
|
|
|
|
|
2026-03-30 15:41:08 +08:00
|
|
|
|
if __name__ == "__main__":
|
2026-03-30 14:17:50 +08:00
|
|
|
|
main()
|