fix(cluster): join по приватному адресу corosync + подтверждение членства
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 58s
Deploy to Production / Trigger production update (push) Successful in 7s

Проблема: присоединяемая нода подключалась к кластерной по её адресу из
панели (часто публичный 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:
markmorado
2026-07-09 03:28:39 +05:00
parent 3b10e903a4
commit c88cdb4e26
5 changed files with 89 additions and 25 deletions

View File

@@ -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,
}

View File

@@ -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" />

View File

@@ -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(),
});

View File

@@ -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)",

View File

@@ -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)",