fix(recovery): persist Host111 broker transport
Some checks failed
CD Pipeline / select-latest-carrier (push) Successful in 38s
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Failing after 1m17s
CD Pipeline / revalidate-deploy-carrier (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / revalidate-post-deploy-carrier (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped

This commit is contained in:
Your Name
2026-07-22 19:22:07 +08:00
parent b7d23d297f
commit 7cf98f642c
6 changed files with 150 additions and 26 deletions

View File

@@ -3013,14 +3013,14 @@ jobs:
# 不能覆蓋現有 secret防止 production SSH 自動修復路徑癱瘓。 # 不能覆蓋現有 secret防止 production SSH 自動修復路徑癱瘓。
: > /tmp/known_hosts_repair : > /tmp/known_hosts_repair
: > /tmp/known_hosts_scan_err : > /tmp/known_hosts_scan_err
EXPECTED_HOSTS=5 EXPECTED_HOSTS=6
PRESENT=0 PRESENT=0
for scan_attempt in 1 2 3; do for scan_attempt in 1 2 3; do
ssh-keyscan -T 5 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 \ ssh-keyscan -T 5 192.168.0.110 192.168.0.111 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 \
>> /tmp/known_hosts_repair 2>>/tmp/known_hosts_scan_err || true >> /tmp/known_hosts_repair 2>>/tmp/known_hosts_scan_err || true
sort -u /tmp/known_hosts_repair -o /tmp/known_hosts_repair sort -u /tmp/known_hosts_repair -o /tmp/known_hosts_repair
PRESENT=0 PRESENT=0
for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do for ip in 192.168.0.110 192.168.0.111 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do
if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then
PRESENT=\$((PRESENT + 1)) PRESENT=\$((PRESENT + 1))
fi fi
@@ -3030,7 +3030,7 @@ jobs:
fi fi
sleep "\$scan_attempt" sleep "\$scan_attempt"
done done
for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do for ip in 192.168.0.110 192.168.0.111 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do
if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then
: :
else else
@@ -3047,7 +3047,7 @@ jobs:
KNOWN_HOSTS_B64=\$(base64 -w 0 /tmp/known_hosts_repair) KNOWN_HOSTS_B64=\$(base64 -w 0 /tmp/known_hosts_repair)
\$KUBECTL patch secret ssh-mcp-key -n awoooi-prod --type=merge \ \$KUBECTL patch secret ssh-mcp-key -n awoooi-prod --type=merge \
-p="{\"data\":{\"known_hosts\":\"\${KNOWN_HOSTS_B64}\"}}" \ -p="{\"data\":{\"known_hosts\":\"\${KNOWN_HOSTS_B64}\"}}" \
&& echo "✅ ssh-mcp-key known_hosts 已更新(5 台主機完整)" \ && echo "✅ ssh-mcp-key known_hosts 已更新(6 台主機完整)" \
|| { echo "❌ ssh-mcp-key known_hosts 更新失敗,停止部署"; exit 1; } || { echo "❌ ssh-mcp-key known_hosts 更新失敗,停止部署"; exit 1; }
rm -f /tmp/known_hosts_repair /tmp/known_hosts_scan_err rm -f /tmp/known_hosts_repair /tmp/known_hosts_scan_err
else else
@@ -3059,7 +3059,7 @@ jobs:
EXISTING_PRESENT=0 EXISTING_PRESENT=0
if \$KUBECTL get secret ssh-mcp-key -n awoooi-prod \ if \$KUBECTL get secret ssh-mcp-key -n awoooi-prod \
-o jsonpath='{.data.known_hosts}' | base64 -d > "\$EXISTING_KNOWN_HOSTS"; then -o jsonpath='{.data.known_hosts}' | base64 -d > "\$EXISTING_KNOWN_HOSTS"; then
for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do for ip in 192.168.0.110 192.168.0.111 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do
if grep -qE "^\${ip}[[:space:]]" "\$EXISTING_KNOWN_HOSTS" 2>/dev/null; then if grep -qE "^\${ip}[[:space:]]" "\$EXISTING_KNOWN_HOSTS" 2>/dev/null; then
EXISTING_PRESENT=\$((EXISTING_PRESENT + 1)) EXISTING_PRESENT=\$((EXISTING_PRESENT + 1))
fi fi
@@ -3067,7 +3067,7 @@ jobs:
fi fi
rm -f "\$EXISTING_KNOWN_HOSTS" /tmp/known_hosts_repair /tmp/known_hosts_scan_err rm -f "\$EXISTING_KNOWN_HOSTS" /tmp/known_hosts_repair /tmp/known_hosts_scan_err
if [ "\$EXISTING_PRESENT" -eq "\$EXPECTED_HOSTS" ]; then if [ "\$EXISTING_PRESENT" -eq "\$EXPECTED_HOSTS" ]; then
echo "⚠️ fresh host-key scan \${PRESENT}/\${EXPECTED_HOSTS}; existing ssh-mcp-key verified 5/5 and preserved" echo "⚠️ fresh host-key scan \${PRESENT}/\${EXPECTED_HOSTS}; existing ssh-mcp-key verified 6/6 and preserved"
else else
echo "❌ fresh host-key scan \${PRESENT}/\${EXPECTED_HOSTS}; existing ssh-mcp-key coverage \${EXISTING_PRESENT}/\${EXPECTED_HOSTS}" echo "❌ fresh host-key scan \${PRESENT}/\${EXPECTED_HOSTS}; existing ssh-mcp-key coverage \${EXISTING_PRESENT}/\${EXPECTED_HOSTS}"
exit 1 exit 1

View File

@@ -6,6 +6,7 @@ import asyncio
import hashlib import hashlib
import os import os
import re import re
import shlex
import time import time
from collections.abc import Awaitable, Callable, Mapping from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
@@ -24,9 +25,11 @@ VERIFIER_ID = "asset_specific_read_only_host_postconditions"
INDEPENDENT_SOURCE = "broker_ssh_host_runtime_readback" INDEPENDENT_SOURCE = "broker_ssh_host_runtime_readback"
_SAFE_HOST_ALIAS_RE = re.compile(r"^[A-Za-z0-9_.-]+$") _SAFE_HOST_ALIAS_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
_SAFE_REMOTE_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$") _SAFE_REMOTE_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
_FIXED_PROXY_JUMP_BY_HOST = { _FIXED_PROXY_TARGET_BY_HOST = {
"host_111": "wooo@192.168.0.110", "host_111": "wooo@192.168.0.110",
} }
_INVENTORY_PROXY_KEY_PATH = Path("/run/secrets/ssh_mcp_key")
_INVENTORY_PROXY_KNOWN_HOSTS_PATH = Path("/etc/ssh-mcp/known_hosts")
_RUNTIME_CLOSURE_RECEIPT_CATALOGS = frozenset( _RUNTIME_CLOSURE_RECEIPT_CATALOGS = frozenset(
{"ansible:188-openclaw-callback-forwarder"} {"ansible:188-openclaw-callback-forwarder"}
) )
@@ -699,6 +702,23 @@ def _load_inventory_hosts(inventory_path: Path) -> dict[str, dict[str, str]]:
return hosts return hosts
def _pinned_proxy_command(
*,
proxy_target: str,
ssh_key_path: Path,
known_hosts_path: Path,
) -> str:
if not re.fullmatch(r"[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+", proxy_target):
raise ValueError("postcondition_proxy_target_invalid")
return (
f"ssh -i {shlex.quote(str(ssh_key_path))} "
f"-o UserKnownHostsFile={shlex.quote(str(known_hosts_path))} "
"-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "
"-o BatchMode=yes -o ConnectTimeout=10 "
f"-W %h:%p {proxy_target}"
)
def build_read_only_probe_command( def build_read_only_probe_command(
condition: AssetPostcondition, condition: AssetPostcondition,
*, *,
@@ -729,14 +749,30 @@ def build_read_only_probe_command(
"-o", "-o",
"ConnectTimeout=10", "ConnectTimeout=10",
] ]
proxy_jump = _FIXED_PROXY_JUMP_BY_HOST.get(condition.inventory_host) proxy_target = _FIXED_PROXY_TARGET_BY_HOST.get(condition.inventory_host)
if proxy_jump is not None: if proxy_target is not None:
inventory_proxy_command = _pinned_proxy_command(
proxy_target=proxy_target,
ssh_key_path=_INVENTORY_PROXY_KEY_PATH,
known_hosts_path=_INVENTORY_PROXY_KNOWN_HOSTS_PATH,
)
expected_common_args = ( expected_common_args = (
f"-o ProxyJump={proxy_jump} -o StrictHostKeyChecking=yes" f'-o ProxyCommand="{inventory_proxy_command}" '
"-o StrictHostKeyChecking=yes"
) )
if target.get("ansible_ssh_common_args") != expected_common_args: if target.get("ansible_ssh_common_args") != expected_common_args:
raise ValueError("postcondition_proxy_jump_policy_mismatch") raise ValueError("postcondition_proxy_command_policy_mismatch")
command.extend(["-o", f"ProxyJump={proxy_jump}"]) command.extend(
[
"-o",
"ProxyCommand="
+ _pinned_proxy_command(
proxy_target=proxy_target,
ssh_key_path=ssh_key_path,
known_hosts_path=known_hosts_path,
),
]
)
command.extend([f"{user}@{host}", condition.probe]) command.extend([f"{user}@{host}", condition.probe])
return command return command

View File

@@ -187,9 +187,14 @@ def test_cd_applies_rolls_back_and_verifies_network_boundary() -> None:
"192.168.0.110 192.168.0.112 192.168.0.120 " "192.168.0.110 192.168.0.112 192.168.0.120 "
"192.168.0.121 192.168.0.188" "192.168.0.121 192.168.0.188"
) )
assert f"ssh-keyscan -T 5 {expected_hosts}" in workflow expected_known_hosts = (
assert "EXPECTED_HOSTS=5" in workflow "192.168.0.110 192.168.0.111 192.168.0.112 192.168.0.120 "
assert workflow.count(expected_hosts) >= 4 "192.168.0.121 192.168.0.188"
)
assert f"ssh-keyscan -T 5 {expected_known_hosts}" in workflow
assert "EXPECTED_HOSTS=6" in workflow
assert workflow.count(expected_known_hosts) >= 4
assert workflow.count(expected_hosts) >= 2
assert "except ConnectionRefusedError:" in workflow assert "except ConnectionRefusedError:" in workflow
assert "broker_ssh_refused_but_egress_permitted=" in workflow assert "broker_ssh_refused_but_egress_permitted=" in workflow
assert "broker_ssh_allowlisted_endpoint_unavailable=" in workflow assert "broker_ssh_allowlisted_endpoint_unavailable=" in workflow

View File

@@ -30,6 +30,13 @@ EXPECTED_ALLOWLIST = (
"192.168.0.120/32,192.168.0.121/32" "192.168.0.120/32,192.168.0.121/32"
) )
EXPECTED_PROXY_JUMP = "wooo@192.168.0.110" EXPECTED_PROXY_JUMP = "wooo@192.168.0.110"
EXPECTED_PROXY_COMMAND = (
"ssh -i /run/secrets/ssh_mcp_key "
"-o UserKnownHostsFile=/etc/ssh-mcp/known_hosts "
"-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "
"-o BatchMode=yes -o ConnectTimeout=10 "
f"-W %h:%p {EXPECTED_PROXY_JUMP}"
)
def _flatten_tasks(tasks: list[dict]) -> list[dict]: def _flatten_tasks(tasks: list[dict]) -> list[dict]:
@@ -150,7 +157,12 @@ def test_host111_playbook_is_launchagent_bounded_and_check_mode_safe() -> None:
assert play["vars"]["proxy_label"] == "com.momo.ollama111-allow-proxy" assert play["vars"]["proxy_label"] == "com.momo.ollama111-allow-proxy"
assert play["vars"]["allowed_cidrs"] == EXPECTED_ALLOWLIST assert play["vars"]["allowed_cidrs"] == EXPECTED_ALLOWLIST
assert "192.168.0.110/32" not in play["vars"]["allowed_cidrs"].split(",") assert "192.168.0.110/32" not in play["vars"]["allowed_cidrs"].split(",")
assert "launchctl bootstrap" in source assert source.count("launchctl bootout") == 2
assert source.count("launchctl bootstrap") == 2
assert source.count("launchctl kickstart -k") == 2
assert source.count('test "$stopped" -eq 1') == 2
assert source.count('test "$loaded" -eq 1') == 2
assert source.count('test "$running" -eq 1') == 2
assert "systemctl" not in source assert "systemctl" not in source
assert "docker restart" not in source assert "docker restart" not in source
@@ -203,9 +215,10 @@ def test_host111_broker_transport_preserves_fixed_proxyjump_and_strict_safety(
host111 = inventory["all"]["children"]["ollama_fallback"]["hosts"][ host111 = inventory["all"]["children"]["ollama_fallback"]["hosts"][
"host_111" "host_111"
] ]
assert host111["ansible_ssh_common_args"] == ( assert f'-o ProxyCommand="{EXPECTED_PROXY_COMMAND}"' in (
f"-o ProxyJump={EXPECTED_PROXY_JUMP} -o StrictHostKeyChecking=yes" host111["ansible_ssh_common_args"]
) )
assert "ProxyJump" not in host111["ansible_ssh_common_args"]
assert "accept-new" not in host111["ansible_ssh_common_args"] assert "accept-new" not in host111["ansible_ssh_common_args"]
key = tmp_path / "ssh_mcp_key" key = tmp_path / "ssh_mcp_key"
@@ -227,7 +240,7 @@ def test_host111_broker_transport_preserves_fixed_proxyjump_and_strict_safety(
assert "ProxyJump" not in spec.env["ANSIBLE_SSH_ARGS"] assert "ProxyJump" not in spec.env["ANSIBLE_SSH_ARGS"]
def test_host111_independent_verifier_uses_same_fixed_proxyjump( def test_host111_independent_verifier_uses_same_pinned_proxy_command(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
condition = next( condition = next(
@@ -245,7 +258,13 @@ def test_host111_independent_verifier_uses_same_fixed_proxyjump(
known_hosts_path=known_hosts, known_hosts_path=known_hosts,
) )
assert f"ProxyJump={EXPECTED_PROXY_JUMP}" in command expected_verifier_proxy_command = (
f"ssh -i {key} -o UserKnownHostsFile={known_hosts} "
"-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "
"-o BatchMode=yes -o ConnectTimeout=10 "
f"-W %h:%p {EXPECTED_PROXY_JUMP}"
)
assert f"ProxyCommand={expected_verifier_proxy_command}" in command
assert "StrictHostKeyChecking=yes" in command assert "StrictHostKeyChecking=yes" in command
assert "BatchMode=yes" in command assert "BatchMode=yes" in command
assert "ooo@192.168.0.111" in command assert "ooo@192.168.0.111" in command

View File

@@ -26,7 +26,17 @@ all:
ansible_host: 192.168.0.111 ansible_host: 192.168.0.111
ansible_user: ooo ansible_user: ooo
ansible_ssh_private_key_file: "~/.ssh/id_rsa" ansible_ssh_private_key_file: "~/.ssh/id_rsa"
ansible_ssh_common_args: "-o ProxyJump=wooo@192.168.0.110 -o StrictHostKeyChecking=yes" # ProxyJump starts a second ssh process and does not inherit the
# executor's pinned key/known_hosts options. Keep both hops pinned.
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i /run/secrets/ssh_mcp_key
-o UserKnownHostsFile=/etc/ssh-mcp/known_hosts
-o StrictHostKeyChecking=yes
-o IdentitiesOnly=yes
-o BatchMode=yes
-o ConnectTimeout=10
-W %h:%p wooo@192.168.0.110"
-o StrictHostKeyChecking=yes
k3s_masters: k3s_masters:
hosts: hosts:

View File

@@ -145,10 +145,37 @@
- name: "Ollama111 | rollback 重新啟動 allow proxy" - name: "Ollama111 | rollback 重新啟動 allow proxy"
ansible.builtin.shell: ansible.builtin.shell:
cmd: | cmd: |
set -e set -eu
launchctl enable gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null || true launchctl enable gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null || true
launchctl bootout gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null || true launchctl bootout gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null || true
launchctl bootstrap gui/{{ proxy_user_uid }} {{ proxy_plist }} stopped=0
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if ! launchctl print gui/{{ proxy_user_uid }}/{{ proxy_label }} >/dev/null 2>&1; then
stopped=1
break
fi
sleep 1
done
test "$stopped" -eq 1
loaded=0
for attempt in 1 2 3 4 5; do
if launchctl bootstrap gui/{{ proxy_user_uid }} {{ proxy_plist }} >/dev/null 2>&1; then
loaded=1
break
fi
sleep 1
done
test "$loaded" -eq 1
launchctl kickstart -k gui/{{ proxy_user_uid }}/{{ proxy_label }}
running=0
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if launchctl print gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null | grep -q 'state = running'; then
running=1
break
fi
sleep 1
done
test "$running" -eq 1
when: not ansible_check_mode when: not ansible_check_mode
changed_when: true changed_when: true
tags: ["111", "ollama-fallback", "rollback"] tags: ["111", "ollama-fallback", "rollback"]
@@ -195,8 +222,35 @@
- name: Restart ollama111 allow proxy - name: Restart ollama111 allow proxy
ansible.builtin.shell: ansible.builtin.shell:
cmd: | cmd: |
set -e set -eu
launchctl enable gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null || true launchctl enable gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null || true
launchctl bootout gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null || true launchctl bootout gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null || true
launchctl bootstrap gui/{{ proxy_user_uid }} {{ proxy_plist }} stopped=0
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if ! launchctl print gui/{{ proxy_user_uid }}/{{ proxy_label }} >/dev/null 2>&1; then
stopped=1
break
fi
sleep 1
done
test "$stopped" -eq 1
loaded=0
for attempt in 1 2 3 4 5; do
if launchctl bootstrap gui/{{ proxy_user_uid }} {{ proxy_plist }} >/dev/null 2>&1; then
loaded=1
break
fi
sleep 1
done
test "$loaded" -eq 1
launchctl kickstart -k gui/{{ proxy_user_uid }}/{{ proxy_label }}
running=0
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if launchctl print gui/{{ proxy_user_uid }}/{{ proxy_label }} 2>/dev/null | grep -q 'state = running'; then
running=1
break
fi
sleep 1
done
test "$running" -eq 1
changed_when: true changed_when: true