Skip to content

Commit a2344cf

Browse files
committed
fix: harden npm gateway bootstrap and add coldstart stress validation
1 parent d29b617 commit a2344cf

5 files changed

Lines changed: 228 additions & 13 deletions

File tree

apphub/src/services/integration_session_bridge.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import re
33
import json
4+
import time
45
from pathlib import Path
56
from typing import Literal, Optional
67
from urllib.parse import urlparse
@@ -39,6 +40,8 @@ def __init__(self, gateway_origin: Optional[str] = None):
3940
).rstrip("/")
4041
self.npm_credential_path = Path(os.getenv("WEBSOFT9_NPM_CREDENTIAL_PATH", str(data_root / "credential.json")))
4142
self.npm_database_path = Path(os.getenv("WEBSOFT9_NPM_DATABASE_PATH", str(data_root / "database.sqlite")))
43+
self.npm_bootstrap_retry_attempts = max(1, int(os.getenv("WEBSOFT9_NPM_BOOTSTRAP_RETRY_ATTEMPTS", "6")))
44+
self.npm_bootstrap_retry_interval_seconds = max(1, int(os.getenv("WEBSOFT9_NPM_BOOTSTRAP_RETRY_INTERVAL_SECONDS", "2")))
4245

4346
def _resolve_platform_origin(self) -> str:
4447
public_origin = (os.getenv("WEBSOFT9_PLATFORM_PUBLIC_ORIGIN") or "").strip().rstrip("/")
@@ -273,7 +276,7 @@ def bootstrap_npm(self) -> list[dict[str, object]]:
273276
active_credentials = self.credential_provider.get_npm_credentials()
274277

275278
fallback_credentials = self.credential_provider.get_npm_config_credentials()
276-
response = self._request_npm_token(session, active_credentials.username, active_credentials.password)
279+
response = self._request_npm_token_with_retry(session, active_credentials.username, active_credentials.password)
277280

278281
should_try_fallback = (
279282
response.status_code in {401, 403}
@@ -287,7 +290,7 @@ def bootstrap_npm(self) -> list[dict[str, object]]:
287290
)
288291

289292
if should_try_fallback:
290-
fallback_response = self._request_npm_token(session, fallback_credentials.username, fallback_credentials.password)
293+
fallback_response = self._request_npm_token_with_retry(session, fallback_credentials.username, fallback_credentials.password)
291294
if fallback_response.status_code == 200:
292295
self.credential_provider.write_npm_credentials(fallback_credentials)
293296
active_credentials = fallback_credentials
@@ -298,7 +301,7 @@ def bootstrap_npm(self) -> list[dict[str, object]]:
298301

299302
if response.status_code in {401, 403} and self.credential_provider.sync_npm_credentials(active_credentials):
300303
logger.warning("Nginx Proxy Manager authentication recovered after synchronizing stored credentials into the runtime database")
301-
response = self._request_npm_token(session, active_credentials.username, active_credentials.password)
304+
response = self._request_npm_token_with_retry(session, active_credentials.username, active_credentials.password)
302305

303306
if response.status_code in {401, 403}:
304307
raise CustomException(
@@ -348,6 +351,45 @@ def _request_npm_token(self, session: requests.Session, username: str, password:
348351
timeout=20,
349352
)
350353

354+
def _request_npm_token_with_retry(self, session: requests.Session, username: str, password: str) -> requests.Response:
355+
last_exception: Exception | None = None
356+
response: requests.Response | None = None
357+
358+
for attempt in range(1, self.npm_bootstrap_retry_attempts + 1):
359+
try:
360+
response = self._request_npm_token(session, username, password)
361+
if response.status_code not in {502, 503, 504}:
362+
return response
363+
364+
if attempt < self.npm_bootstrap_retry_attempts:
365+
logger.warning(
366+
"Nginx Proxy Manager token endpoint not ready "
367+
f"(status={response.status_code}, attempt={attempt}/{self.npm_bootstrap_retry_attempts}); retrying"
368+
)
369+
time.sleep(self.npm_bootstrap_retry_interval_seconds)
370+
except requests.RequestException as exc:
371+
last_exception = exc
372+
if attempt < self.npm_bootstrap_retry_attempts:
373+
logger.warning(
374+
"Nginx Proxy Manager token request failed "
375+
f"(attempt={attempt}/{self.npm_bootstrap_retry_attempts}): {exc}; retrying"
376+
)
377+
time.sleep(self.npm_bootstrap_retry_interval_seconds)
378+
else:
379+
break
380+
381+
if response is not None:
382+
return response
383+
384+
if last_exception is not None:
385+
raise last_exception
386+
387+
raise CustomException(
388+
status_code=502,
389+
message="Integration Session Bootstrap Failed",
390+
details="Unable to restore the embedded gateway session automatically",
391+
)
392+
351393
def _request_portainer_token(self, session: requests.Session, username: str, password: str) -> requests.Response:
352394
return session.post(
353395
f"{self.portainer_direct_origin}/api/auth",

apphub/tests/test_integrations.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ def post(self, url, json, timeout):
141141

142142
cookies = bridge.bootstrap_portainer()
143143

144-
assert cookies == [{"name": bridge._portainer_cookie_name(), "value": "portainer-token", "path": "/", "httponly": True}]
144+
assert cookies[0] == {"name": bridge._portainer_cookie_name(), "value": "portainer-token", "path": "/", "httponly": True}
145+
assert {cookie["name"] for cookie in cookies[1:]} == {"portainer_jwt", "portainer.JWT", "portainer_api_key"}
145146
assert writes == [fallback]
146147
assert len(fake_session.calls) == 2
147148

@@ -192,4 +193,45 @@ def post(self, url, json, timeout):
192193
assert writes == [fallback]
193194
assert len(fake_session.calls) == 2
194195
assert fake_session.calls[0][0] == "http://127.0.0.1:81/api/tokens"
195-
assert fake_session.calls[1][0] == "http://127.0.0.1:81/api/tokens"
196+
assert fake_session.calls[1][0] == "http://127.0.0.1:81/api/tokens"
197+
198+
199+
def test_npm_bootstrap_retries_transient_gateway_errors(monkeypatch):
200+
bridge = IntegrationSessionBridge(gateway_origin="http://gateway.test")
201+
bridge.npm_bootstrap_retry_attempts = 3
202+
bridge.npm_bootstrap_retry_interval_seconds = 1
203+
204+
credentials = SimpleNamespace(
205+
username="admin@example.com",
206+
password="active-pass",
207+
nickname="operator",
208+
display_name="Operator",
209+
)
210+
211+
bridge.credential_provider = SimpleNamespace(
212+
get_npm_credentials=lambda: credentials,
213+
get_npm_config_credentials=lambda: credentials,
214+
write_npm_credentials=lambda _credentials: None,
215+
sync_npm_credentials=lambda _credentials: False,
216+
)
217+
218+
call_count = {"value": 0}
219+
220+
def fake_request_npm_token(_session, username, password):
221+
call_count["value"] += 1
222+
assert username == "admin@example.com"
223+
assert password == "active-pass"
224+
if call_count["value"] == 1:
225+
return FakeResponse(502, {"message": "Bad Gateway"})
226+
return FakeResponse(200, {"token": "npm-token"})
227+
228+
monkeypatch.setattr(bridge, "_request_npm_token", fake_request_npm_token)
229+
monkeypatch.setattr("src.services.integration_session_bridge.time.sleep", lambda _seconds: None)
230+
231+
cookies = bridge.bootstrap_npm()
232+
233+
assert call_count["value"] == 2
234+
assert cookies == [
235+
{"name": bridge._npm_token_cookie_name(), "value": "npm-token", "path": "/", "httponly": False},
236+
{"name": bridge._npm_nickname_cookie_name(), "value": "operator", "path": "/", "httponly": False},
237+
]

install/lib/upgrade-legacy.sh

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,19 @@ run_upgrade_legacy() {
746746
# Prepare modern deployment material without touching the transformed data root.
747747
install_prepare_material "$install_path" "$image_tag" "$console_port"
748748

749+
# Resolve the modern container name from generated deployment material.
750+
# Legacy migration can run on dev/rc channels where container name is not "websoft9".
751+
local env_file compose_file
752+
env_file="${install_path}/.env"
753+
compose_file="${install_path}/docker-compose.yml"
754+
if [ -f "$env_file" ]; then
755+
CONTAINER_NAME="$(grep -m1 '^CONTAINER_NAME=' "$env_file" 2>/dev/null | cut -d= -f2-)"
756+
[ -n "$CONTAINER_NAME" ] && export CONTAINER_NAME
757+
fi
758+
MODERN_CONTAINER_NAME="$(_resolve_container_name "$compose_file")"
759+
export MODERN_CONTAINER_NAME
760+
log_info "Resolved modern container name for migration: ${MODERN_CONTAINER_NAME}"
761+
749762
if [ "${W9_DRY_RUN:-0}" = "1" ]; then
750763
log_info "(dry-run) pre-cutover migration steps completed; stopping before the modern runtime takeover"
751764
log_info "Pre-migration backup point: $backup_dir"
@@ -781,13 +794,10 @@ run_upgrade_legacy() {
781794
die "$EXIT_VALIDATE" "Migration failed during post-cutover validation"
782795
fi
783796

784-
# Stage 8b: remove legacy containers and volumes.
785-
# Host-level artifacts (/data/compose, /data/apps, Cockpit, systemd) are
786-
# deliberately retained — legacy stacks still reference /data/compose for
787-
# bind mounts, and control-plane cleanup should happen after the rollback
788-
# window closes.
789-
log_step "Removing legacy containers and volumes"
790-
_uninstall_legacy "purge" "0" "1" "0"
797+
# Stage 8b: remove legacy containers, volumes, and control-plane artifacts.
798+
# On successful migration, fully retire Cockpit/systemd-based legacy runtime.
799+
log_step "Removing legacy containers, volumes, and legacy control plane"
800+
_uninstall_legacy "purge" "0" "1" "1"
791801

792802
log_info "==== Legacy-to-modern migration completed successfully ===="
793803
print_runtime_summary migration "$install_path" "$console_port" "$backup_dir"
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
container_name="${1:-websoft9-dev}"
6+
rounds="${2:-10}"
7+
settle_seconds="${3:-1}"
8+
report_file="${4:-/tmp/websoft9_npm_coldstart_report.txt}"
9+
health_wait_attempts="${WEBSOFT9_STRESS_HEALTH_WAIT_ATTEMPTS:-30}"
10+
11+
if ! [[ "$rounds" =~ ^[0-9]+$ ]] || [[ "$rounds" -le 0 ]]; then
12+
echo "rounds must be a positive integer" >&2
13+
exit 1
14+
fi
15+
16+
if ! [[ "$settle_seconds" =~ ^[0-9]+$ ]] || [[ "$settle_seconds" -lt 0 ]]; then
17+
echo "settle_seconds must be a non-negative integer" >&2
18+
exit 1
19+
fi
20+
21+
api_url="http://127.0.0.1:9000/api/integrations/npm/session"
22+
health_url="http://127.0.0.1:9000/api/healthz"
23+
start_ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
24+
25+
curl_common=(--noproxy '*' --silent --show-error --connect-timeout 2 --max-time 4)
26+
27+
declare -a duration_ms_list=()
28+
success_count=0
29+
failure_count=0
30+
31+
: > "$report_file"
32+
echo "websoft9 npm gateway coldstart stress report" >> "$report_file"
33+
echo "start_time_utc=$start_ts" >> "$report_file"
34+
echo "container=$container_name rounds=$rounds settle_seconds=$settle_seconds" >> "$report_file"
35+
echo "health_wait_attempts=$health_wait_attempts" >> "$report_file"
36+
echo "" >> "$report_file"
37+
38+
echo "Running $rounds rounds against $container_name"
39+
40+
for ((i=1; i<=rounds; i++)); do
41+
echo "[$i/$rounds] restarting npm-backend + npm-nginx"
42+
docker exec "$container_name" supervisorctl -c /etc/supervisor/conf.d/websoft9-platform.conf restart npm-backend npm-nginx >/tmp/w9_supervisor_restart.log 2>&1
43+
44+
if [[ "$settle_seconds" -gt 0 ]]; then
45+
sleep "$settle_seconds"
46+
fi
47+
48+
health_ready="false"
49+
health_recovery_ms=0
50+
health_start_ms="$(date +%s%3N)"
51+
for ((attempt=1; attempt<=health_wait_attempts; attempt++)); do
52+
health_code="$(curl "${curl_common[@]}" -o /dev/null -w '%{http_code}' "$health_url" || true)"
53+
if [[ "$health_code" == "200" ]]; then
54+
health_ready="true"
55+
health_end_ms="$(date +%s%3N)"
56+
health_recovery_ms=$((health_end_ms - health_start_ms))
57+
break
58+
fi
59+
sleep 1
60+
done
61+
62+
response_file="/tmp/w9_npm_stress_resp_${i}.json"
63+
timing_file="/tmp/w9_npm_stress_timing_${i}.txt"
64+
: > "$response_file"
65+
: > "$timing_file"
66+
67+
if [[ "$health_ready" == "true" ]]; then
68+
curl "${curl_common[@]}" -X POST "$api_url" -H 'Content-Type: application/json' -o "$response_file" -w '%{http_code} %{time_total}\n' > "$timing_file" || true
69+
else
70+
printf '000 0\n' > "$timing_file"
71+
printf '{"status":"error","details":"gateway health check did not recover in time"}' > "$response_file"
72+
fi
73+
74+
status_code="$(awk '{print $1}' "$timing_file" | tr -d '\r' || true)"
75+
time_total="$(awk '{print $2}' "$timing_file" | tr -d '\r' || true)"
76+
77+
if [[ -z "$status_code" ]]; then
78+
status_code="000"
79+
fi
80+
81+
duration_ms="$(awk -v t="${time_total:-0}" 'BEGIN{printf "%d", t*1000}')"
82+
if [[ -z "$duration_ms" ]]; then
83+
duration_ms=0
84+
fi
85+
86+
if [[ "$status_code" == "200" ]]; then
87+
result="PASS"
88+
success_count=$((success_count + 1))
89+
else
90+
result="FAIL"
91+
failure_count=$((failure_count + 1))
92+
fi
93+
94+
duration_ms_list+=("$duration_ms")
95+
payload_preview="$(tr -d '\n' < "$response_file" | cut -c1-200)"
96+
97+
printf 'round=%02d result=%s health_ready=%s health_recovery_ms=%s status=%s duration_ms=%s payload=%s\n' "$i" "$result" "$health_ready" "$health_recovery_ms" "$status_code" "$duration_ms" "$payload_preview" | tee -a "$report_file"
98+
done
99+
100+
sorted_durations="$(printf '%s\n' "${duration_ms_list[@]}" | sort -n)"
101+
mean_ms="$(printf '%s\n' "${duration_ms_list[@]}" | awk '{sum+=$1} END{if (NR>0) printf "%.2f", sum/NR; else print "0"}')"
102+
min_ms="$(printf '%s\n' "$sorted_durations" | head -n 1)"
103+
max_ms="$(printf '%s\n' "$sorted_durations" | tail -n 1)"
104+
105+
p95_index=$(( (rounds * 95 + 99) / 100 ))
106+
p95_ms="$(printf '%s\n' "$sorted_durations" | sed -n "${p95_index}p")"
107+
108+
success_rate="$(awk -v s="$success_count" -v r="$rounds" 'BEGIN{printf "%.2f", (r>0 ? s*100/r : 0)}')"
109+
end_ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
110+
111+
echo "" >> "$report_file"
112+
echo "summary:" >> "$report_file"
113+
echo "end_time_utc=$end_ts" >> "$report_file"
114+
echo "success_count=$success_count failure_count=$failure_count success_rate_percent=$success_rate" >> "$report_file"
115+
echo "latency_ms min=$min_ms mean=$mean_ms p95=$p95_ms max=$max_ms" >> "$report_file"
116+
117+
echo ""
118+
echo "Completed. Summary:"
119+
cat "$report_file" | tail -n 5
120+
121+
echo "Report saved to $report_file"

scripts/platform-entrypoint.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ bootstrap_portainer() {
238238
bootstrap_nginx_proxy_manager() {
239239
log "phase=workspace-bootstrap action=bootstrap-nginx-proxy-manager"
240240

241-
if ! wait_for_url "nginx-proxy-manager" "${WEBSOFT9_NPM_HEALTH_URL:-http://127.0.0.1:81/}" 45; then
241+
if ! wait_for_url "nginx-proxy-manager" "${WEBSOFT9_NPM_HEALTH_URL:-http://127.0.0.1:81/api/}" 45; then
242242
write_status "degraded" "nginx-proxy-manager failed to become healthy during bootstrap"
243243
return 0
244244
fi

0 commit comments

Comments
 (0)