chore: commit all pending changes and ignore project context
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
PUBLIC_HOST=stend.4mont.ru
|
||||||
|
LETSENCRYPT_EMAIL=admin@4mont.ru
|
||||||
|
|
||||||
|
POSTGRES_DB=portal
|
||||||
|
POSTGRES_USER=portal
|
||||||
|
POSTGRES_PASSWORD=change_me
|
||||||
|
|
||||||
|
SIGNING_KEY=replace_with_long_random_key
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=StrongAdminPassword!
|
||||||
|
SESSION_IDLE_SECONDS=300
|
||||||
|
PREWARM_POOL_SIZE=2
|
||||||
|
UNIVERSAL_POOL_SIZE=0
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER=4
|
||||||
|
LOG_LEVEL=INFO
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
PUBLIC_HOST=stend.4mont.ru
|
||||||
|
LETSENCRYPT_EMAIL=admin@4mont.ru
|
||||||
|
|
||||||
|
POSTGRES_DB=portal
|
||||||
|
POSTGRES_USER=portal
|
||||||
|
POSTGRES_PASSWORD=change_me
|
||||||
|
|
||||||
|
SIGNING_KEY=9a6d4b053a47ae24078e07587e69f344111652f153ba50eff31603e43c91f89b
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=StrongAdminPassword!
|
||||||
|
SESSION_IDLE_SECONDS=300
|
||||||
|
PREWARM_POOL_SIZE=2
|
||||||
|
UNIVERSAL_POOL_SIZE=0
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER=4
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
WEB_POOL_SIZE=20
|
||||||
|
WEB_POOL_BUFFER=2
|
||||||
@@ -10,3 +10,5 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
traefik/letsencrypt/acme.json
|
traefik/letsencrypt/acme.json
|
||||||
|
docs/PROJECT_CONTEXT.md
|
||||||
|
PROJECT_CONTEXT.md
|
||||||
|
|||||||
+306
-138
@@ -47,12 +47,18 @@ SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "300"))
|
|||||||
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
|
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
|
LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "8.0"))
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "5.0"))
|
||||||
|
POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "4"))
|
||||||
|
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0"))
|
||||||
|
POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
|
||||||
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
|
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
|
||||||
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0"))
|
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0"))
|
||||||
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
|
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
|
||||||
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5"))
|
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5"))
|
||||||
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
|
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
|
||||||
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
|
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
|
||||||
|
ENABLE_STARTUP_MAINTENANCE = os.getenv("ENABLE_STARTUP_MAINTENANCE", "1") == "1"
|
||||||
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
||||||
ICON_UPLOAD_TYPES = {
|
ICON_UPLOAD_TYPES = {
|
||||||
"image/png": "png",
|
"image/png": "png",
|
||||||
@@ -67,6 +73,7 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("portal")
|
logger = logging.getLogger("portal")
|
||||||
request_id_ctx = contextvars.ContextVar("request_id", default="-")
|
request_id_ctx = contextvars.ContextVar("request_id", default="-")
|
||||||
|
maintenance_lock_file = None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_log_value(value):
|
def _normalize_log_value(value):
|
||||||
@@ -791,14 +798,14 @@ def dispatch_universal_target(slot: int, service: Service) -> None:
|
|||||||
raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only")
|
raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only")
|
||||||
|
|
||||||
last_exc = None
|
last_exc = None
|
||||||
for _ in range(8):
|
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, json=payload, timeout=3)
|
resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
time.sleep(0.4)
|
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
|
||||||
if last_exc:
|
if last_exc:
|
||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
@@ -808,14 +815,14 @@ def dispatch_web_pool_target(slot: int, service: Service) -> None:
|
|||||||
target_url = normalize_web_target(service.target)
|
target_url = normalize_web_target(service.target)
|
||||||
url = f"http://{name}:7000/open"
|
url = f"http://{name}:7000/open"
|
||||||
last_exc = None
|
last_exc = None
|
||||||
for _ in range(8):
|
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, json={"url": target_url}, timeout=3)
|
resp = requests.post(url, json={"url": target_url}, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
time.sleep(0.4)
|
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
|
||||||
if last_exc:
|
if last_exc:
|
||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
@@ -1363,14 +1370,31 @@ def find_active_session_for_user_service(db: Session, user_id: int, service_id:
|
|||||||
return db.scalars(q).first()
|
return db.scalars(q).first()
|
||||||
|
|
||||||
|
|
||||||
def allocator_lock(db: Session, lock_id: int):
|
class LockTimeoutError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def allocator_lock(db: Session, lock_id: int, timeout_seconds: Optional[float] = None, poll_seconds: float = 0.05):
|
||||||
class _LockCtx:
|
class _LockCtx:
|
||||||
def __enter__(self_nonlocal):
|
def __enter__(self_nonlocal):
|
||||||
db.execute(text("SELECT pg_advisory_lock(:lid)"), {"lid": lock_id})
|
self_nonlocal._acquired = False
|
||||||
|
if timeout_seconds is None:
|
||||||
|
db.execute(text("SELECT pg_advisory_xact_lock(:lid)"), {"lid": lock_id})
|
||||||
|
self_nonlocal._acquired = True
|
||||||
|
return self_nonlocal
|
||||||
|
|
||||||
|
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
||||||
|
while time.monotonic() <= deadline:
|
||||||
|
got = db.execute(text("SELECT pg_try_advisory_xact_lock(:lid)"), {"lid": lock_id}).scalar()
|
||||||
|
if got:
|
||||||
|
self_nonlocal._acquired = True
|
||||||
|
return self_nonlocal
|
||||||
|
time.sleep(max(0.01, poll_seconds))
|
||||||
|
raise LockTimeoutError(f"advisory lock timeout lock_id={lock_id} timeout={timeout_seconds}")
|
||||||
|
|
||||||
return self_nonlocal
|
return self_nonlocal
|
||||||
|
|
||||||
def __exit__(self_nonlocal, exc_type, exc, tb):
|
def __exit__(self_nonlocal, exc_type, exc, tb):
|
||||||
db.execute(text("SELECT pg_advisory_unlock(:lid)"), {"lid": lock_id})
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return _LockCtx()
|
return _LockCtx()
|
||||||
@@ -1492,17 +1516,36 @@ def bootstrap_admin():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
def try_acquire_maintenance_leader() -> bool:
|
||||||
def startup_event():
|
global maintenance_lock_file
|
||||||
# Multiple uvicorn workers run startup in parallel. Serialize schema bootstrap
|
if maintenance_lock_file is not None:
|
||||||
# to avoid DDL races on first run and during schema extension.
|
return True
|
||||||
|
lock_file = open("/tmp/portal-maintenance.lock", "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
lock_file.close()
|
||||||
|
return False
|
||||||
|
maintenance_lock_file = lock_file
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run_maintenance_service() -> None:
|
||||||
|
logger.info("maintenance_service_bootstrap_started")
|
||||||
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
ensure_schema_compatibility()
|
ensure_schema_compatibility()
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
ensure_icons_dir()
|
ensure_icons_dir()
|
||||||
bootstrap_admin()
|
bootstrap_admin()
|
||||||
|
|
||||||
|
maintenance_lock = open("/tmp/portal-maintenance.lock", "w")
|
||||||
|
fcntl.flock(maintenance_lock.fileno(), fcntl.LOCK_EX)
|
||||||
|
logger.info("maintenance_service_leader_acquired")
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ensure_universal_pool()
|
ensure_universal_pool()
|
||||||
@@ -1517,8 +1560,46 @@ def startup_event():
|
|||||||
ensure_warm_pool(svc)
|
ensure_warm_pool(svc)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
logger.info("maintenance_service_loop_started")
|
||||||
|
cleanup_loop()
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup_event():
|
||||||
|
# Multiple uvicorn workers run startup in parallel. Serialize schema bootstrap
|
||||||
|
# to avoid DDL races on first run and during schema extension.
|
||||||
|
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
ensure_schema_compatibility()
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
ensure_icons_dir()
|
||||||
|
bootstrap_admin()
|
||||||
|
if not ENABLE_STARTUP_MAINTENANCE:
|
||||||
|
logger.info("startup_maintenance_disabled")
|
||||||
|
return
|
||||||
|
if not try_acquire_maintenance_leader():
|
||||||
|
logger.info("maintenance_leader_skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ensure_universal_pool()
|
||||||
|
ensure_web_pool()
|
||||||
|
for svc in db.scalars(
|
||||||
|
select(Service).where(
|
||||||
|
Service.active == True,
|
||||||
|
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||||
|
ensure_warm_pool(svc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
thread = threading.Thread(target=cleanup_loop, daemon=True)
|
thread = threading.Thread(target=cleanup_loop, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
logger.info("maintenance_leader_started")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
@@ -1778,6 +1859,23 @@ def logout(request: Request):
|
|||||||
|
|
||||||
@app.get("/go/{slug}")
|
@app.get("/go/{slug}")
|
||||||
def go_service(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
def go_service(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||||||
|
total_started = time.perf_counter()
|
||||||
|
phase_ms = {}
|
||||||
|
|
||||||
|
def _mark(name: str, started: float) -> None:
|
||||||
|
phase_ms[name] = int((time.perf_counter() - started) * 1000)
|
||||||
|
|
||||||
|
def _emit(result: str, **extra) -> None:
|
||||||
|
payload = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"service_slug": slug,
|
||||||
|
"result": result,
|
||||||
|
"total_ms": int((time.perf_counter() - total_started) * 1000),
|
||||||
|
}
|
||||||
|
payload.update(phase_ms)
|
||||||
|
payload.update(extra)
|
||||||
|
log_event("go_service_timing", **payload)
|
||||||
|
|
||||||
log_event("session_open_requested", user_id=user.id, service_slug=slug)
|
log_event("session_open_requested", user_id=user.id, service_slug=slug)
|
||||||
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
|
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
|
||||||
if not service:
|
if not service:
|
||||||
@@ -1786,148 +1884,218 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
|
|||||||
raise HTTPException(status_code=410, detail="VNC services are deprecated")
|
raise HTTPException(status_code=410, detail="VNC services are deprecated")
|
||||||
if not has_access(db, user.id, service.id):
|
if not has_access(db, user.id, service.id):
|
||||||
raise HTTPException(status_code=403, detail="ACL denied")
|
raise HTTPException(status_code=403, detail="ACL denied")
|
||||||
with allocator_lock(db, 92000 + int(user.id)):
|
|
||||||
existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
|
|
||||||
if existing_user_session:
|
|
||||||
return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
|
|
||||||
|
|
||||||
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
user_lock_started = time.perf_counter()
|
||||||
active_rows = db.scalars(
|
try:
|
||||||
select(SessionModel).where(
|
with allocator_lock(db, 92000 + int(user.id), timeout_seconds=GO_USER_LOCK_TIMEOUT_SECONDS):
|
||||||
SessionModel.user_id == user.id,
|
_mark("wait_user_lock_ms", user_lock_started)
|
||||||
SessionModel.status == SessionStatus.ACTIVE,
|
|
||||||
SessionModel.last_access_at >= cutoff,
|
t_existing = time.perf_counter()
|
||||||
)
|
existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
|
||||||
).all()
|
_mark("check_existing_ms", t_existing)
|
||||||
active_rows = sorted(active_rows, key=lambda row: row.created_at)
|
if existing_user_session:
|
||||||
active_service_ids = {row.service_id for row in active_rows}
|
_emit("reuse_session", session_id=existing_user_session.id)
|
||||||
if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER:
|
return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
|
||||||
oldest = next((row for row in active_rows if row.service_id != service.id), None)
|
|
||||||
if oldest:
|
t_limit = time.perf_counter()
|
||||||
terminate_session_record(db, oldest, SessionStatus.ROTATED, stop_container=True)
|
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||||||
db.commit()
|
active_rows = db.scalars(
|
||||||
log_event(
|
select(SessionModel).where(
|
||||||
"session_rotated",
|
SessionModel.user_id == user.id,
|
||||||
user_id=user.id,
|
SessionModel.status == SessionStatus.ACTIVE,
|
||||||
closed_session_id=oldest.id,
|
SessionModel.last_access_at >= cutoff,
|
||||||
closed_service_id=oldest.service_id,
|
|
||||||
new_service_id=service.id,
|
|
||||||
)
|
)
|
||||||
else:
|
).all()
|
||||||
return RedirectResponse(url="/?launch_error=max_services", status_code=303)
|
active_rows = sorted(active_rows, key=lambda row: row.created_at)
|
||||||
|
active_service_ids = {row.service_id for row in active_rows}
|
||||||
if service.type == ServiceType.RDP:
|
_mark("check_limit_ms", t_limit)
|
||||||
active_owner = find_active_session_for_service(db, service.id)
|
if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER:
|
||||||
if active_owner:
|
oldest = next((row for row in active_rows if row.service_id != service.id), None)
|
||||||
if active_owner.user_id != user.id:
|
if oldest:
|
||||||
owner = db.get(User, active_owner.user_id)
|
t_rotate = time.perf_counter()
|
||||||
owner_name = owner.username if owner else f"id={active_owner.user_id}"
|
terminate_session_record(db, oldest, SessionStatus.ROTATED, stop_container=True)
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.",
|
|
||||||
)
|
|
||||||
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303)
|
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
|
|
||||||
try:
|
|
||||||
with allocator_lock(db, 91001):
|
|
||||||
ensure_web_pool()
|
|
||||||
slot = acquire_web_pool_slot(db)
|
|
||||||
slot_cid = f"WEBPOOLIDX:{slot}"
|
|
||||||
terminate_active_slot_sessions(db, slot_cid)
|
|
||||||
dispatch_web_pool_target(slot, service)
|
|
||||||
session_obj = SessionModel(
|
|
||||||
id=session_id,
|
|
||||||
user_id=user.id,
|
|
||||||
service_id=service.id,
|
|
||||||
container_id=slot_cid,
|
|
||||||
status=SessionStatus.ACTIVE,
|
|
||||||
created_at=now_utc(),
|
|
||||||
last_access_at=now_utc(),
|
|
||||||
)
|
|
||||||
db.add(session_obj)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as exc:
|
_mark("rotate_oldest_ms", t_rotate)
|
||||||
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
log_event(
|
||||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="web_pool", error=str(exc))
|
"session_rotated",
|
||||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
|
||||||
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
|
|
||||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="web_pool", slot=slot)
|
|
||||||
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
|
||||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
|
||||||
|
|
||||||
if service_uses_universal_pool(service):
|
|
||||||
try:
|
|
||||||
with allocator_lock(db, 91002):
|
|
||||||
ensure_universal_pool()
|
|
||||||
slot = acquire_universal_slot(db)
|
|
||||||
slot_cid = f"POOLIDX:{slot}"
|
|
||||||
terminate_active_slot_sessions(db, slot_cid)
|
|
||||||
dispatch_universal_target(slot, service)
|
|
||||||
session_obj = SessionModel(
|
|
||||||
id=session_id,
|
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
service_id=service.id,
|
closed_session_id=oldest.id,
|
||||||
container_id=slot_cid,
|
closed_service_id=oldest.service_id,
|
||||||
status=SessionStatus.ACTIVE,
|
new_service_id=service.id,
|
||||||
created_at=now_utc(),
|
|
||||||
last_access_at=now_utc(),
|
|
||||||
)
|
)
|
||||||
db.add(session_obj)
|
else:
|
||||||
db.commit()
|
_emit("max_services_redirect")
|
||||||
except Exception as exc:
|
return RedirectResponse(url="/?launch_error=max_services", status_code=303)
|
||||||
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
|
||||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="universal_pool", error=str(exc))
|
|
||||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
|
||||||
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
|
|
||||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="universal_pool", slot=slot)
|
|
||||||
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
|
||||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
|
||||||
|
|
||||||
if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
|
if service.type == ServiceType.RDP:
|
||||||
ensure_warm_pool(service)
|
t_rdp_owner = time.perf_counter()
|
||||||
open_warm_web_url(service, service.target)
|
active_owner = find_active_session_for_service(db, service.id)
|
||||||
|
_mark("check_rdp_owner_ms", t_rdp_owner)
|
||||||
|
if active_owner:
|
||||||
|
if active_owner.user_id != user.id:
|
||||||
|
owner = db.get(User, active_owner.user_id)
|
||||||
|
owner_name = owner.username if owner else f"id={active_owner.user_id}"
|
||||||
|
_emit("rdp_busy", owner=owner_name)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.",
|
||||||
|
)
|
||||||
|
_emit("reuse_rdp_session", session_id=active_owner.id)
|
||||||
|
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303)
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
|
||||||
|
try:
|
||||||
|
t_pool_lock = time.perf_counter()
|
||||||
|
with allocator_lock(db, 91001, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
|
||||||
|
_mark("wait_web_pool_lock_ms", t_pool_lock)
|
||||||
|
t_ensure = time.perf_counter()
|
||||||
|
ensure_web_pool()
|
||||||
|
_mark("ensure_web_pool_ms", t_ensure)
|
||||||
|
|
||||||
|
t_acquire = time.perf_counter()
|
||||||
|
slot = acquire_web_pool_slot(db)
|
||||||
|
_mark("acquire_web_slot_ms", t_acquire)
|
||||||
|
slot_cid = f"WEBPOOLIDX:{slot}"
|
||||||
|
|
||||||
|
t_dispatch = time.perf_counter()
|
||||||
|
terminate_active_slot_sessions(db, slot_cid)
|
||||||
|
dispatch_web_pool_target(slot, service)
|
||||||
|
_mark("dispatch_web_target_ms", t_dispatch)
|
||||||
|
|
||||||
|
t_commit = time.perf_counter()
|
||||||
|
session_obj = SessionModel(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
service_id=service.id,
|
||||||
|
container_id=slot_cid,
|
||||||
|
status=SessionStatus.ACTIVE,
|
||||||
|
created_at=now_utc(),
|
||||||
|
last_access_at=now_utc(),
|
||||||
|
)
|
||||||
|
db.add(session_obj)
|
||||||
|
db.commit()
|
||||||
|
_mark("db_commit_ms", t_commit)
|
||||||
|
except LockTimeoutError:
|
||||||
|
_emit("web_pool_lock_timeout")
|
||||||
|
raise HTTPException(status_code=503, detail="Пул WEB занят. Повторите через несколько секунд.")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
||||||
|
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="web_pool", error=str(exc))
|
||||||
|
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||||
|
_emit("web_pool_create_failed", error=str(exc))
|
||||||
|
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
|
||||||
|
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="web_pool", slot=slot)
|
||||||
|
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
||||||
|
_emit("session_created_web_pool", session_id=session_id, slot=slot)
|
||||||
|
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||||
|
|
||||||
|
if service_uses_universal_pool(service):
|
||||||
|
try:
|
||||||
|
t_pool_lock = time.perf_counter()
|
||||||
|
with allocator_lock(db, 91002, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
|
||||||
|
_mark("wait_universal_pool_lock_ms", t_pool_lock)
|
||||||
|
t_ensure = time.perf_counter()
|
||||||
|
ensure_universal_pool()
|
||||||
|
_mark("ensure_universal_pool_ms", t_ensure)
|
||||||
|
|
||||||
|
t_acquire = time.perf_counter()
|
||||||
|
slot = acquire_universal_slot(db)
|
||||||
|
_mark("acquire_universal_slot_ms", t_acquire)
|
||||||
|
slot_cid = f"POOLIDX:{slot}"
|
||||||
|
|
||||||
|
t_dispatch = time.perf_counter()
|
||||||
|
terminate_active_slot_sessions(db, slot_cid)
|
||||||
|
dispatch_universal_target(slot, service)
|
||||||
|
_mark("dispatch_universal_target_ms", t_dispatch)
|
||||||
|
|
||||||
|
t_commit = time.perf_counter()
|
||||||
|
session_obj = SessionModel(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
service_id=service.id,
|
||||||
|
container_id=slot_cid,
|
||||||
|
status=SessionStatus.ACTIVE,
|
||||||
|
created_at=now_utc(),
|
||||||
|
last_access_at=now_utc(),
|
||||||
|
)
|
||||||
|
db.add(session_obj)
|
||||||
|
db.commit()
|
||||||
|
_mark("db_commit_ms", t_commit)
|
||||||
|
except LockTimeoutError:
|
||||||
|
_emit("universal_pool_lock_timeout")
|
||||||
|
raise HTTPException(status_code=503, detail="Пул RDP занят. Повторите через несколько секунд.")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
||||||
|
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="universal_pool", error=str(exc))
|
||||||
|
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||||
|
_emit("universal_pool_create_failed", error=str(exc))
|
||||||
|
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
|
||||||
|
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="universal_pool", slot=slot)
|
||||||
|
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
||||||
|
_emit("session_created_universal_pool", session_id=session_id, slot=slot)
|
||||||
|
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||||
|
|
||||||
|
if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
|
||||||
|
t_warm = time.perf_counter()
|
||||||
|
ensure_warm_pool(service)
|
||||||
|
open_warm_web_url(service, service.target)
|
||||||
|
_mark("warm_pool_prepare_ms", t_warm)
|
||||||
|
|
||||||
|
t_commit = time.perf_counter()
|
||||||
|
session_obj = SessionModel(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
service_id=service.id,
|
||||||
|
container_id=f"POOL:{service.slug}",
|
||||||
|
status=SessionStatus.ACTIVE,
|
||||||
|
created_at=now_utc(),
|
||||||
|
last_access_at=now_utc(),
|
||||||
|
)
|
||||||
|
db.add(session_obj)
|
||||||
|
db.commit()
|
||||||
|
_mark("db_commit_ms", t_commit)
|
||||||
|
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="warm_pool")
|
||||||
|
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id)
|
||||||
|
_emit("session_created_warm_pool", session_id=session_id)
|
||||||
|
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||||
|
|
||||||
|
try:
|
||||||
|
t_create = time.perf_counter()
|
||||||
|
container_id = create_runtime_container(service, session_id)
|
||||||
|
_mark("create_runtime_container_ms", t_create)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
|
||||||
|
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="single_runtime", error=str(exc))
|
||||||
|
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||||
|
_emit("single_runtime_create_failed", error=str(exc))
|
||||||
|
raise HTTPException(status_code=502, detail="Session runtime failed to start")
|
||||||
|
|
||||||
|
t_commit = time.perf_counter()
|
||||||
session_obj = SessionModel(
|
session_obj = SessionModel(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
service_id=service.id,
|
service_id=service.id,
|
||||||
container_id=f"POOL:{service.slug}",
|
container_id=container_id,
|
||||||
status=SessionStatus.ACTIVE,
|
status=SessionStatus.ACTIVE,
|
||||||
created_at=now_utc(),
|
created_at=now_utc(),
|
||||||
last_access_at=now_utc(),
|
last_access_at=now_utc(),
|
||||||
)
|
)
|
||||||
db.add(session_obj)
|
db.add(session_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="warm_pool")
|
_mark("db_commit_ms", t_commit)
|
||||||
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id)
|
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="single_runtime", container_id=container_id)
|
||||||
|
|
||||||
|
audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
|
||||||
|
t_wait = time.perf_counter()
|
||||||
|
ready = wait_for_session_route(session_id)
|
||||||
|
_mark("wait_session_route_ms", t_wait)
|
||||||
|
log_event("session_route_ready", session_id=session_id, ready=ready)
|
||||||
|
_emit("session_created_single_runtime", session_id=session_id, ready=ready)
|
||||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||||
|
except LockTimeoutError:
|
||||||
try:
|
_emit("user_lock_timeout")
|
||||||
container_id = create_runtime_container(service, session_id)
|
raise HTTPException(status_code=429, detail="Слишком много параллельных запусков. Повторите через несколько секунд.")
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
|
|
||||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="single_runtime", error=str(exc))
|
|
||||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
|
||||||
raise HTTPException(status_code=502, detail="Session runtime failed to start")
|
|
||||||
|
|
||||||
session_obj = SessionModel(
|
|
||||||
id=session_id,
|
|
||||||
user_id=user.id,
|
|
||||||
service_id=service.id,
|
|
||||||
container_id=container_id,
|
|
||||||
status=SessionStatus.ACTIVE,
|
|
||||||
created_at=now_utc(),
|
|
||||||
last_access_at=now_utc(),
|
|
||||||
)
|
|
||||||
db.add(session_obj)
|
|
||||||
db.commit()
|
|
||||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="single_runtime", container_id=container_id)
|
|
||||||
|
|
||||||
audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
|
|
||||||
ready = wait_for_session_route(session_id)
|
|
||||||
log_event("session_route_ready", session_id=session_id, ready=ready)
|
|
||||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/svc/{slug}/", response_class=HTMLResponse)
|
@app.get("/svc/{slug}/", response_class=HTMLResponse)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main.run_maintenance_service()
|
||||||
+78
-5
@@ -102,15 +102,26 @@ button {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
.grid.service-grid {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
.admin-intro {
|
.admin-intro {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #f8fbfe;
|
background: linear-gradient(180deg, #eff7fd 0%, #e0effa 100%);
|
||||||
padding: 0.8rem 0.9rem;
|
padding: 0.9rem 1rem;
|
||||||
color: #2b4760;
|
color: #123e60;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
.summary-strip {
|
.summary-strip {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -286,6 +297,13 @@ button {
|
|||||||
.split {
|
.split {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.rules-banner-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.rules-banner-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.service-grid {
|
.service-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -370,8 +388,10 @@ button {
|
|||||||
color: #4b6178;
|
color: #4b6178;
|
||||||
}
|
}
|
||||||
.tile-comment {
|
.tile-comment {
|
||||||
max-height: 96px;
|
display: block;
|
||||||
overflow: auto;
|
max-height: calc(1.35em * 15);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
padding-right: 0.2rem;
|
padding-right: 0.2rem;
|
||||||
}
|
}
|
||||||
@@ -394,6 +414,54 @@ button {
|
|||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.rules-banner {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
border: 1px solid #c5daec;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: linear-gradient(180deg, #f2f8fd 0%, #e7f2fb 100%);
|
||||||
|
}
|
||||||
|
.rules-banner-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #123c5d;
|
||||||
|
}
|
||||||
|
.rules-banner-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.rules-banner-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
}
|
||||||
|
.rules-ack-btn {
|
||||||
|
border: 1px solid #2c8c3f;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.38rem 0.72rem;
|
||||||
|
background: linear-gradient(180deg, #41bb5a 0%, #2f9745 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.81rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rules-banner-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.rules-pill {
|
||||||
|
border: 1px solid #c7dbed;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.48rem 0.55rem;
|
||||||
|
color: #23465f;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
.compact-grid {
|
.compact-grid {
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.7rem;
|
||||||
}
|
}
|
||||||
@@ -570,6 +638,11 @@ button {
|
|||||||
background: rgba(255, 255, 255, 0.9) !important;
|
background: rgba(255, 255, 255, 0.9) !important;
|
||||||
border: 1px solid rgba(198, 218, 235, 0.9) !important;
|
border: 1px solid rgba(198, 218, 235, 0.9) !important;
|
||||||
}
|
}
|
||||||
|
.dashboard-page .panel {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
.tile,
|
.tile,
|
||||||
.tile:hover,
|
.tile:hover,
|
||||||
.made-by,
|
.made-by,
|
||||||
|
|||||||
@@ -25,10 +25,24 @@
|
|||||||
</header>
|
</header>
|
||||||
<main class="admin-layout">
|
<main class="admin-layout">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="admin-intro">Добро пожаловать в инфрастуктурный полигон</div>
|
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div>
|
||||||
{% if session_notice %}
|
{% if session_notice %}
|
||||||
<div class="session-notice">{{ session_notice }}</div>
|
<div class="session-notice">{{ session_notice }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="rules-banner" id="rules-banner">
|
||||||
|
<div class="rules-banner-head">
|
||||||
|
<div class="rules-banner-title">Правила работы стенда</div>
|
||||||
|
</div>
|
||||||
|
<div class="rules-banner-grid">
|
||||||
|
<div class="rules-pill">Лимит: до 4 сервисов одновременно. При открытии нового сверх лимита самый старый закрывается автоматически.</div>
|
||||||
|
<div class="rules-pill">При бездействии более 5 минут сессия закрывается автоматически.</div>
|
||||||
|
<div class="rules-pill">Все сервисы работают в защищённом контуре с резервированием и бэкапами.</div>
|
||||||
|
<div class="rules-pill">Состояние сервисов возвращается к базовому каждую ночь в 00:00.</div>
|
||||||
|
</div>
|
||||||
|
<div class="rules-banner-actions">
|
||||||
|
<button type="button" class="rules-ack-btn" id="rules-ack-btn">Ознакомлен</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% if categories %}
|
{% if categories %}
|
||||||
<div class="category-strip">
|
<div class="category-strip">
|
||||||
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
|
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
|
||||||
@@ -70,5 +84,22 @@
|
|||||||
</section>
|
</section>
|
||||||
<footer class="made-by-wrap"><a class="made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer>
|
<footer class="made-by-wrap"><a class="made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer>
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const username = {{ user.username|tojson }};
|
||||||
|
const key = `rules_ack_${username}`;
|
||||||
|
const banner = document.getElementById('rules-banner');
|
||||||
|
const btn = document.getElementById('rules-ack-btn');
|
||||||
|
if (!banner || !btn) return;
|
||||||
|
if (localStorage.getItem(key) === '1') {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
localStorage.setItem(key, '1');
|
||||||
|
banner.style.display = 'none';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+39
-2
@@ -30,18 +30,24 @@ services:
|
|||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./app
|
context: ./app
|
||||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "6"]
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
SIGNING_KEY: ${SIGNING_KEY}
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-7200}
|
||||||
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10}
|
||||||
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 20
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
volumes:
|
volumes:
|
||||||
@@ -61,6 +67,37 @@ services:
|
|||||||
- portal_net
|
- portal_net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
maintenance:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: [python, maintenance_runner.py]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-7200}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 20
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
kiosk-image:
|
kiosk-image:
|
||||||
image: portal-kiosk:latest
|
image: portal-kiosk:latest
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.2
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8288:80"
|
||||||
|
- "0.0.0.0:2288:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic
|
||||||
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "6"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 20
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=portal_net
|
||||||
|
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
|
||||||
|
- traefik.http.routers.portal.entrypoints=websecure
|
||||||
|
- traefik.http.routers.portal.tls=true
|
||||||
|
- traefik.http.routers.portal.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.portal.priority=1
|
||||||
|
- traefik.http.services.portal.loadbalancer.server.port=8000
|
||||||
|
- traefik.http.routers.portal.middlewares=secure-headers@file
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
maintenance:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: [python, maintenance_runner.py]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 20
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kiosk-image:
|
||||||
|
image: portal-kiosk:latest
|
||||||
|
build:
|
||||||
|
context: ./kiosk
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
rdp-proxy-image:
|
||||||
|
image: portal-rdp-proxy:latest
|
||||||
|
build:
|
||||||
|
context: ./rdp-proxy
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
universal-runtime-image:
|
||||||
|
image: portal-universal-runtime:latest
|
||||||
|
build:
|
||||||
|
context: ./universal-runtime
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portal_net:
|
||||||
|
name: portal_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.2
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8288:80"
|
||||||
|
- "0.0.0.0:2288:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic
|
||||||
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=portal_net
|
||||||
|
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
|
||||||
|
- traefik.http.routers.portal.entrypoints=websecure
|
||||||
|
- traefik.http.routers.portal.tls=true
|
||||||
|
- traefik.http.routers.portal.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.portal.priority=1
|
||||||
|
- traefik.http.services.portal.loadbalancer.server.port=8000
|
||||||
|
- traefik.http.routers.portal.middlewares=secure-headers@file
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
maintenance:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: [python, maintenance_runner.py]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kiosk-image:
|
||||||
|
image: portal-kiosk:latest
|
||||||
|
build:
|
||||||
|
context: ./kiosk
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
rdp-proxy-image:
|
||||||
|
image: portal-rdp-proxy:latest
|
||||||
|
build:
|
||||||
|
context: ./rdp-proxy
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
universal-runtime-image:
|
||||||
|
image: portal-universal-runtime:latest
|
||||||
|
build:
|
||||||
|
context: ./universal-runtime
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portal_net:
|
||||||
|
name: portal_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.2
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8288:80"
|
||||||
|
- "0.0.0.0:2288:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic
|
||||||
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=portal_net
|
||||||
|
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
|
||||||
|
- traefik.http.routers.portal.entrypoints=websecure
|
||||||
|
- traefik.http.routers.portal.tls=true
|
||||||
|
- traefik.http.routers.portal.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.portal.priority=1
|
||||||
|
- traefik.http.services.portal.loadbalancer.server.port=8000
|
||||||
|
- traefik.http.routers.portal.middlewares=secure-headers@file
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
maintenance:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: [python, maintenance_runner.py]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kiosk-image:
|
||||||
|
image: portal-kiosk:latest
|
||||||
|
build:
|
||||||
|
context: ./kiosk
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
rdp-proxy-image:
|
||||||
|
image: portal-rdp-proxy:latest
|
||||||
|
build:
|
||||||
|
context: ./rdp-proxy
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
universal-runtime-image:
|
||||||
|
image: portal-universal-runtime:latest
|
||||||
|
build:
|
||||||
|
context: ./universal-runtime
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portal_net:
|
||||||
|
name: portal_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.2
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8288:80"
|
||||||
|
- "0.0.0.0:2288:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic
|
||||||
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=portal_net
|
||||||
|
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
|
||||||
|
- traefik.http.routers.portal.entrypoints=websecure
|
||||||
|
- traefik.http.routers.portal.tls=true
|
||||||
|
- traefik.http.routers.portal.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.portal.priority=1
|
||||||
|
- traefik.http.services.portal.loadbalancer.server.port=8000
|
||||||
|
- traefik.http.routers.portal.middlewares=secure-headers@file
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
maintenance:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: [python, maintenance_runner.py]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
|
||||||
|
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS: 8
|
||||||
|
POOL_DISPATCH_RETRIES: 6
|
||||||
|
ENABLE_STARTUP_MAINTENANCE: 0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kiosk-image:
|
||||||
|
image: portal-kiosk:latest
|
||||||
|
build:
|
||||||
|
context: ./kiosk
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
rdp-proxy-image:
|
||||||
|
image: portal-rdp-proxy:latest
|
||||||
|
build:
|
||||||
|
context: ./rdp-proxy
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
universal-runtime-image:
|
||||||
|
image: portal-universal-runtime:latest
|
||||||
|
build:
|
||||||
|
context: ./universal-runtime
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portal_net:
|
||||||
|
name: portal_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.2
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8288:80"
|
||||||
|
- "0.0.0.0:2288:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic
|
||||||
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=portal_net
|
||||||
|
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
|
||||||
|
- traefik.http.routers.portal.entrypoints=websecure
|
||||||
|
- traefik.http.routers.portal.tls=true
|
||||||
|
- traefik.http.routers.portal.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.portal.priority=1
|
||||||
|
- traefik.http.services.portal.loadbalancer.server.port=8000
|
||||||
|
- traefik.http.routers.portal.middlewares=secure-headers@file
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kiosk-image:
|
||||||
|
image: portal-kiosk:latest
|
||||||
|
build:
|
||||||
|
context: ./kiosk
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
rdp-proxy-image:
|
||||||
|
image: portal-rdp-proxy:latest
|
||||||
|
build:
|
||||||
|
context: ./rdp-proxy
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
universal-runtime-image:
|
||||||
|
image: portal-universal-runtime:latest
|
||||||
|
build:
|
||||||
|
context: ./universal-runtime
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portal_net:
|
||||||
|
name: portal_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.2
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8288:80"
|
||||||
|
- "0.0.0.0:2288:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic
|
||||||
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "6"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./app/static/service-icons:/app/static/service-icons
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=portal_net
|
||||||
|
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
|
||||||
|
- traefik.http.routers.portal.entrypoints=websecure
|
||||||
|
- traefik.http.routers.portal.tls=true
|
||||||
|
- traefik.http.routers.portal.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.portal.priority=1
|
||||||
|
- traefik.http.services.portal.loadbalancer.server.port=8000
|
||||||
|
- traefik.http.routers.portal.middlewares=secure-headers@file
|
||||||
|
networks:
|
||||||
|
- portal_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kiosk-image:
|
||||||
|
image: portal-kiosk:latest
|
||||||
|
build:
|
||||||
|
context: ./kiosk
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
rdp-proxy-image:
|
||||||
|
image: portal-rdp-proxy:latest
|
||||||
|
build:
|
||||||
|
context: ./rdp-proxy
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
universal-runtime-image:
|
||||||
|
image: portal-universal-runtime:latest
|
||||||
|
build:
|
||||||
|
context: ./universal-runtime
|
||||||
|
profiles: ["build-only"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portal_net:
|
||||||
|
name: portal_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# CONTEXT_TEST
|
||||||
|
|
||||||
|
Обновлено: 2026-04-23 (Europe/Moscow)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Продолжить нагрузочное тестирование маршрута `GET /go/{slug}` и стабилизировать поведение под конкуренцией.
|
||||||
|
|
||||||
|
## Что внедрено в API
|
||||||
|
|
||||||
|
1. Ограничение ожидания lock-ов:
|
||||||
|
- добавлен `LockTimeoutError`;
|
||||||
|
- `allocator_lock(...)` теперь поддерживает timeout через `pg_try_advisory_lock`;
|
||||||
|
- для user-lock в `go_service`: `GO_USER_LOCK_TIMEOUT_SECONDS` (default `2.0`);
|
||||||
|
- для pool-lock: `GO_POOL_LOCK_TIMEOUT_SECONDS` (default `5.0`).
|
||||||
|
|
||||||
|
2. Контролируемые ответы вместо долгого зависания:
|
||||||
|
- timeout user-lock -> `429`;
|
||||||
|
- timeout pool-lock -> `503`.
|
||||||
|
|
||||||
|
3. Фазовая телеметрия `go_service`:
|
||||||
|
- событие: `go_service_timing`;
|
||||||
|
- фиксируются времена фаз (wait lock, check existing/limit, ensure/acquire/dispatch/commit, total).
|
||||||
|
|
||||||
|
4. Ограничен dispatch runtime-пула:
|
||||||
|
- `POOL_DISPATCH_RETRIES` (default `4`),
|
||||||
|
- `POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS` (default `2.0`),
|
||||||
|
- `POOL_DISPATCH_SLEEP_SECONDS` (default `0.3`).
|
||||||
|
|
||||||
|
## Что исправлено в тестовом контуре
|
||||||
|
|
||||||
|
1. В `.env` был пустой `SIGNING_KEY` -> заполнен, `api` перезапущен.
|
||||||
|
2. В k6-скрипте включено `noCookiesReset: true`, иначе возникал ложный вал `401`.
|
||||||
|
|
||||||
|
## Актуальные контрольные результаты
|
||||||
|
|
||||||
|
Контрольный тест (после правок):
|
||||||
|
- профиль: `5 VU`, `25s`, single-user;
|
||||||
|
- `http_req_failed = 0%`;
|
||||||
|
- `open_success = 1138`;
|
||||||
|
- `open_rejected = 0`;
|
||||||
|
- `p95 http_req_duration = 10.79ms`;
|
||||||
|
- по логам `/go/*`: `1138 x 303`, `1 x 503`.
|
||||||
|
|
||||||
|
Это подтверждает, что:
|
||||||
|
- долгие зависания заменены на быстрые контролируемые ответы;
|
||||||
|
- тестовый сценарий больше не искажается cookie-сбросом.
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
1. Повторить multi-user `load` (30 VU, 5m) на этом же скрипте и зафиксировать:
|
||||||
|
- долю `303/429/503`,
|
||||||
|
- p95/p99,
|
||||||
|
- `go_service_timing` по фазам.
|
||||||
|
|
||||||
|
2. При необходимости тонко настроить:
|
||||||
|
- `GO_USER_LOCK_TIMEOUT_SECONDS`,
|
||||||
|
- `GO_POOL_LOCK_TIMEOUT_SECONDS`,
|
||||||
|
- `POOL_DISPATCH_*`.
|
||||||
@@ -1,492 +0,0 @@
|
|||||||
# Project Context: Portal Stand Access
|
|
||||||
|
|
||||||
Этот файл нужен как быстрый технический контекст для нового разработчика/оператора.
|
|
||||||
|
|
||||||
## 1) Что это за проект
|
|
||||||
|
|
||||||
Веб-портал для выдачи пользователям доступа к стендам/сервисам через браузер.
|
|
||||||
|
|
||||||
Ключевая идея:
|
|
||||||
- пользователь выбирает сервис;
|
|
||||||
- портал открывает сервис в уже прогретом браузерном контейнере (WEB) или в RDP-слоте;
|
|
||||||
- каждая пользовательская сессия имеет `session_id` (UUID) и свой URL `/s/<session_id>/...`.
|
|
||||||
|
|
||||||
## 2) Текущий стек
|
|
||||||
|
|
||||||
- API: FastAPI (`app/main.py`)
|
|
||||||
- БД: PostgreSQL
|
|
||||||
- Edge/router: Traefik (обязателен для динамических маршрутов runtime-контейнеров)
|
|
||||||
- Runtime WEB: `portal-kiosk` (Chromium + x11vnc + websockify/noVNC)
|
|
||||||
- Runtime RDP: `portal-rdp-proxy` (xfreerdp + x11vnc + websockify/noVNC)
|
|
||||||
|
|
||||||
## 3) Принятые продуктовые решения
|
|
||||||
|
|
||||||
- Режим VNC как отдельный сервис больше не используется (deprecate).
|
|
||||||
- Основной сценарий для пользователей: WEB и RDP.
|
|
||||||
- Для WEB используется общий пул `portal-webpool-*` (и авторасширение при нагрузке).
|
|
||||||
- Для RDP используется универсальный пул слотов (`UNIVERSAL_POOL_SIZE`).
|
|
||||||
- Сессии пользователя имеют UUID-ссылки (`/s/<uuid>/...`).
|
|
||||||
|
|
||||||
## 4) Критичные маршруты
|
|
||||||
|
|
||||||
- `/` — выбор сервисов
|
|
||||||
- `/go/<slug>` — запуск пользовательской сессии
|
|
||||||
- `/s/<session_id>/` — страница ожидания старта
|
|
||||||
- `/s/<session_id>/view` — сессионный view для WEB-пула
|
|
||||||
- `/svc/<slug>/` — роут к warm runtime конкретного сервиса
|
|
||||||
- `/w/<slot>/` — роут к WEB pool слоту
|
|
||||||
- `/u/<slot>/` — роут к universal pool слоту
|
|
||||||
- `/admin` — админка
|
|
||||||
|
|
||||||
## 5) Что важно помнить по инфраструктуре
|
|
||||||
|
|
||||||
1. Traefik удалять нельзя.
|
|
||||||
Причина: динамические контейнеры создают labels во время работы, и именно Traefik маршрутизирует:
|
|
||||||
- `/s/<session_id>/...`
|
|
||||||
- `/svc/<slug>/...`
|
|
||||||
- `/w/<slot>/...`
|
|
||||||
- `/u/<slot>/...`
|
|
||||||
|
|
||||||
2. При Nginx Proxy Manager (NPM):
|
|
||||||
- внешний домен -> NPM -> внутренний Traefik.
|
|
||||||
- в `docker-compose.yml` Traefik опубликован так:
|
|
||||||
- `0.0.0.0:2288 -> 443`
|
|
||||||
- `0.0.0.0:8288 -> 80`
|
|
||||||
- в NPM обязательна опция `Websockets Support`.
|
|
||||||
|
|
||||||
3. Кнопка «Домой» в runtime UI:
|
|
||||||
- должна возвращать к выбору сервисов портала (`/`), а не вводить URL в удалённом сайте.
|
|
||||||
|
|
||||||
## 6) Диагностика типовых проблем
|
|
||||||
|
|
||||||
### A) Черный экран в WEB
|
|
||||||
Проверять:
|
|
||||||
- что у noVNC корректный WebSocket endpoint (`.../websockify`);
|
|
||||||
- что сессия active в БД;
|
|
||||||
- что контейнер WEB-пула running;
|
|
||||||
- что в NPM включен websocket proxy.
|
|
||||||
|
|
||||||
Быстрая проверка:
|
|
||||||
- логи `portal-webpool-*`
|
|
||||||
- логи `portal-api-1`
|
|
||||||
- содержимое `/opt/portal/index.html` внутри runtime-контейнера.
|
|
||||||
|
|
||||||
### B) "Соединение со слотом потеряно" в RDP
|
|
||||||
Обычно не проблема портала, а проблема соединения `xfreerdp` до целевого host:port/cred/sec.
|
|
||||||
Смотреть `/tmp/session-app.log`/`xfreerdp.log` в `portal-universal-*`.
|
|
||||||
|
|
||||||
### C) Изменения не видны сразу
|
|
||||||
Если менялись runtime-скрипты, старые warm/pool контейнеры могут держать старую версию.
|
|
||||||
Нужно пересобрать образ + пересоздать пул.
|
|
||||||
|
|
||||||
## 7) Где смотреть код
|
|
||||||
|
|
||||||
- Backend и orchestration: `app/main.py`
|
|
||||||
- Админка/UI: `app/templates/admin.html`, `app/static/style.css`
|
|
||||||
- Пользовательский дашборд: `app/templates/dashboard.html`
|
|
||||||
- WEB runtime: `kiosk/entrypoint.sh`, `kiosk/manager.py`
|
|
||||||
- RDP runtime: `rdp-proxy/entrypoint.sh`
|
|
||||||
- Universal runtime: `universal-runtime/entrypoint.sh`, `universal-runtime/manager.py`
|
|
||||||
- Оркестрация: `docker-compose.yml`, `traefik/traefik.yml`
|
|
||||||
|
|
||||||
## 8) Операционные команды
|
|
||||||
|
|
||||||
Сборка runtime-образов:
|
|
||||||
```bash
|
|
||||||
docker compose --profile build-only build kiosk-image rdp-proxy-image universal-runtime-image
|
|
||||||
```
|
|
||||||
|
|
||||||
Поднять всё:
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Перезапуск только API:
|
|
||||||
```bash
|
|
||||||
docker compose up -d api
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка состояния:
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs -f api traefik
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9) Что еще можно улучшить
|
|
||||||
|
|
||||||
- вынести миграции в Alembic;
|
|
||||||
- добавить отдельный health dashboard с websocket/rdp метриками;
|
|
||||||
- централизованный сбор логов и алерты;
|
|
||||||
- e2e smoke-тесты на сценарии `/go -> /s/<uuid>/view`.
|
|
||||||
|
|
||||||
## 10) Сервер и рабочие пути
|
|
||||||
|
|
||||||
- SSH сервер: `ruslan@10.17.39.3`
|
|
||||||
- Пароль `sudo` на сервере: `utOgbZ09ruslanstand`
|
|
||||||
- Рабочий каталог проекта на сервере: `/root/Stend_mont`
|
|
||||||
- Файл контекста на сервере: `/root/Stend_mont/docs/PROJECT_CONTEXT.md`
|
|
||||||
|
|
||||||
Базовый рабочий сценарий:
|
|
||||||
```bash
|
|
||||||
ssh ruslan@10.17.39.3
|
|
||||||
sudo -s
|
|
||||||
cd /root/Stend_mont
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11) Git доступ и публикация
|
|
||||||
|
|
||||||
Репозиторий:
|
|
||||||
- `https://git.ruslan.xyz/ruslan/Stend_mont`
|
|
||||||
|
|
||||||
Учетные данные HTTPS (текущие):
|
|
||||||
- login: `ruslan@ipcom.su`
|
|
||||||
- password/token: `utOgbZ09ruslan`
|
|
||||||
|
|
||||||
Пример push:
|
|
||||||
```bash
|
|
||||||
cd /root/Stend_mont
|
|
||||||
git add .
|
|
||||||
git commit -m "your message"
|
|
||||||
git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mont main
|
|
||||||
```
|
|
||||||
|
|
||||||
## 12) Текущее runtime-состояние (на момент фиксации)
|
|
||||||
|
|
||||||
- API запущен с `uvicorn --workers 4` через `docker-compose.yml`.
|
|
||||||
- Для WEB используется `portal-webpool-*`.
|
|
||||||
- Для RDP используется `portal-universal-*`.
|
|
||||||
|
|
||||||
## 13) Последние изменения (2026-04-21)
|
|
||||||
|
|
||||||
1. UI/брендинг:
|
|
||||||
- Тексты в интерфейсе переведены на формулировку `инфрастуктурный полигон`.
|
|
||||||
- На главной панели приветствие в блоке `admin-intro`: `Добро пожаловать в инфрастуктурный полигон`.
|
|
||||||
- Кнопка выхода на дашборде: `Выход` (вместо `Logout`).
|
|
||||||
|
|
||||||
2. WEB runtime (браузерные сервисы):
|
|
||||||
- В панели управления runtime оставлены 2 кнопки:
|
|
||||||
- `Назад`
|
|
||||||
- `Главная` (ведет на главную панель портала `/`).
|
|
||||||
- Кнопка `Вперед` удалена.
|
|
||||||
- Изменения применены в `kiosk/entrypoint.sh` и `universal-runtime/entrypoint.sh`.
|
|
||||||
|
|
||||||
3. Логин и просроченные пользователи:
|
|
||||||
- Если пользователь найден и пароль верный, но аккаунт просрочен/неактивен, на экране входа показывается сообщение:
|
|
||||||
`Доступ к сервису приостоновлен, обратитесь к вашему менеджеру`.
|
|
||||||
- Сообщение рендерится в шаблоне `app/templates/login.html` через `login_error`.
|
|
||||||
|
|
||||||
4. Категории сервисов:
|
|
||||||
- Добавлены сущности и связи:
|
|
||||||
- `categories`
|
|
||||||
- `service_categories`
|
|
||||||
- Категории можно создавать/удалять в админке.
|
|
||||||
- При создании/редактировании WEB/RDP сервиса можно выбрать категории.
|
|
||||||
- На главной панели добавлен стильный фильтр по категориям (chips) и бейджи категорий на карточке сервиса.
|
|
||||||
|
|
||||||
5. Иконки сервисов:
|
|
||||||
- Иконки на главной панели увеличены примерно в 6 раз.
|
|
||||||
- Масштабирование иконок: `object-fit: contain`, чтобы картинка полностью влезала в рамку.
|
|
||||||
- В админке загрузка иконки стала автоматической при выборе файла (без кнопки Upload).
|
|
||||||
|
|
||||||
6. Многоворкерный API и startup:
|
|
||||||
- API работает с `uvicorn --workers 4`.
|
|
||||||
- Чтобы убрать гонку DDL на старте (при нескольких воркерах), добавлен file-lock на bootstrap схемы:
|
|
||||||
- lock-файл: `/tmp/portal-schema.lock`
|
|
||||||
- сериализуется выполнение `Base.metadata.create_all(...)` и `ensure_schema_compatibility()`.
|
|
||||||
|
|
||||||
7. Операционные заметки по применению runtime-изменений:
|
|
||||||
- После изменения `kiosk`/`universal-runtime` нужно:
|
|
||||||
1. пересобрать runtime-образы,
|
|
||||||
2. пересоздать `portal-webpool-*`, `portal-universal-*`, `portal-warm-*` контейнеры,
|
|
||||||
3. перезапустить `api`.
|
|
||||||
|
|
||||||
|
|
||||||
## 14) Обновление контекста (2026-04-21, вечер)
|
|
||||||
|
|
||||||
1. Главная страница и 500:
|
|
||||||
- Был зафиксирован Internal Server Error на /.
|
|
||||||
- Причина: синтаксическая ошибка Jinja в app/templates/login.html (поврежденный endif).
|
|
||||||
- Статус: исправлено, API перезапущен, / отвечает 200.
|
|
||||||
|
|
||||||
2. Фон и визуальные эффекты:
|
|
||||||
- Были тесты фонов main.jpg, main_general.jpg, 123.jpg и локального файла 71ba42f1d7d61e4313ad8fd086d3ed7f.jpg.
|
|
||||||
- Текущее состояние по запросу: эффекты отключены.
|
|
||||||
- Отключено: parallax, анимации облаков, hover-движения карточек/ссылок, blur карточек.
|
|
||||||
- Главная панель оставлена со статичным светлым фоном без motion-эффектов.
|
|
||||||
|
|
||||||
3. Файлы, затронутые в этой волне:
|
|
||||||
- app/templates/dashboard.html: удален parallax/cloud слой из разметки.
|
|
||||||
- app/static/style.css: добавлен override-блок для отключения эффектов.
|
|
||||||
- app/templates/login.html: исправлена ошибка шаблона.
|
|
||||||
|
|
||||||
4. Актуальный операционный контур:
|
|
||||||
- Сервер: ruslan@10.17.39.3
|
|
||||||
- Проект: /root/Stend_mont
|
|
||||||
- Контекст: /root/Stend_mont/docs/PROJECT_CONTEXT.md
|
|
||||||
- Применение UI-правок:
|
|
||||||
1) ssh ruslan@10.17.39.3
|
|
||||||
2) sudo -s
|
|
||||||
3) cd /root/Stend_mont
|
|
||||||
4) docker compose up -d --build api
|
|
||||||
|
|
||||||
5. Git публикация:
|
|
||||||
- origin: https://git.ruslan.xyz/ruslan/Stend_mont
|
|
||||||
- Стандартно: git add, git commit, git push origin main
|
|
||||||
- При необходимости HTTPS с явными credential:
|
|
||||||
git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mont main
|
|
||||||
|
|
||||||
## 15) Обновления (2026-04-21, таймаут и пулы)
|
|
||||||
|
|
||||||
1. Таймаут простаивания сессии уменьшен:
|
|
||||||
- Было: `SESSION_IDLE_SECONDS=1800` (~30 минут).
|
|
||||||
- Стало: `SESSION_IDLE_SECONDS=300` (~5 минут).
|
|
||||||
- Источник значения:
|
|
||||||
- `.env`: `SESSION_IDLE_SECONDS=300`
|
|
||||||
- `docker-compose.yml`: `SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}`
|
|
||||||
- fallback в `app/main.py`: `300`.
|
|
||||||
|
|
||||||
2. Поведение при простое (heartbeat):
|
|
||||||
- В runtime-страницах (`kiosk`, `universal-runtime`, `rdp-proxy`) heartbeat теперь проверяет HTTP-статус `touch`.
|
|
||||||
- Если `touch` возвращает не `2xx` (например, `410 Session expired`), клиент делает редирект на:
|
|
||||||
`/?session_closed=idle`
|
|
||||||
- На `/` добавлено уведомление:
|
|
||||||
`Сессия была закрыта из-за простоя. Откройте сервис заново.`
|
|
||||||
- Уведомление показывается и на login-page, и на dashboard.
|
|
||||||
|
|
||||||
3. Изменение API для touch:
|
|
||||||
- `POST /api/sessions/{id}/touch`:
|
|
||||||
- `404` если сессия не найдена/не принадлежит пользователю;
|
|
||||||
- `410` если сессия найдена, но уже не `ACTIVE`.
|
|
||||||
|
|
||||||
## 16) Обновления (2026-04-21, ночь)
|
|
||||||
|
|
||||||
1. Ограничение активных сервисов пользователя:
|
|
||||||
- Лимит оставлен `MAX_ACTIVE_SERVICES_PER_USER=4`.
|
|
||||||
- Поведение изменено на FIFO-ротацию:
|
|
||||||
- при открытии 5-го сервиса автоматически закрывается самый старый активный;
|
|
||||||
- при открытии 6-го — следующий по старшинству и т.д.
|
|
||||||
- Жесткий редирект с ошибкой теперь используется только как аварийный fallback.
|
|
||||||
|
|
||||||
2. Время простоя:
|
|
||||||
- Для обычного простоя подтверждено `SESSION_IDLE_SECONDS=300` (5 минут).
|
|
||||||
- Значения синхронизированы в `.env`, `docker-compose.yml`, `app/main.py`.
|
|
||||||
|
|
||||||
3. Runtime-навигация в сервисах:
|
|
||||||
- Кнопки оставлены символьные:
|
|
||||||
- `←` (назад)
|
|
||||||
- `⌂` (главная)
|
|
||||||
- Позиция обновлена: слева вверху, но чуть ниже прежнего:
|
|
||||||
- `kiosk`: `top:34px`
|
|
||||||
- `universal-runtime`: `top:64px` (ниже статусного блока)
|
|
||||||
|
|
||||||
4. UI карточек на главной:
|
|
||||||
- В описании карточки добавлена прокрутка (`max-height` + `overflow:auto`), если текст не влезает.
|
|
||||||
- Поддержаны переносы строк.
|
|
||||||
- Поддержано отображение жирного текста из:
|
|
||||||
- `**markdown**`
|
|
||||||
- простых HTML-тегов (`<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`), с безопасным экранированием остального.
|
|
||||||
|
|
||||||
5. Авторизация:
|
|
||||||
- При неверном логине/пароле теперь отображается явное сообщение на странице входа:
|
|
||||||
`Неверный логин или пароль`
|
|
||||||
(вместо немого 401 без человекочитаемого текста).
|
|
||||||
|
|
||||||
6. Производительность API:
|
|
||||||
- Увеличено число воркеров Uvicorn:
|
|
||||||
- было: `--workers 4`
|
|
||||||
- стало: `--workers 6`
|
|
||||||
- Изменение внесено в `docker-compose.yml`.
|
|
||||||
|
|
||||||
4. WEB pool (устойчивость при пике):
|
|
||||||
- Добавлен recovery на конфликты Docker имен/удаления (`already in use`, `marked for removal`).
|
|
||||||
- Для `ensure_web_pool` добавлены повторные попытки и принудительное удаление конфликтного контейнера перед повтором.
|
|
||||||
- Это закрывает сценарий, когда буфер (`WEB_POOL_BUFFER`) должен расширять пул, но упирается в конфликт имени контейнера.
|
|
||||||
|
|
||||||
5. RDP режим приведен к on-demand модели:
|
|
||||||
- `UNIVERSAL_POOL_SIZE=0` в `.env`.
|
|
||||||
- default в `docker-compose.yml`: `${UNIVERSAL_POOL_SIZE:-0}`.
|
|
||||||
- Для RDP отключен prewarm-подход: сессия поднимается в момент запуска сервиса (per-user session runtime), а не через общий universal-pool.
|
|
||||||
- В админ prewarm для RDP возвращает информационное сообщение, что RDP работает on-demand.
|
|
||||||
|
|
||||||
6. Важный операционный урок:
|
|
||||||
- При работе с `docker compose` обязательно сохранять `.env` заполненным; пустой `.env` приводит к запуску со значениями по умолчанию (пустые креды/хост), что ломает подключение API к БД.
|
|
||||||
|
|
||||||
## 17) Версионность (введено 2026-04-22)
|
|
||||||
|
|
||||||
Принята базовая схема SemVer:
|
|
||||||
- `MAJOR` — несовместимые изменения API/поведения;
|
|
||||||
- `MINOR` — новая функциональность без поломки совместимости;
|
|
||||||
- `PATCH` — исправления багов и операционные правки.
|
|
||||||
|
|
||||||
Текущая версия проекта:
|
|
||||||
- `0.6.0` (см. файл `VERSION` в корне репозитория).
|
|
||||||
|
|
||||||
Правило релиза:
|
|
||||||
- при любом релизном изменении обновлять `VERSION` и добавлять краткую запись в `PROJECT_CONTEXT.md`.
|
|
||||||
|
|
||||||
## 18) Обновления (2026-04-22)
|
|
||||||
|
|
||||||
1. Причина закрытия сессии (`idle` vs `limit`):
|
|
||||||
- Добавлен статус сессии `ROTATED` в API;
|
|
||||||
- Для `POST /api/sessions/{id}/touch` при закрытой сессии возвращается `410` с JSON:
|
|
||||||
- `ok: false`
|
|
||||||
- `reason: idle|limit`
|
|
||||||
- `status: <SessionStatus>`
|
|
||||||
- В рантаймах (`kiosk`, `universal-runtime`) редирект на главную теперь учитывает причину:
|
|
||||||
- `/?session_closed=idle`
|
|
||||||
- `/?session_closed=limit`
|
|
||||||
- На главной странице добавлено отдельное сообщение о закрытии из-за лимита активных сервисов.
|
|
||||||
|
|
||||||
2. API воркеры:
|
|
||||||
- Значение в `docker-compose.yml` увеличено до `uvicorn --workers 18`.
|
|
||||||
|
|
||||||
3. Логирование API усилено (structured logging):
|
|
||||||
- Добавлены структурированные JSON-события с `event` и `req_id`;
|
|
||||||
- Расширен middleware логирования запросов: метод, путь, query, статус, длительность, client_ip, user_agent;
|
|
||||||
- Добавлен порог медленных запросов через `LOG_SLOW_REQUEST_MS` (по умолчанию `2000` мс);
|
|
||||||
- Добавлены ключевые события жизненного цикла сессий:
|
|
||||||
- `session_open_requested`
|
|
||||||
- `session_created`
|
|
||||||
- `session_rotated`
|
|
||||||
- `session_closed`
|
|
||||||
- `session_touch_rejected`
|
|
||||||
- `session_closed_by_user`
|
|
||||||
|
|
||||||
4. Операционная польза:
|
|
||||||
- Быстрее диагностируются причины `504`/обрывов/закрытий;
|
|
||||||
- Проще фильтровать инциденты по `req_id` и `session_id` в `docker compose logs api`.
|
|
||||||
|
|
||||||
## 19) Обновления (2026-04-23, лимиты + нагрузка)
|
|
||||||
|
|
||||||
1. Исправление гонки лимитов активных сервисов:
|
|
||||||
- Зафиксирован кейс, когда при параллельных открытиях сервисов одним пользователем лимит мог временно обходиться (наблюдалось до 8 активных сервисов).
|
|
||||||
- Причина: проверка лимита выполнялась вне критической секции.
|
|
||||||
- Исправление: в `go_service` добавлена пользовательская advisory-lock секция `allocator_lock(db, 92000 + user.id)`, внутри которой выполняются:
|
|
||||||
- проверка существующей сессии по сервису,
|
|
||||||
- проверка/ротация по лимиту,
|
|
||||||
- создание новой сессии.
|
|
||||||
- Результат: операции открытия сервисов для одного пользователя сериализованы, лимит применяется стабильно.
|
|
||||||
|
|
||||||
2. Нагрузочное тестирование (k6):
|
|
||||||
- Добавлен скрипт `scripts/load/portal_k6.js`:
|
|
||||||
- логин,
|
|
||||||
- открытие сервиса `/go/<slug>`,
|
|
||||||
- heartbeat `/api/sessions/{id}/touch`,
|
|
||||||
- закрытие `/api/sessions/{id}/close`.
|
|
||||||
- Добавлены профили: `smoke`, `load`, `stress`.
|
|
||||||
- Добавлены пользовательские метрики: `open_success`, `open_rejected`, `limit_redirects`, `touch_rejected`, `flow_errors`.
|
|
||||||
- Добавлена инструкция запуска: `docs/LOAD_TESTING.md`.
|
|
||||||
|
|
||||||
3. Git фиксация:
|
|
||||||
- Commit: `1438dee`
|
|
||||||
- Message: `feat: improve session limit handling and add k6 load testing`
|
|
||||||
|
|
||||||
## 16) Обновления (2026-04-24, вынос maintenance в отдельный контейнер)
|
|
||||||
|
|
||||||
1. Выделен отдельный сервис maintenance:
|
|
||||||
- Добавлен контейнер `maintenance` в `docker-compose.yml`.
|
|
||||||
- Команда контейнера: `python maintenance_runner.py`.
|
|
||||||
- Назначение: единственный фоновый процесс обслуживания пулов и cleanup просроченных сессий.
|
|
||||||
|
|
||||||
2. Поведение API на старте изменено:
|
|
||||||
- Для `api` установлен флаг `ENABLE_STARTUP_MAINTENANCE=0`.
|
|
||||||
- API-воркеры больше не запускают maintenance-потоки при startup.
|
|
||||||
- В логах API при старте ожидаемое сообщение: `startup_maintenance_disabled`.
|
|
||||||
|
|
||||||
3. Что делает maintenance-контейнер:
|
|
||||||
- bootstrap схемы БД (под schema-lock),
|
|
||||||
- `ensure_universal_pool()` и `ensure_web_pool()`,
|
|
||||||
- поддержка warm-pool (когда WEB pool отключен),
|
|
||||||
- cleanup протухших сессий (через существующий `cleanup_loop`).
|
|
||||||
|
|
||||||
4. Блокировка лидера maintenance:
|
|
||||||
- Используется file-lock `/tmp/portal-maintenance.lock`.
|
|
||||||
- Контейнер maintenance удерживает lock и работает как singleton.
|
|
||||||
|
|
||||||
5. Операционные команды:
|
|
||||||
- Перезапуск API + maintenance:
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build api maintenance
|
|
||||||
```
|
|
||||||
- Проверка:
|
|
||||||
```bash
|
|
||||||
docker compose ps api maintenance
|
|
||||||
docker compose logs -f api maintenance
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Текущее целевое состояние после обновления:
|
|
||||||
- `api` отвечает за пользовательские HTTP-запросы.
|
|
||||||
- `maintenance` отвечает за фоновые задачи и состояние пулов.
|
|
||||||
- Traefik продолжает маршрутизацию как и раньше.
|
|
||||||
|
|
||||||
## 17) Нагрузочный прогон (2026-04-24, 100 пользователей x 2 сервиса)
|
|
||||||
|
|
||||||
Сценарий:
|
|
||||||
- 100 тестовых пользователей `loadu001..loadu100` (пароль `LoadTest!2026`),
|
|
||||||
- каждому выдан доступ к 2 WEB-сервисам: `termidesk`, `vmmanager`,
|
|
||||||
- тест: каждый пользователь логинится и запускает оба сервиса последовательно.
|
|
||||||
|
|
||||||
Инструмент и артефакты:
|
|
||||||
- k6 через Docker: `grafana/k6`,
|
|
||||||
- скрипт: `/root/Stend_mont/scripts/load/k6_100_users_2_services.js`,
|
|
||||||
- вывод прогона: `/tmp/k6_100x2.out`.
|
|
||||||
|
|
||||||
Итог прогона:
|
|
||||||
- `iterations`: 100 (по одной на VU),
|
|
||||||
- `checks_succeeded`: 41.61% (124/298),
|
|
||||||
- `http_req_failed`: 41.13% (174/423),
|
|
||||||
- `open termidesk -> 303`: 14% (14/99),
|
|
||||||
- `open vmmanager -> 303`: 11% (11/99),
|
|
||||||
- p95 `http_req_duration`: ~9.07s,
|
|
||||||
- основная причина ошибок по API-логам: `web_pool_lock_timeout` -> HTTP 503 на `/go/<slug>`.
|
|
||||||
|
|
||||||
Вывод:
|
|
||||||
- при burst-нагрузке 100x2 текущий WEB-пул и таймауты распределения не выдерживают,
|
|
||||||
- требуется увеличение емкости/параметров пула и повторный прогон.
|
|
||||||
|
|
||||||
## 18) Нагрузочный прогон (2026-04-24, плавный 20 пользователей x 2 сервиса)
|
|
||||||
|
|
||||||
Цель:
|
|
||||||
- проверить поведение без резкого пика;
|
|
||||||
- имитировать постепенное подключение: +1 пользователь в минуту;
|
|
||||||
- довести до 20 online, каждый запускает 2 WEB-сервиса (termidesk, vmmanager).
|
|
||||||
|
|
||||||
Подготовка:
|
|
||||||
- временно увеличен idle timeout для теста:
|
|
||||||
- .env: SESSION_IDLE_SECONDS=7200;
|
|
||||||
- WEB runtime слоты пересозданы, чтобы получили IDLE_TIMEOUT=7200.
|
|
||||||
- API и maintenance пересозданы с новыми env.
|
|
||||||
|
|
||||||
Профиль нагрузки:
|
|
||||||
- k6 сценарий ramping-vus:
|
|
||||||
- 20m до 20 VU,
|
|
||||||
- 5m удержание 20 VU,
|
|
||||||
- 1m спад до 0.
|
|
||||||
- каждый VU: логин + /go/termidesk + /go/vmmanager, затем удержание.
|
|
||||||
|
|
||||||
Фактический результат:
|
|
||||||
- k6 checks: 60/60 (100%);
|
|
||||||
- custom metrics:
|
|
||||||
- login_ok: 20/20;
|
|
||||||
- open_service_a_ok: 20/20;
|
|
||||||
- open_service_b_ok: 20/20;
|
|
||||||
- HTTP errors: 0/80;
|
|
||||||
- в БД после прогона: 40 ACTIVE WEB-сессий (20 termidesk + 20 vmmanager).
|
|
||||||
|
|
||||||
Наблюдения по инфраструктуре:
|
|
||||||
- во время роста зафиксировано авторасширение WEB-пула до слотов 0..40;
|
|
||||||
- позже часть старших слотов была удалена, но в БД остались ACTIVE-сессии на слотах 20..39.
|
|
||||||
|
|
||||||
Ресурсы сервера (по /tmp/server_stats_20x2.log):
|
|
||||||
- max load average (1m): 17.35;
|
|
||||||
- max used RAM: 9135 MB (из ~64 GB);
|
|
||||||
- max disk usage /: 96%;
|
|
||||||
- max CPU:
|
|
||||||
- stend_mont-api-1: 3.39%,
|
|
||||||
- stend_mont-traefik-1: 60.87%,
|
|
||||||
- stend_mont-db-1: 7.71%,
|
|
||||||
- single portal-webpool-*: до 250.44%.
|
|
||||||
|
|
||||||
Вывод:
|
|
||||||
- плавный сценарий 20x2 проходит стабильно по HTTP/логике запуска;
|
|
||||||
- обнаружен риск целостности состояния: ACTIVE-сессии могут ссылаться на слоты, контейнеры которых уже scale-down/удалены.
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, fail, sleep } from 'k6';
|
||||||
|
import exec from 'k6/execution';
|
||||||
|
|
||||||
|
const BASE_URL = (__ENV.BASE_URL || 'https://127.0.0.1:2288').replace(/\/$/, '');
|
||||||
|
const HOST_HEADER = (__ENV.HOST_HEADER || 'stend.4mont.ru').trim();
|
||||||
|
const SERVICE_A = __ENV.SERVICE_A || 'termidesk';
|
||||||
|
const SERVICE_B = __ENV.SERVICE_B || 'vmmanager';
|
||||||
|
const CLOSE_SESSION = (__ENV.CLOSE_SESSION || '1') !== '0';
|
||||||
|
const REQ_TIMEOUT = __ENV.REQ_TIMEOUT || '20s';
|
||||||
|
|
||||||
|
const users = (__ENV.USERS_CSV || '')
|
||||||
|
.split(';')
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((pair) => {
|
||||||
|
const i = pair.indexOf(':');
|
||||||
|
return i > 0 ? { username: pair.slice(0, i), password: pair.slice(i + 1) } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
burst_100_users: {
|
||||||
|
executor: 'per-vu-iterations',
|
||||||
|
vus: 100,
|
||||||
|
iterations: 1,
|
||||||
|
maxDuration: '3m',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ['rate<0.2'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function hdr(extra = {}) {
|
||||||
|
return HOST_HEADER ? { ...extra, Host: HOST_HEADER } : extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreds() {
|
||||||
|
if (users.length < 100) fail(`Need at least 100 users in USERS_CSV, got ${users.length}`);
|
||||||
|
const idx = (exec.vu.idInTest || 1) - 1;
|
||||||
|
return users[idx % users.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(username, password) {
|
||||||
|
const jar = http.cookieJar();
|
||||||
|
jar.clear(BASE_URL);
|
||||||
|
|
||||||
|
const landing = http.get(`${BASE_URL}/`, {
|
||||||
|
redirects: 0,
|
||||||
|
timeout: REQ_TIMEOUT,
|
||||||
|
headers: hdr(),
|
||||||
|
tags: { name: 'GET /' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const csrfFromCookie = (jar.cookiesForURL(BASE_URL).csrf_token || [])[0] || '';
|
||||||
|
const csrf = csrfFromCookie || ((landing.cookies.csrf_token || [])[0] || {}).value || '';
|
||||||
|
if (!csrf) fail(`No CSRF for ${username}`);
|
||||||
|
|
||||||
|
const payload = [
|
||||||
|
`username=${encodeURIComponent(username)}`,
|
||||||
|
`password=${encodeURIComponent(password)}`,
|
||||||
|
`csrf_token=${encodeURIComponent(csrf)}`,
|
||||||
|
].join('&');
|
||||||
|
|
||||||
|
const loginRes = http.post(`${BASE_URL}/login`, payload, {
|
||||||
|
redirects: 0,
|
||||||
|
timeout: REQ_TIMEOUT,
|
||||||
|
headers: hdr({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||||
|
tags: { name: 'POST /login' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = check(loginRes, { 'login status 303': (r) => r.status === 303 });
|
||||||
|
if (!ok) fail(`Login failed ${username}, status=${loginRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openService(slug) {
|
||||||
|
const res = http.get(`${BASE_URL}/go/${slug}`, {
|
||||||
|
redirects: 0,
|
||||||
|
timeout: REQ_TIMEOUT,
|
||||||
|
headers: hdr(),
|
||||||
|
tags: { name: 'GET /go/:slug' },
|
||||||
|
});
|
||||||
|
const ok = check(res, { [`open ${slug} -> 303`]: (r) => r.status === 303 });
|
||||||
|
const location = res.headers.Location || '';
|
||||||
|
const m = location.match(/\/s\/([0-9a-fA-F-]{36})\//);
|
||||||
|
return { ok, status: res.status, sessionId: m ? m[1] : '', location };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSession(sessionId) {
|
||||||
|
if (!sessionId || !CLOSE_SESSION) return;
|
||||||
|
http.post(`${BASE_URL}/api/sessions/${sessionId}/close`, null, {
|
||||||
|
redirects: 0,
|
||||||
|
timeout: REQ_TIMEOUT,
|
||||||
|
headers: hdr(),
|
||||||
|
tags: { name: 'POST /api/sessions/:id/close' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const { username, password } = getCreds();
|
||||||
|
login(username, password);
|
||||||
|
|
||||||
|
const a = openService(SERVICE_A);
|
||||||
|
sleep(0.1);
|
||||||
|
const b = openService(SERVICE_B);
|
||||||
|
|
||||||
|
closeSession(a.sessionId);
|
||||||
|
closeSession(b.sessionId);
|
||||||
|
|
||||||
|
if (!a.ok || !b.ok) {
|
||||||
|
fail(`open failed user=${username} a=${a.status} b=${b.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import exec from 'k6/execution';
|
|||||||
import { Counter, Rate, Trend } from 'k6/metrics';
|
import { Counter, Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
const BASE_URL = (__ENV.BASE_URL || 'https://stend.4mont.ru').replace(/\/$/, '');
|
const BASE_URL = (__ENV.BASE_URL || 'https://stend.4mont.ru').replace(/\/$/, '');
|
||||||
|
const HOST_HEADER = (__ENV.HOST_HEADER || '').trim();
|
||||||
const SERVICE_SLUGS = (__ENV.SERVICE_SLUGS || 'vmmanager')
|
const SERVICE_SLUGS = (__ENV.SERVICE_SLUGS || 'vmmanager')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
@@ -51,6 +52,7 @@ const selected = PROFILE_OPTIONS[PROFILE] || PROFILE_OPTIONS.smoke;
|
|||||||
|
|
||||||
export const options = {
|
export const options = {
|
||||||
...selected,
|
...selected,
|
||||||
|
noCookiesReset: true,
|
||||||
insecureSkipTLSVerify: (__ENV.INSECURE_TLS || '0') === '1',
|
insecureSkipTLSVerify: (__ENV.INSECURE_TLS || '0') === '1',
|
||||||
thresholds: {
|
thresholds: {
|
||||||
http_req_failed: ['rate<0.02'],
|
http_req_failed: ['rate<0.02'],
|
||||||
@@ -61,6 +63,11 @@ export const options = {
|
|||||||
|
|
||||||
let loggedInKey = '';
|
let loggedInKey = '';
|
||||||
|
|
||||||
|
function withHostHeaders(headers = {}) {
|
||||||
|
if (!HOST_HEADER) return headers;
|
||||||
|
return { ...headers, Host: HOST_HEADER };
|
||||||
|
}
|
||||||
|
|
||||||
function parseUsersCsv(raw) {
|
function parseUsersCsv(raw) {
|
||||||
if (!raw.trim()) return [];
|
if (!raw.trim()) return [];
|
||||||
return raw
|
return raw
|
||||||
@@ -98,7 +105,11 @@ function ensureLoggedIn() {
|
|||||||
const jar = http.cookieJar();
|
const jar = http.cookieJar();
|
||||||
jar.clear(BASE_URL);
|
jar.clear(BASE_URL);
|
||||||
|
|
||||||
const landing = http.get(`${BASE_URL}/`, { redirects: 0, tags: { name: 'GET /' } });
|
const landing = http.get(`${BASE_URL}/`, {
|
||||||
|
redirects: 0,
|
||||||
|
tags: { name: 'GET /' },
|
||||||
|
headers: withHostHeaders(),
|
||||||
|
});
|
||||||
const csrfFromCookie = (jar.cookiesForURL(BASE_URL).csrf_token || [])[0] || '';
|
const csrfFromCookie = (jar.cookiesForURL(BASE_URL).csrf_token || [])[0] || '';
|
||||||
const csrf = csrfFromCookie || ((landing.cookies.csrf_token || [])[0] || {}).value || '';
|
const csrf = csrfFromCookie || ((landing.cookies.csrf_token || [])[0] || {}).value || '';
|
||||||
if (!csrf) {
|
if (!csrf) {
|
||||||
@@ -114,7 +125,7 @@ function ensureLoggedIn() {
|
|||||||
|
|
||||||
const loginRes = http.post(`${BASE_URL}/login`, payload, {
|
const loginRes = http.post(`${BASE_URL}/login`, payload, {
|
||||||
redirects: 0,
|
redirects: 0,
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: withHostHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||||
tags: { name: 'POST /login' },
|
tags: { name: 'POST /login' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +161,7 @@ export default function () {
|
|||||||
const openRes = http.get(`${BASE_URL}/go/${slug}`, {
|
const openRes = http.get(`${BASE_URL}/go/${slug}`, {
|
||||||
redirects: 0,
|
redirects: 0,
|
||||||
tags: { name: 'GET /go/:slug' },
|
tags: { name: 'GET /go/:slug' },
|
||||||
|
headers: withHostHeaders(),
|
||||||
});
|
});
|
||||||
openLatency.add(openRes.timings.duration);
|
openLatency.add(openRes.timings.duration);
|
||||||
|
|
||||||
@@ -187,6 +199,7 @@ export default function () {
|
|||||||
const touchRes = http.post(`${BASE_URL}/api/sessions/${sessionId}/touch`, null, {
|
const touchRes = http.post(`${BASE_URL}/api/sessions/${sessionId}/touch`, null, {
|
||||||
redirects: 0,
|
redirects: 0,
|
||||||
tags: { name: 'POST /api/sessions/:id/touch' },
|
tags: { name: 'POST /api/sessions/:id/touch' },
|
||||||
|
headers: withHostHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (touchRes.status === 410) {
|
if (touchRes.status === 410) {
|
||||||
@@ -208,6 +221,7 @@ export default function () {
|
|||||||
const closeRes = http.post(`${BASE_URL}/api/sessions/${sessionId}/close`, null, {
|
const closeRes = http.post(`${BASE_URL}/api/sessions/${sessionId}/close`, null, {
|
||||||
redirects: 0,
|
redirects: 0,
|
||||||
tags: { name: 'POST /api/sessions/:id/close' },
|
tags: { name: 'POST /api/sessions/:id/close' },
|
||||||
|
headers: withHostHeaders(),
|
||||||
});
|
});
|
||||||
closeCalls.add(1);
|
closeCalls.add(1);
|
||||||
check(closeRes, {
|
check(closeRes, {
|
||||||
|
|||||||
Reference in New Issue
Block a user