fix(cluster): join по приватному адресу corosync + подтверждение членства
Проблема: присоединяемая нода подключалась к кластерной по её адресу из панели (часто публичный FQDN), из-за чего join падал с "Can't connect to <fqdn>:8006 (Connection timed out)" при выделенной corosync-сети. Плюс панель помечала ноду как присоединённую сразу после старта задачи, показывая ложное членство даже при провале join. - join: новый опциональный параметр cluster_host — приватный IP кластерной ноды в corosync-сети; используется как hostname для API-подключения и для выбора fingerprint из nodelist. Фолбэк — ip_address из панели. - join: cluster_name пишется в БД только после подтверждения, что нода реально появилась среди нод кластерной ноды (опрос до ~40с, укладываемся в nginx proxy_read_timeout 60с). Иначе — confirmed=false и предупреждение. - UI: поле "Адрес кластерной ноды для подключения" в шаге 3 мастера, обработка неподтверждённого join (toast.warning, окно не закрывается). - Локали ru/en. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -501,13 +501,18 @@ async def join_cluster(
|
||||
cluster_server_id (int): ID любой ноды существующего кластера
|
||||
rootpw (str): root-пароль присоединяемой ноды
|
||||
link0 (str, optional): Локальный IP присоединяемой ноды для corosync
|
||||
cluster_host (str, optional): Адрес кластерной ноды, к которому
|
||||
подключается присоединяемая нода (IP в corosync-сети). Если не
|
||||
задан — берётся ip_address кластерной ноды из панели, что может
|
||||
быть публичным FQDN и недоступно из выделенной приватной сети.
|
||||
|
||||
Returns 409 если нода содержит VM/LXC (сначала вызовите prepare-join).
|
||||
"""
|
||||
node_server_id = body.get("node_server_id")
|
||||
cluster_server_id = body.get("cluster_server_id")
|
||||
rootpw = body.get("rootpw", "").strip()
|
||||
link0 = body.get("link0")
|
||||
link0 = (body.get("link0") or "").strip() or None
|
||||
cluster_host = (body.get("cluster_host") or "").strip() or None
|
||||
|
||||
if not node_server_id or not cluster_server_id:
|
||||
raise HTTPException(
|
||||
@@ -570,7 +575,11 @@ async def join_cluster(
|
||||
)
|
||||
|
||||
join_data = join_info_result.get("data", {})
|
||||
fingerprint = _extract_fingerprint(join_data, target_ip=cluster_server.ip_address)
|
||||
# Адрес, к которому реально подключается присоединяемая нода: явный
|
||||
# cluster_host (IP corosync-сети) в приоритете, иначе — ip_address из панели.
|
||||
connect_host = cluster_host or cluster_server.ip_address
|
||||
fingerprint = _extract_fingerprint(join_data, target_ip=connect_host) \
|
||||
or _extract_fingerprint(join_data, target_ip=cluster_server.ip_address)
|
||||
cluster_name = join_data.get("totem", {}).get("cluster_name") or cluster_server.cluster_name
|
||||
|
||||
if not fingerprint:
|
||||
@@ -583,7 +592,7 @@ async def join_cluster(
|
||||
joining_client = _get_password_client(node_server, rootpw)
|
||||
result = await _run_in_executor(
|
||||
joining_client.join_cluster,
|
||||
cluster_host=cluster_server.ip_address,
|
||||
cluster_host=connect_host,
|
||||
rootpw=rootpw,
|
||||
fingerprint=fingerprint,
|
||||
link0=link0,
|
||||
@@ -595,26 +604,58 @@ async def join_cluster(
|
||||
detail=f"Ошибка присоединения к кластеру: {result.get('error')}"
|
||||
)
|
||||
|
||||
# --- Обновить панель: выставить cluster_name ---
|
||||
node_server.cluster_name = cluster_name
|
||||
db.commit()
|
||||
node_upid = result.get("upid")
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.username} joined '{node_server.name}' "
|
||||
f"to cluster '{cluster_name}' (via '{cluster_server.name}')"
|
||||
)
|
||||
# --- Подтвердить членство перед записью в БД ---
|
||||
# Задача join выполняется асинхронно и может упасть (напр. таймаут
|
||||
# подключения к кластерной ноде по недоступному адресу). При УСПЕШНОМ join
|
||||
# API присоединяемой ноды перезапускается, поэтому надёжнее спрашивать
|
||||
# кластерную ноду (node1): как только присоединяемая нода реально войдёт в
|
||||
# кластер, она появится в её списке нод. Окно ~40с — чтобы уложиться в
|
||||
# proxy_read_timeout (60с) фронтового nginx.
|
||||
joined_confirmed = False
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
cluster_nodes = await _run_in_executor(cluster_client.get_nodes)
|
||||
except Exception:
|
||||
cluster_nodes = []
|
||||
names = {n.get("node") for n in cluster_nodes if isinstance(n, dict)}
|
||||
if node_name in names:
|
||||
joined_confirmed = True
|
||||
break
|
||||
|
||||
if joined_confirmed:
|
||||
# Только теперь помечаем ноду как члена кластера в панели.
|
||||
node_server.cluster_name = cluster_name
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"User {current_user.username} joined '{node_server.name}' "
|
||||
f"to cluster '{cluster_name}' (via '{cluster_server.name}')"
|
||||
)
|
||||
message = f"Нода '{node_server.name}' присоединена к кластеру '{cluster_name}'."
|
||||
else:
|
||||
# Членство не подтверждено — НЕ выставляем cluster_name, чтобы панель
|
||||
# не показывала ложное присоединение.
|
||||
logger.warning(
|
||||
f"Join of '{node_server.name}' to '{cluster_name}' not confirmed "
|
||||
f"within timeout (upid={node_upid})"
|
||||
)
|
||||
message = (
|
||||
f"Задача присоединения запущена (upid={node_upid}), но членство "
|
||||
f"'{node_server.name}' в кластере пока не подтверждено. Проверьте задачу; "
|
||||
"если нода не вошла в кластер — убедитесь, что кластерная нода доступна "
|
||||
"с присоединяемой по адресу corosync (link0), а не по публичному FQDN."
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"node_server_id": node_server_id,
|
||||
"node_name": node_server.name,
|
||||
"cluster_name": cluster_name,
|
||||
"upid": result.get("upid"),
|
||||
"message": (
|
||||
f"Нода '{node_server.name}' присоединяется к кластеру '{cluster_name}'. "
|
||||
"После завершения задачи интерфейс ноды перезапустится. "
|
||||
"Отслеживайте прогресс через upid."
|
||||
),
|
||||
"cluster_name": cluster_name if joined_confirmed else None,
|
||||
"confirmed": joined_confirmed,
|
||||
"upid": node_upid,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -143,11 +143,12 @@ function JoinWizard({
|
||||
const [prepared, setPrepared] = useState(false);
|
||||
const [rootpw, setRootpw] = useState('');
|
||||
const [link0, setLink0] = useState('');
|
||||
const [clusterHost, setClusterHost] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setStep(1); setNodeServerId(''); setClusterServerId(''); setCheck(null);
|
||||
setBackupStorage(''); setPrepared(false); setRootpw(''); setLink0(''); setConfirmed(false);
|
||||
setBackupStorage(''); setPrepared(false); setRootpw(''); setLink0(''); setClusterHost(''); setConfirmed(false);
|
||||
};
|
||||
|
||||
const close = (v: boolean) => { if (!v) reset(); onOpenChange(v); };
|
||||
@@ -183,12 +184,18 @@ function JoinWizard({
|
||||
cluster_server_id: Number(clusterServerId),
|
||||
rootpw: rootpw.trim(),
|
||||
link0: link0.trim() || undefined,
|
||||
cluster_host: clusterHost.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message || t('cluster.join_started', 'Join started'));
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
if (res.confirmed === false) {
|
||||
// Задача запущена, но членство не подтверждено — предупреждаем, окно не закрываем.
|
||||
toast.warning(res.message || t('cluster.join_unconfirmed', 'Join started but not confirmed'));
|
||||
} else {
|
||||
toast.success(res.message || t('cluster.join_started', 'Join started'));
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
},
|
||||
@@ -196,6 +203,7 @@ function JoinWizard({
|
||||
};
|
||||
|
||||
const joiningServer = standalone.find((s) => s.id === Number(nodeServerId));
|
||||
const targetClusterNode = clusterMembers.find((s) => s.id === Number(clusterServerId));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={close}>
|
||||
@@ -341,9 +349,17 @@ function JoinWizard({
|
||||
<Input type="password" value={rootpw} onChange={(e) => setRootpw(e.target.value)} className="mt-1" autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('cluster.link0', 'Corosync link0 IP (optional)')}</Label>
|
||||
<Label>{t('cluster.link0', 'Corosync link0 IP of the joining node (optional)')}</Label>
|
||||
<Input value={link0} onChange={(e) => setLink0(e.target.value)} className="mt-1" placeholder={joiningServer?.ip_address} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('cluster.cluster_host', 'Cluster node address to connect to (optional)')}</Label>
|
||||
<Input value={clusterHost} onChange={(e) => setClusterHost(e.target.value)} className="mt-1" placeholder={targetClusterNode?.ip_address} />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('cluster.cluster_host_hint',
|
||||
'Private IP of the cluster node reachable over the corosync network. Leave empty to use its panel address (may be a public FQDN unreachable from a dedicated network).')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} className="h-4 w-4" />
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface ClusterActionResult {
|
||||
cluster_name?: string;
|
||||
upid?: string;
|
||||
message: string;
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Queries ====================
|
||||
@@ -111,7 +112,7 @@ export function usePrepareJoin() {
|
||||
export function useJoinCluster() {
|
||||
const invalidate = useInvalidateCluster();
|
||||
return useMutation({
|
||||
mutationFn: (body: { node_server_id: number; cluster_server_id: number; rootpw: string; link0?: string }) =>
|
||||
mutationFn: (body: { node_server_id: number; cluster_server_id: number; rootpw: string; link0?: string; cluster_host?: string }) =>
|
||||
apiClient.post<ClusterActionResult>('/proxmox/api/cluster/join', body),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
@@ -151,12 +151,15 @@
|
||||
"select_node": "Select server",
|
||||
"cluster_name": "Cluster name",
|
||||
"name_rule": "Starts with a letter; letters, digits, hyphens; max 15 chars.",
|
||||
"link0": "Corosync link0 IP (optional)",
|
||||
"link0": "Corosync link0 IP of the joining node (optional)",
|
||||
"link0_hint": "A dedicated network for corosync is recommended in production.",
|
||||
"cluster_host": "Cluster node address to connect to (optional)",
|
||||
"cluster_host_hint": "Private IP of the cluster node reachable over the corosync network. Leave empty to use its panel address (may be a public FQDN unreachable from a dedicated network).",
|
||||
"rootpw_optional": "root@pam password (if not saved on server)",
|
||||
"join_node": "Join node",
|
||||
"join_title": "Join node to cluster",
|
||||
"join_started": "Join started",
|
||||
"join_unconfirmed": "Join task started, but membership is not confirmed yet",
|
||||
"join_btn": "Join cluster",
|
||||
"step": "Step",
|
||||
"joining_node": "Node to join (standalone)",
|
||||
|
||||
@@ -151,12 +151,15 @@
|
||||
"select_node": "Выберите сервер",
|
||||
"cluster_name": "Имя кластера",
|
||||
"name_rule": "Начинается с буквы; буквы, цифры, дефисы; макс 15 символов.",
|
||||
"link0": "IP для corosync link0 (опционально)",
|
||||
"link0": "IP для corosync link0 присоединяемой ноды (опционально)",
|
||||
"link0_hint": "Для production рекомендуется выделенная сеть для corosync.",
|
||||
"cluster_host": "Адрес кластерной ноды для подключения (опционально)",
|
||||
"cluster_host_hint": "Приватный IP кластерной ноды, доступный по сети corosync. Оставьте пустым, чтобы использовать адрес из панели (может быть публичным FQDN, недоступным из выделенной сети).",
|
||||
"rootpw_optional": "Пароль root@pam (если не сохранён на сервере)",
|
||||
"join_node": "Присоединить ноду",
|
||||
"join_title": "Присоединение ноды к кластеру",
|
||||
"join_started": "Присоединение запущено",
|
||||
"join_unconfirmed": "Задача присоединения запущена, но членство пока не подтверждено",
|
||||
"join_btn": "Присоединить к кластеру",
|
||||
"step": "Шаг",
|
||||
"joining_node": "Присоединяемая нода (standalone)",
|
||||
|
||||
Reference in New Issue
Block a user