diff --git a/apps/common/oidc.py b/apps/common/oidc.py index f1d01e8ffe31487d46fad322f915e1cb29e82ea3..5c67b9f64ec76db3546b18e9b073b842b7e3ce12 100644 --- a/apps/common/oidc.py +++ b/apps/common/oidc.py @@ -8,6 +8,7 @@ from typing import Any from apps.common.config import Config from apps.common.mongo import MongoDB from apps.common.oidc_provider.authhub import AuthhubOIDCProvider +from apps.common.oidc_provider.authelia import AutheliaOIDCProvider from apps.common.oidc_provider.openeuler import OpenEulerOIDCProvider from apps.constants import OIDC_ACCESS_TOKEN_EXPIRE_TIME, OIDC_REFRESH_TOKEN_EXPIRE_TIME @@ -23,6 +24,8 @@ class OIDCProvider: self.provider = OpenEulerOIDCProvider() elif Config().get_config().login.provider == "authhub": self.provider = AuthhubOIDCProvider() + elif Config().get_config().login.provider == "authelia": + self.provider = AutheliaOIDCProvider() else: err = f"[OIDC] 未知OIDC提供商: {Config().get_config().login.provider}" logger.error(err) diff --git a/apps/common/oidc_provider/authelia.py b/apps/common/oidc_provider/authelia.py new file mode 100644 index 0000000000000000000000000000000000000000..44015473d6a0ea1eacd76078b4e70df840919e16 --- /dev/null +++ b/apps/common/oidc_provider/authelia.py @@ -0,0 +1,162 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2023-2025. All rights reserved. +"""Authelia OIDC Provider""" + +import logging +import secrets +from typing import Any + +import httpx +from fastapi import status + +from apps.common.config import Config +from apps.common.oidc_provider.base import OIDCProviderBase +from apps.schemas.config import AutheliaConfig + +logger = logging.getLogger(__name__) + + +class AutheliaOIDCProvider(OIDCProviderBase): + """Authelia OIDC Provider""" + + @classmethod + def _get_login_config(cls) -> AutheliaConfig: + """获取并验证登录配置""" + login_config = Config().get_config().login.settings + if not isinstance(login_config, AutheliaConfig): + err = "Authelia OIDC配置错误" + raise TypeError(err) + return login_config + + @classmethod + async def get_oidc_token(cls, code: str) -> dict[str, Any]: + """获取Authelia OIDC Token""" + login_config = cls._get_login_config() + + data = { + "client_id": login_config.client_id, + "client_secret": login_config.client_secret, + "redirect_uri": login_config.redirect_uri, + "grant_type": "authorization_code", + "code": code, + } + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + url = await cls.get_access_token_url() + result = None + async with httpx.AsyncClient(verify=False) as client: + resp = await client.post( + url, + headers=headers, + data=data, + timeout=10, + ) + if resp.status_code != status.HTTP_200_OK: + err = f"[Authelia] 获取OIDC Token失败: {resp.status_code},完整输出: {resp.text}" + raise RuntimeError(err) + logger.info("[Authelia] 获取OIDC Token成功: %s", resp.text) + result = resp.json() + return { + "access_token": result["access_token"], + "refresh_token": result.get("refresh_token", ""), + } + + @classmethod + async def get_oidc_user(cls, access_token: str) -> dict[str, Any]: + """获取Authelia OIDC用户""" + login_config = cls._get_login_config() + + if not access_token: + err = "Access token is empty." + raise RuntimeError(err) + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + url = login_config.host.rstrip("/") + "/api/oidc/userinfo" + result = None + async with httpx.AsyncClient(verify=False) as client: + resp = await client.get( + url, + headers=headers, + timeout=10, + ) + if resp.status_code != status.HTTP_200_OK: + err = f"[Authelia] 获取用户信息失败: {resp.status_code},完整输出: {resp.text}" + raise RuntimeError(err) + logger.info("[Authelia] 获取用户信息成功: %s", resp.text) + result = resp.json() + + return { + "user_sub": result.get("sub", result.get("preferred_username", "")), + "user_name": result.get("name", result.get("preferred_username", result.get("nickname", ""))), + } + + @classmethod + async def get_login_status(cls, cookie: dict[str, str]) -> dict[str, Any]: + """检查登录状态;Authelia通过session cookie检查""" + login_config = cls._get_login_config() + + headers = { + "Content-Type": "application/json", + } + url = login_config.host.rstrip("/") + "/api/user/info" + async with httpx.AsyncClient(verify=False) as client: + resp = await client.get( + url, + headers=headers, + cookies=cookie, + timeout=10, + ) + if resp.status_code != status.HTTP_200_OK: + err = f"[Authelia] 获取登录状态失败: {resp.status_code},完整输出: {resp.text}" + raise RuntimeError(err) + result = resp.json() + + # Authelia 返回用户信息表示已登录,需要获取或生成token + # 这里返回空的token,实际使用中可能需要根据具体情况调整 + return { + "access_token": "", + "refresh_token": "", + } + + @classmethod + async def oidc_logout(cls, cookie: dict[str, str]) -> None: + """触发OIDC的登出""" + login_config = cls._get_login_config() + + headers = { + "Content-Type": "application/json", + } + url = login_config.host.rstrip("/") + "/api/logout" + async with httpx.AsyncClient(verify=False) as client: + resp = await client.post( + url, + headers=headers, + cookies=cookie, + timeout=10, + ) + # Authelia登出成功通常返回200或302重定向 + if resp.status_code not in [status.HTTP_200_OK, status.HTTP_302_FOUND]: + err = f"[Authelia] 登出失败: {resp.status_code},完整输出: {resp.text}" + raise RuntimeError(err) + + @classmethod + async def get_redirect_url(cls) -> str: + """获取Authelia OIDC 重定向URL""" + login_config = cls._get_login_config() + + # 生成随机的 state 参数以确保安全性和唯一性 + state = secrets.token_urlsafe(32) + return (f"{login_config.host.rstrip('/')}/api/oidc/authorization?" + f"client_id={login_config.client_id}&" + f"response_type=code&" + f"scope=openid profile email&" + f"redirect_uri={login_config.redirect_uri}&" + f"state={state}") + + @classmethod + async def get_access_token_url(cls) -> str: + """获取Authelia OIDC 访问Token URL""" + login_config = cls._get_login_config() + return login_config.host.rstrip("/") + "/api/oidc/token" diff --git a/apps/common/oidc_provider/authhub.py b/apps/common/oidc_provider/authhub.py index f0f462ecb8997b0505a22bd8f6a0c6ec9a2e3895..b8516e011613c498b8cc29f150fe7384466b443d 100644 --- a/apps/common/oidc_provider/authhub.py +++ b/apps/common/oidc_provider/authhub.py @@ -67,30 +67,77 @@ class AuthhubOIDCProvider(OIDCProviderBase): if not access_token: err = "Access token is empty." raise RuntimeError(err) + # 首先尝试使用标准的 userinfo 端点 headers = { + "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } - url = login_config.host_inner.rstrip("/") + "/oauth2/introspect" - data = { - "token": access_token, - "client_id": login_config.app_id, - } + userinfo_url = login_config.host_inner.rstrip("/") + "/oauth2/userinfo" + result = None async with httpx.AsyncClient() as client: - resp = await client.post( - url, + # 尝试 userinfo 端点 + resp = await client.get( + userinfo_url, headers=headers, - json=data, timeout=10, ) + + if resp.status_code == status.HTTP_200_OK: + logger.info("[Authhub] 从 userinfo 端点获取用户信息成功: %s", resp.text) + result = resp.json() + else: + # 如果 userinfo 端点失败,回退到 introspect 端点 + logger.warning("[Authhub] userinfo 端点失败,尝试 introspect 端点") + introspect_url = login_config.host_inner.rstrip("/") + "/oauth2/introspect" + data = { + "token": access_token, + "client_id": login_config.app_id, + } + headers = { + "Content-Type": "application/json", + } + resp = await client.post( + introspect_url, + headers=headers, + json=data, + timeout=10, + ) if resp.status_code != status.HTTP_200_OK: err = f"[Authhub] 获取用户信息失败: {resp.status_code},完整输出: {resp.text}" raise RuntimeError(err) logger.info("[Authhub] 获取用户信息成功: %s", resp.text) result = resp.json() + # 记录完整的响应结构用于调试 + logger.info("[Authhub] 完整响应结构: %s", result) + + # 根据不同的端点响应结构提取用户信息 + if "sub" in result: + # 标准 OIDC userinfo 响应 + user_sub = result["sub"] + user_name = result.get("name", result.get("preferred_username", result.get("nickname", result.get("username", "")))) + elif "data" in result: + # Authhub introspect 响应 + user_data = result["data"] + if isinstance(user_data, str): + # 如果data是字符串,说明只返回了user_sub + user_sub = user_data + user_name = "" + else: + # 如果data是对象,尝试提取用户信息 + user_sub = user_data.get("sub", user_data.get("user_id", str(user_data))) + user_name = user_data.get("name", user_data.get("preferred_username", user_data.get("nickname", user_data.get("username", "")))) + else: + # 其他格式,尝试直接提取 + user_sub = result.get("sub", result.get("user_id", "")) + user_name = result.get("name", result.get("preferred_username", result.get("nickname", result.get("username", "")))) + + logger.info("[Authhub] 提取的用户信息 - user_sub: %s, user_name: %s", user_sub, user_name) + return { - "user_sub": result["data"], + "user_sub": user_sub, + "user_name": user_name, } @classmethod @@ -138,7 +185,8 @@ class AuthhubOIDCProvider(OIDCProviderBase): cookies=cookie, timeout=10, ) - if resp.status_code != status.HTTP_200_OK: + # authHub登出成功会返回302重定向,这是正常的 + if resp.status_code not in [status.HTTP_200_OK, status.HTTP_302_FOUND]: err = f"[Authhub] 登出失败: {resp.status_code},完整输出: {resp.text}" raise RuntimeError(err) diff --git a/apps/common/oidc_provider/openeuler.py b/apps/common/oidc_provider/openeuler.py index 5fec2f51a1efe1c0a7eeffe4b921cf981d14a896..86bdf372bf4a73d01518e46aa48cc8826636d5a6 100644 --- a/apps/common/oidc_provider/openeuler.py +++ b/apps/common/oidc_provider/openeuler.py @@ -92,6 +92,7 @@ class OpenEulerOIDCProvider(OIDCProviderBase): return { "user_sub": result["sub"], + "user_name": result.get("name", result.get("preferred_username", result.get("nickname", ""))), } diff --git a/apps/llm/function.py b/apps/llm/function.py index efaa11548886b21a07bbedb372572cbba021feac..e46633e451bdf326cf57219acc8fc44cbd59a7db 100644 --- a/apps/llm/function.py +++ b/apps/llm/function.py @@ -16,6 +16,11 @@ from apps.common.config import Config from apps.constants import JSON_GEN_MAX_TRIAL, REASONING_END_TOKEN from apps.llm.prompt import JSON_GEN_BASIC +# 导入异常处理相关模块 +import openai +import httpx +from openai import APIError, APIConnectionError, APITimeoutError, RateLimitError, AuthenticationError + logger = logging.getLogger(__name__) @@ -125,14 +130,61 @@ class FunctionLLM: }, ] - response = await self._client.chat.completions.create(**self._params) # type: ignore[arg-type] try: - logger.info("[FunctionCall] 大模型输出:%s", response.choices[0].message.tool_calls[0].function.arguments) - return response.choices[0].message.tool_calls[0].function.arguments - except Exception: # noqa: BLE001 - ans = response.choices[0].message.content - logger.info("[FunctionCall] 大模型输出:%s", ans) - return await FunctionLLM.process_response(ans) + response = await self._client.chat.completions.create(**self._params) # type: ignore[arg-type] + except AuthenticationError as e: + logger.error("[FunctionCall] API认证失败: %s", e) + raise ValueError(f"API认证失败,请检查API密钥: {e}") + except RateLimitError as e: + logger.error("[FunctionCall] API调用频率限制: %s", e) + raise ValueError(f"API调用频率超限,请稍后重试: {e}") + except APIConnectionError as e: + logger.error("[FunctionCall] API连接失败: %s", e) + raise ValueError(f"无法连接到API服务: {e}") + except APITimeoutError as e: + logger.error("[FunctionCall] API请求超时: %s", e) + raise ValueError(f"API请求超时,请稍后重试: {e}") + except APIError as e: + logger.error("[FunctionCall] API调用错误: %s", e) + # 检查是否是账户欠费问题 + if hasattr(e, 'code') and e.code == 'Arrearage': + raise ValueError("账户余额不足,请充值后重试") + elif hasattr(e, 'status_code') and e.status_code == 400: + raise ValueError(f"API请求参数错误: {e}") + else: + raise ValueError(f"API调用失败: {e}") + except httpx.ConnectTimeout: + logger.error("[FunctionCall] 网络连接超时") + raise ValueError("网络连接超时,请检查网络连接") + except httpx.ReadTimeout: + logger.error("[FunctionCall] 网络读取超时") + raise ValueError("网络读取超时,请稍后重试") + except Exception as e: + logger.error("[FunctionCall] 未知错误: %s", e) + raise ValueError(f"LLM调用发生未知错误: {e}") + + try: + # 尝试获取function call结果 + if (response.choices and + response.choices[0].message.tool_calls and + response.choices[0].message.tool_calls[0].function.arguments): + logger.info("[FunctionCall] 大模型输出:%s", response.choices[0].message.tool_calls[0].function.arguments) + return response.choices[0].message.tool_calls[0].function.arguments + except (AttributeError, IndexError, TypeError) as e: + logger.warning("[FunctionCall] 无法获取function call结果,尝试解析content: %s", e) + + # 如果无法获取function call结果,尝试解析content + try: + if response.choices and response.choices[0].message.content: + ans = response.choices[0].message.content + logger.info("[FunctionCall] 大模型输出:%s", ans) + return await FunctionLLM.process_response(ans) + else: + logger.error("[FunctionCall] 大模型返回空响应") + raise ValueError("大模型返回空响应") + except Exception as e: + logger.error("[FunctionCall] 处理响应失败: %s", e) + raise ValueError(f"处理大模型响应失败: {e}") @staticmethod @@ -198,8 +250,21 @@ class FunctionLLM: "format": schema, }) - response = await self._client.chat(**self._params) # type: ignore[arg-type] - return await self.process_response(response.message.content or "") + try: + response = await self._client.chat(**self._params) # type: ignore[arg-type] + except Exception as e: + logger.error("[FunctionCall] Ollama调用失败: %s", e) + raise ValueError(f"Ollama调用失败: {e}") + + try: + content = response.message.content or "" + if not content.strip(): + logger.error("[FunctionCall] Ollama返回空内容") + raise ValueError("Ollama返回空内容") + return await self.process_response(content) + except Exception as e: + logger.error("[FunctionCall] 处理Ollama响应失败: %s", e) + raise ValueError(f"处理Ollama响应失败: {e}") async def call( @@ -220,21 +285,39 @@ class FunctionLLM: if temperature is None: temperature = self._config.temperature - if self._config.backend == "ollama": - json_str = await self._call_ollama(messages, schema, max_tokens, temperature) - - elif self._config.backend in ["function_call", "json_mode", "response_format", "vllm"]: - json_str = await self._call_openai(messages, schema, max_tokens, temperature) - - else: - err = "未知的Function模型后端" - raise ValueError(err) - try: - return json.loads(json_str) - except Exception: # noqa: BLE001 - logger.error("[FunctionCall] 大模型JSON解析失败:%s", json_str) # noqa: TRY400 - return {} + if self._config.backend == "ollama": + json_str = await self._call_ollama(messages, schema, max_tokens, temperature) + elif self._config.backend in ["function_call", "json_mode", "response_format", "vllm", "structured_output"]: + json_str = await self._call_openai(messages, schema, max_tokens, temperature) + else: + err = f"未知的Function模型后端: {self._config.backend}" + logger.error("[FunctionCall] %s", err) + raise ValueError(err) + except ValueError: + # 重新抛出已知的ValueError + raise + except Exception as e: + logger.error("[FunctionCall] 调用模型失败: %s", e) + raise ValueError(f"调用模型失败: {e}") + + # 解析JSON响应 + if not json_str or not json_str.strip(): + logger.error("[FunctionCall] 模型返回空字符串") + raise ValueError("模型返回空响应") + + try: + result = json.loads(json_str) + if not isinstance(result, dict): + logger.warning("[FunctionCall] 模型返回非字典类型: %s", type(result)) + return {} + return result + except json.JSONDecodeError as e: + logger.error("[FunctionCall] JSON解析失败:%s, 原始内容: %s", e, json_str[:200]) + raise ValueError(f"模型返回的内容不是有效的JSON格式: {e}") + except Exception as e: + logger.error("[FunctionCall] 处理JSON响应失败: %s", e) + raise ValueError(f"处理模型响应失败: {e}") class JsonGenerator: @@ -242,6 +325,7 @@ class JsonGenerator: @staticmethod async def _parse_result_by_stack(result: str, schema: dict[str, Any]) -> str: """解析推理结果""" + # 首先尝试解析对象格式 left_index = result.find('{') right_index = result.rfind('}') if left_index != -1 and right_index != -1 and left_index < right_index: @@ -250,7 +334,22 @@ class JsonGenerator: validate(instance=tmp_js, schema=schema) return tmp_js except Exception as e: - logger.error("[JsonGenerator] 解析结果失败: %s", e) + logger.error("[JsonGenerator] 对象格式解析失败: %s", e) + + # 如果对象格式失败,尝试解析数组格式并取第一个元素 + array_left = result.find('[') + array_right = result.rfind(']') + if array_left != -1 and array_right != -1 and array_left < array_right: + try: + array_result = json.loads(result[array_left:array_right + 1]) + if isinstance(array_result, list) and len(array_result) > 0: + # 取数组的第一个元素 + first_item = array_result[0] + validate(instance=first_item, schema=schema) + logger.info("[JsonGenerator] 从数组中提取第一个元素作为结果") + return first_item + except Exception as e: + logger.error("[JsonGenerator] 数组格式解析失败: %s", e) stack = [] json_candidates = [] # 定义括号匹配关系 @@ -330,7 +429,7 @@ class JsonGenerator: prompt = await self._assemble_message() messages = [ {"role": "system", "content": prompt}, -+ {"role": "user", "content": "please generate a JSON response based on the above information and schema."}, + {"role": "user", "content": "please generate a JSON response based on the above information and schema."}, ] function = FunctionLLM() return await function.call(messages, self._schema, max_tokens, temperature) @@ -338,20 +437,45 @@ class JsonGenerator: async def generate(self) -> dict[str, Any]: """生成JSON""" - Draft7Validator.check_schema(self._schema) + try: + Draft7Validator.check_schema(self._schema) + except Exception as e: + logger.error("[JSONGenerator] Schema验证失败: %s", e) + raise ValueError(f"JSON Schema无效: {e}") + validator = Draft7Validator(self._schema) logger.info("[JSONGenerator] Schema:%s", self._schema) + last_error = None while self._count < JSON_GEN_MAX_TRIAL: self._count += 1 - result = await self._single_trial() try: + result = await self._single_trial() + if not result: + logger.warning("[JSONGenerator] 第%d次尝试返回空结果", self._count) + last_error = "模型返回空结果" + continue + + # 验证结果是否符合schema validator.validate(result) - except Exception as err: # noqa: BLE001 + logger.info("[JSONGenerator] 第%d次尝试成功生成有效JSON", self._count) + return result + + except ValueError as e: + # 这是来自FunctionLLM的错误,直接抛出 + logger.error("[JSONGenerator] 第%d次尝试失败,LLM调用错误: %s", self._count, e) + raise e + except Exception as err: err_info = str(err) err_info = err_info.split("\n\n")[0] self._err_info = err_info + last_error = err_info + logger.warning("[JSONGenerator] 第%d次尝试失败,Schema验证错误: %s", self._count, err_info) continue - return result - return {} + # 所有尝试都失败了 + error_msg = f"经过{JSON_GEN_MAX_TRIAL}次尝试仍无法生成有效JSON" + if last_error: + error_msg += f",最后一次错误: {last_error}" + logger.error("[JSONGenerator] %s", error_msg) + raise ValueError(error_msg) diff --git a/apps/main.py b/apps/main.py index 1389af0ed51bbbf0741f0fcad711c2b6af87b018..0d6eda9cd8f520a3f957e1bfff2dadb024f20796 100644 --- a/apps/main.py +++ b/apps/main.py @@ -15,6 +15,7 @@ from contextlib import asynccontextmanager import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.exceptions import HTTPException, RequestValidationError from rich.console import Console from rich.logging import RichHandler @@ -43,6 +44,7 @@ from apps.routers import ( ) from apps.scheduler.pool.pool import Pool from apps.services.predecessor_cache_service import cleanup_background_tasks +from apps.middleware.error_handler import ErrorHandlerMiddleware, create_error_handlers # 全局变量用于跟踪后台任务 _cleanup_task = None @@ -95,6 +97,9 @@ app = FastAPI( version="1.0.0", lifespan=lifespan, ) +# 添加全局异常处理中间件 +app.add_middleware(ErrorHandlerMiddleware) + # 定义FastAPI全局中间件 app.add_middleware( CORSMiddleware, @@ -103,6 +108,11 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) + +# 注册异常处理器 +error_handlers = create_error_handlers() +for exc_type, handler in error_handlers.items(): + app.add_exception_handler(exc_type, handler) # 关联API路由 app.include_router(conversation.router) app.include_router(auth.router) diff --git a/apps/middleware/__init__.py b/apps/middleware/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8e5908e546f6d9e7919eb7d3d00d78a5e26eb3c7 --- /dev/null +++ b/apps/middleware/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2023-2025. All rights reserved. +"""中间件模块""" + +from .error_handler import ErrorHandlerMiddleware, create_error_handlers + +__all__ = ["ErrorHandlerMiddleware", "create_error_handlers"] diff --git a/apps/middleware/error_handler.py b/apps/middleware/error_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..f354d035f7cf3cae77b5966b7577abc35c22edb7 --- /dev/null +++ b/apps/middleware/error_handler.py @@ -0,0 +1,239 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2023-2025. All rights reserved. +"""全局异常处理中间件""" + +import logging +import traceback +from typing import Any, Dict + +from fastapi import Request, Response, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import HTTPException, RequestValidationError +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.exceptions import HTTPException as StarletteHTTPException + +logger = logging.getLogger(__name__) + + +class ErrorHandlerMiddleware(BaseHTTPMiddleware): + """全局错误处理中间件""" + + async def dispatch(self, request: Request, call_next): + """处理请求并捕获异常""" + try: + response = await call_next(request) + return response + except Exception as exc: + return await self.handle_exception(request, exc) + + async def handle_exception(self, request: Request, exc: Exception) -> JSONResponse: + """处理异常并返回适当的响应""" + + # 获取请求信息用于日志 + client_ip = request.client.host if request.client else "unknown" + method = request.method + url = str(request.url) + + # 根据异常类型返回不同的响应 + if isinstance(exc, HTTPException): + # FastAPI HTTPException + logger.warning( + f"HTTP异常 - {method} {url} - IP: {client_ip} - " + f"状态码: {exc.status_code} - 详情: {exc.detail}" + ) + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": exc.status_code, + "message": exc.detail, + "type": "HTTPException" + } + } + ) + + elif isinstance(exc, StarletteHTTPException): + # Starlette HTTPException + logger.warning( + f"Starlette HTTP异常 - {method} {url} - IP: {client_ip} - " + f"状态码: {exc.status_code} - 详情: {exc.detail}" + ) + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": exc.status_code, + "message": exc.detail, + "type": "HTTPException" + } + } + ) + + elif isinstance(exc, RequestValidationError): + # 请求验证错误 + logger.warning( + f"请求验证错误 - {method} {url} - IP: {client_ip} - " + f"错误: {exc.errors()}" + ) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "error": { + "code": 422, + "message": "请求参数验证失败", + "details": exc.errors(), + "type": "ValidationError" + } + } + ) + + elif isinstance(exc, ValueError): + # 业务逻辑错误(通常来自我们的代码) + error_msg = str(exc) + logger.error( + f"业务逻辑错误 - {method} {url} - IP: {client_ip} - " + f"错误: {error_msg}" + ) + + # 根据错误消息判断具体的错误类型 + status_code = status.HTTP_400_BAD_REQUEST + error_type = "ValueError" + + if "认证失败" in error_msg or "API密钥" in error_msg: + status_code = status.HTTP_401_UNAUTHORIZED + error_type = "AuthenticationError" + elif "余额不足" in error_msg or "欠费" in error_msg: + status_code = status.HTTP_402_PAYMENT_REQUIRED + error_type = "PaymentRequired" + elif "频率超限" in error_msg or "限制" in error_msg: + status_code = status.HTTP_429_TOO_MANY_REQUESTS + error_type = "RateLimitError" + elif "连接" in error_msg or "网络" in error_msg: + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + error_type = "ConnectionError" + elif "超时" in error_msg: + status_code = status.HTTP_504_GATEWAY_TIMEOUT + error_type = "TimeoutError" + + return JSONResponse( + status_code=status_code, + content={ + "error": { + "code": status_code, + "message": error_msg, + "type": error_type + } + } + ) + + elif isinstance(exc, PermissionError): + # 权限错误 + logger.warning( + f"权限错误 - {method} {url} - IP: {client_ip} - " + f"错误: {str(exc)}" + ) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "error": { + "code": 403, + "message": str(exc) or "权限不足", + "type": "PermissionError" + } + } + ) + + elif isinstance(exc, FileNotFoundError): + # 文件未找到错误 + logger.warning( + f"文件未找到 - {method} {url} - IP: {client_ip} - " + f"错误: {str(exc)}" + ) + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={ + "error": { + "code": 404, + "message": "请求的资源不存在", + "type": "NotFoundError" + } + } + ) + + else: + # 未知异常 + error_id = id(exc) # 生成唯一错误ID用于追踪 + logger.error( + f"未知异常 [ID: {error_id}] - {method} {url} - IP: {client_ip} - " + f"异常类型: {type(exc).__name__} - 错误: {str(exc)}\n" + f"堆栈跟踪:\n{traceback.format_exc()}" + ) + + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": { + "code": 500, + "message": "服务器内部错误,请稍后重试", + "error_id": error_id, + "type": "InternalServerError" + } + } + ) + + +def create_error_handlers() -> Dict[Any, Any]: + """创建错误处理器字典""" + + async def http_exception_handler(request: Request, exc: HTTPException): + """HTTP异常处理器""" + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": exc.status_code, + "message": exc.detail, + "type": "HTTPException" + } + } + ) + + async def validation_exception_handler(request: Request, exc: RequestValidationError): + """请求验证异常处理器""" + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "error": { + "code": 422, + "message": "请求参数验证失败", + "details": exc.errors(), + "type": "ValidationError" + } + } + ) + + async def general_exception_handler(request: Request, exc: Exception): + """通用异常处理器""" + error_id = id(exc) + logger.error( + f"未处理异常 [ID: {error_id}] - {request.method} {request.url} - " + f"异常类型: {type(exc).__name__} - 错误: {str(exc)}\n" + f"堆栈跟踪:\n{traceback.format_exc()}" + ) + + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": { + "code": 500, + "message": "服务器内部错误,请稍后重试", + "error_id": error_id, + "type": "InternalServerError" + } + } + ) + + return { + HTTPException: http_exception_handler, + RequestValidationError: validation_exception_handler, + Exception: general_exception_handler, + } diff --git a/apps/routers/auth.py b/apps/routers/auth.py index c2d1a6a741b6ef3989f45019db9c9fd581a5f25a..a4bb8e643ce8997ca287cbdbb1765c3a1821be43 100644 --- a/apps/routers/auth.py +++ b/apps/routers/auth.py @@ -35,19 +35,60 @@ templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates" @router.get("/login") -async def oidc_login(request: Request, code: str) -> HTMLResponse: +async def oidc_login(request: Request, code: str = None, error: str = None, error_description: str = None) -> HTMLResponse: """ - OIDC login + OIDC login callback :param request: Request object - :param code: OIDC code + :param code: OIDC authorization code (success case) + :param error: OAuth2 error code (failure case) + :param error_description: OAuth2 error description (failure case) :return: HTMLResponse """ + # Handle OAuth2 error response + if error: + logger.warning(f"OAuth2 authorization failed: {error} - {error_description}") + + # Handle different error types + if error == "access_denied": + reason = "授权被拒绝,请重新登录。" + elif error == "invalid_request": + reason = "请求参数错误,请重试。" + elif error == "unauthorized_client": + reason = "客户端未授权,请联系管理员。" + elif error == "unsupported_response_type": + reason = "不支持的响应类型,请联系管理员。" + elif error == "invalid_scope": + reason = "权限范围无效,请联系管理员。" + elif error == "server_error": + reason = "服务器错误,请稍后重试。" + elif error == "temporarily_unavailable": + reason = "服务暂时不可用,请稍后重试。" + else: + reason = f"登录失败:{error_description or error}" + + return templates.TemplateResponse( + "login_failed.html.j2", + {"request": request, "reason": reason}, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + # Handle missing code parameter + if not code: + logger.error("Neither code nor error parameter provided in OAuth2 callback") + return templates.TemplateResponse( + "login_failed.html.j2", + {"request": request, "reason": "缺少必要的授权参数,请重新登录。"}, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + # Handle successful authorization (existing logic) try: token = await oidc_provider.get_oidc_token(code) user_info = await oidc_provider.get_oidc_user(token["access_token"]) user_sub: str | None = user_info.get("user_sub", None) + user_name: str = user_info.get("user_name", "") except Exception as e: logger.exception("User login failed") status_code = status.HTTP_400_BAD_REQUEST if "auth error" in str(e) else status.HTTP_403_FORBIDDEN @@ -74,9 +115,9 @@ async def oidc_login(request: Request, code: str) -> HTMLResponse: status_code=status.HTTP_403_FORBIDDEN, ) - await UserManager.update_refresh_revision_by_user_sub(user_sub) + await UserManager.update_refresh_revision_by_user_sub(user_sub, user_name=user_name) - current_session = await SessionManager.create_session(user_host, user_sub) + current_session = await SessionManager.create_session(user_host, user_sub, user_name) data = Audit( user_sub=user_sub, @@ -110,6 +151,16 @@ async def logout( result={}, ).model_dump(exclude_none=True, by_alias=True), ) + + # 先触发authHub的登出,清理authHub侧的cookie + try: + await oidc_provider.oidc_logout(dict(request.cookies)) + logger.info(f"AuthHub logout succeeded for user: {user_sub}") + except Exception as e: + logger.warning(f"AuthHub logout failed for user {user_sub}: {e}") + # 即使authHub登出失败,也继续清理本地session,避免用户无法登出 + + # 清理本地token和session await TokenManager.delete_plugin_token(user_sub) await SessionManager.delete_session(session_id) @@ -175,6 +226,7 @@ async def userinfo( message="success", result=AuthUserMsg( user_sub=user_sub, + user_name=user.user_name, revision=user.is_active, is_admin=user.is_admin, auto_execute=user.auto_execute, @@ -222,8 +274,10 @@ async def update_revision_number(request: Request, user_sub: Annotated[str, Depe message="success", result=AuthUserMsg( user_sub=user_sub, + user_name=user.user_name, revision=user.is_active, is_admin=user.is_admin, + auto_execute=user.auto_execute, ), ).model_dump(exclude_none=True, by_alias=True), ) diff --git a/apps/routers/user.py b/apps/routers/user.py index 6ccc167fef96a788014150a284f547c6a7414c70..01587c43c163eadbe63ca3cc7d6b7b6b279f76fc 100644 --- a/apps/routers/user.py +++ b/apps/routers/user.py @@ -28,11 +28,14 @@ async def get_user_sub( user_list, total = await UserManager.get_all_user_sub(page_cnt=page_cnt, page_size=page_size, filter_user_subs=[user_sub]) user_info_list = [] for user in user_list: - # user_info = await UserManager.get_userinfo_by_user_sub(user) 暂时不需要查询user_name if user == user_sub: continue + # 获取用户详细信息,包括用户名 + user_info = await UserManager.get_userinfo_by_user_sub(user) + user_name = user_info.user_name if user_info and user_info.user_name else user + info = UserInfo( - userName=user, + userName=user_name, userSub=user, ) user_info_list.append(info) diff --git a/apps/scheduler/mcp_agent/prompt.py b/apps/scheduler/mcp_agent/prompt.py index d030c6f5aa9d06a6ea877d257f5b334c3f9c392c..e80603ce463fef51f3823e16c8946bf2035e0a9e 100644 --- a/apps/scheduler/mcp_agent/prompt.py +++ b/apps/scheduler/mcp_agent/prompt.py @@ -2079,6 +2079,8 @@ GEN_PARAMS: dict[LanguageType, str] = { 1.生成的参数在格式上必须符合工具入参的schema。 2.总的目标、阶段性的目标和背景信息必须被充分理解,利用其中的信息来生成工具入参。 3.生成的参数必须符合阶段性目标。 + 4.必须返回单个JSON对象,不要返回数组格式。如果需要处理多个项目,请选择最重要或最相关的一个。 + 5.输出格式必须严格按照schema定义,返回一个完整的JSON对象。 # 样例 # 工具信息 @@ -2155,6 +2157,8 @@ GEN_PARAMS: dict[LanguageType, str] = { 1. The generated parameters must conform to the tool input parameter schema. 2. The overall goal, phased goals, and background information must be fully understood and used to generate tool input parameters. 3. The generated parameters must conform to the phased goals. + 4. Must return a single JSON object, not an array format. If multiple items need to be processed, please select the most important or relevant one. + 5. The output format must strictly follow the schema definition and return a complete JSON object. # Example # Tool Information diff --git a/apps/scheduler/pool/loader/mcp.py b/apps/scheduler/pool/loader/mcp.py index bb4cb8a10408118d7614145259e1ef0fd9de8d46..2506bc5d1f48e17c5d367282471e0a889f953b15 100644 --- a/apps/scheduler/pool/loader/mcp.py +++ b/apps/scheduler/pool/loader/mcp.py @@ -216,23 +216,44 @@ class MCPLoader(metaclass=SingletonMeta): ): client = MCPClient() else: - err = f"MCP {mcp_id}:未知的MCP服务类型“{config.type}”" + err = f"MCP {mcp_id}:未知的MCP服务类型'{config.type}'" logger.error(err) raise ValueError(err) - await client.init(user_sub, mcp_id, config.config) + try: + await client.init(user_sub, mcp_id, config.config) + except ConnectionError as e: + logger.error("[MCPLoader] MCP %s 连接失败: %s", mcp_id, e) + raise ValueError(f"MCP {mcp_id} 连接失败: {e}") + except Exception as e: + logger.error("[MCPLoader] MCP %s 初始化异常: %s", mcp_id, e) + raise ValueError(f"MCP {mcp_id} 初始化失败: {e}") # 获取工具列表 tool_list = [] - for item in client.tools: - tool_list += [MCPTool( - id=sqids.encode([random.randint(0, 1000000) for _ in range(5)])[:6], # noqa: S311 - name=item.name, - mcp_id=mcp_id, - description=item.description or "", - input_schema=item.inputSchema, - )] - await client.stop() + try: + for item in client.tools: + tool_list += [MCPTool( + id=sqids.encode([random.randint(0, 1000000) for _ in range(5)])[:6], # noqa: S311 + name=item.name, + mcp_id=mcp_id, + description=item.description or "", + input_schema=item.inputSchema, + )] + logger.info("[MCPLoader] MCP %s 成功获取 %d 个工具", mcp_id, len(tool_list)) + except Exception as e: + logger.error("[MCPLoader] MCP %s 获取工具列表失败: %s", mcp_id, e) + raise ValueError(f"MCP {mcp_id} 获取工具列表失败: {e}") + finally: + # 确保客户端被正确停止 + if hasattr(client, 'stop'): + try: + await client.stop() + except Exception as e: + logger.warning("[MCPLoader] MCP %s 停止客户端时发生异常: %s", mcp_id, e) + else: + logger.warning("[MCPLoader] MCP %s 客户端没有stop方法", mcp_id) + return tool_list @staticmethod diff --git a/apps/scheduler/pool/mcp/client.py b/apps/scheduler/pool/mcp/client.py index b672690536bc1f03923de51bef6b1ed88c785a04..35600c8c6326e4549f984b47431496b5068f8e8d 100644 --- a/apps/scheduler/pool/mcp/client.py +++ b/apps/scheduler/pool/mcp/client.py @@ -6,6 +6,7 @@ import logging from contextlib import AsyncExitStack from typing import TYPE_CHECKING +import httpx from mcp import ClientSession, StdioServerParameters from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client @@ -56,6 +57,55 @@ class MCPClient: # 创建Client if isinstance(config, MCPServerSSEConfig): headers = config.headers or {} + # 添加超时配置 + timeout = getattr(config, 'timeout', 30) # 默认30秒超时 + logger.info("[MCPClient] MCP %s:尝试连接SSE端点 %s,超时时间: %s秒", mcp_id, config.url, timeout) + + try: + # 先测试端点可达性 - 对于SSE端点,我们只检查连接性,不读取内容 + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=3.0)) as test_client: + try: + # 首先尝试HEAD请求 + response = await test_client.head(config.url, headers=headers) + logger.info("[MCPClient] MCP %s:端点预检查响应状态 %s", mcp_id, response.status_code) + + # 如果HEAD请求返回404,尝试流式GET请求验证连接性 + if response.status_code == 404: + logger.info("[MCPClient] MCP %s:HEAD请求返回404,尝试流式连接验证", mcp_id) + try: + # 使用stream=True避免读取完整响应,只验证连接 + async with test_client.stream('GET', config.url, headers=headers) as stream_response: + if stream_response.status_code == 200: + logger.info("[MCPClient] MCP %s:流式连接成功,端点可用", mcp_id) + # 立即关闭流,不读取内容 + else: + logger.warning("[MCPClient] MCP %s:流式连接返回状态 %s", mcp_id, stream_response.status_code) + except httpx.ReadTimeout: + # 对于SSE端点,读取超时是正常的,说明连接成功但在等待流数据 + logger.info("[MCPClient] MCP %s:连接成功但读取超时(SSE端点正常行为)", mcp_id) + except Exception as get_e: + logger.error("[MCPClient] MCP %s:流式连接失败: %s", mcp_id, get_e) + raise ConnectionError(f"MCP端点不可用: {config.url}") + + except httpx.ConnectTimeout: + logger.error("[MCPClient] MCP %s:连接超时", mcp_id) + raise ConnectionError(f"无法连接到MCP端点 {config.url}: 连接超时") + except httpx.RequestError as e: + logger.error("[MCPClient] MCP %s:端点预检查失败: %s", mcp_id, e) + raise ConnectionError(f"无法连接到MCP端点 {config.url}: {e}") + except httpx.HTTPStatusError as e: + logger.warning("[MCPClient] MCP %s:端点返回HTTP错误 %s", mcp_id, e.response.status_code) + # 对于SSE端点,某些HTTP错误是可以接受的 + + except ConnectionError: + # 重新抛出连接错误 + self.error_sign.set() + self.status = MCPStatus.ERROR + raise + except Exception as e: + logger.warning("[MCPClient] MCP %s:连接预检查遇到异常,但继续尝试连接: %s", mcp_id, e) + # 对于其他异常,记录警告但不阻止连接尝试 + client = sse_client( url=config.url, headers=headers @@ -66,6 +116,7 @@ class MCPClient: else: cwd = MCP_PATH / "template" / mcp_id / "project" await cwd.mkdir(parents=True, exist_ok=True) + logger.info("[MCPClient] MCP %s:创建Stdio客户端,工作目录: %s", mcp_id, cwd.as_posix()) client = stdio_client(server=StdioServerParameters( command=config.command, args=config.args, @@ -74,36 +125,85 @@ class MCPClient: )) else: self.error_sign.set() - err = f"[MCPClient] MCP {mcp_id}:未知的MCP服务类型“{config.type}”" + err = f"[MCPClient] MCP {mcp_id}:未知的MCP服务类型'{config.type}'" logger.error(err) raise TypeError(err) # 创建Client、Session + exit_stack = AsyncExitStack() try: - exit_stack = AsyncExitStack() - read, write = await exit_stack.enter_async_context(client) + logger.info("[MCPClient] MCP %s:开始建立连接", mcp_id) + + # 设置超时时间 + timeout_duration = getattr(config, 'timeout', 30) + read, write = await asyncio.wait_for( + exit_stack.enter_async_context(client), + timeout=timeout_duration + ) + self.client = ClientSession(read, write) session = await exit_stack.enter_async_context(self.client) + # 初始化Client - await session.initialize() - except Exception: + logger.info("[MCPClient] MCP %s:开始初始化会话", mcp_id) + await asyncio.wait_for( + session.initialize(), + timeout=timeout_duration + ) + logger.info("[MCPClient] MCP %s:初始化成功", mcp_id) + + except asyncio.TimeoutError: self.error_sign.set() - self.status = MCPStatus.STOPPED - logger.exception("[MCPClient] MCP %s:初始化失败", mcp_id) + self.status = MCPStatus.ERROR + logger.error("[MCPClient] MCP %s:连接或初始化超时", mcp_id) + # 清理资源 + try: + await exit_stack.aclose() + except Exception: + pass + raise ConnectionError(f"MCP {mcp_id} 连接超时") + except Exception as e: + self.error_sign.set() + self.status = MCPStatus.ERROR + logger.error("[MCPClient] MCP %s:初始化失败: %s", mcp_id, e) + # 清理资源 + try: + await exit_stack.aclose() + except Exception: + pass raise self.ready_sign.set() self.status = MCPStatus.RUNNING - # 等待关闭信号 - await self.stop_sign.wait() - logger.info("[MCPClient] MCP %s:收到停止信号,正在关闭", mcp_id) - - # 关闭Client + try: - await exit_stack.aclose() # type: ignore[attr-defined] - self.status = MCPStatus.STOPPED - except Exception: - logger.exception("[MCPClient] MCP %s:关闭失败", mcp_id) + # 等待关闭信号 + await self.stop_sign.wait() + logger.info("[MCPClient] MCP %s:收到停止信号,正在关闭", mcp_id) + except asyncio.CancelledError: + logger.info("[MCPClient] MCP %s:任务被取消,开始清理", mcp_id) + finally: + # 关闭Client - 在finally块中确保资源清理 + try: + # 不使用超时,直接关闭以避免取消作用域问题 + await exit_stack.aclose() + self.status = MCPStatus.STOPPED + logger.info("[MCPClient] MCP %s:成功关闭", mcp_id) + except asyncio.CancelledError: + # 任务被取消是正常情况 + self.status = MCPStatus.STOPPED + logger.info("[MCPClient] MCP %s:关闭过程中被取消(正常)", mcp_id) + except RuntimeError as e: + if "cancel scope" in str(e).lower() or "different task" in str(e).lower(): + # 这是已知的TaskGroup问题,记录警告但不影响功能 + self.status = MCPStatus.STOPPED + logger.warning("[MCPClient] MCP %s:关闭时遇到TaskGroup问题(已知问题,忽略)", mcp_id) + else: + self.status = MCPStatus.ERROR + logger.warning("[MCPClient] MCP %s:关闭时发生运行时错误: %s", mcp_id, e) + except Exception as e: + self.status = MCPStatus.ERROR + logger.warning("[MCPClient] MCP %s:关闭时发生异常: %s", mcp_id, e) async def init(self, user_sub: str | None, mcp_id: str, config: MCPServerSSEConfig | MCPServerStdioConfig) -> None: """ @@ -126,15 +226,41 @@ class MCPClient: self.task = asyncio.create_task(self._main_loop(user_sub, mcp_id, config)) # 等待初始化完成 - done, pending = await asyncio.wait( - [asyncio.create_task(self.ready_sign.wait()), - asyncio.create_task(self.error_sign.wait())], - return_when=asyncio.FIRST_COMPLETED - ) - if self.error_sign.is_set(): - self.status = MCPStatus.ERROR - logger.error("[MCPClient] MCP %s:初始化失败", mcp_id) - raise Exception(f"MCP {mcp_id} 初始化失败") + try: + done, pending = await asyncio.wait( + [asyncio.create_task(self.ready_sign.wait()), + asyncio.create_task(self.error_sign.wait())], + return_when=asyncio.FIRST_COMPLETED + ) + + # 取消未完成的任务 + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + if self.error_sign.is_set(): + self.status = MCPStatus.ERROR + logger.error("[MCPClient] MCP %s:初始化失败", mcp_id) + # 检查主任务是否有异常 + if hasattr(self, 'task') and self.task.done(): + try: + self.task.result() # 这会重新抛出任务中的异常 + except Exception as task_exc: + logger.error("[MCPClient] MCP %s:主任务异常: %s", mcp_id, task_exc) + raise task_exc + raise Exception(f"MCP {mcp_id} 初始化失败") + except Exception as e: + # 确保清理任务 + if hasattr(self, 'task') and not self.task.done(): + self.task.cancel() + try: + await self.task + except asyncio.CancelledError: + pass + raise # 获取工具列表 self.tools = (await self.client.list_tools()).tools @@ -145,8 +271,20 @@ class MCPClient: async def stop(self) -> None: """停止MCP Client""" + if not hasattr(self, 'stop_sign') or not hasattr(self, 'task'): + logger.warning("[MCPClient] 客户端未初始化,无需停止") + return + + logger.info("[MCPClient] MCP %s:开始停止客户端", self.mcp_id) self.stop_sign.set() + try: + # 等待任务完成,不设置超时以避免取消作用域问题 await self.task + logger.info("[MCPClient] MCP %s:客户端已正常停止", self.mcp_id) + except asyncio.CancelledError: + logger.info("[MCPClient] MCP %s:任务已被取消", self.mcp_id) except Exception as e: logger.warning("[MCPClient] MCP %s:停止时发生异常:%s", self.mcp_id, e) + finally: + self.status = MCPStatus.STOPPED diff --git a/apps/scheduler/scheduler/message.py b/apps/scheduler/scheduler/message.py index b2725d6cafc32abc6e4e88b1e4054767708dde9b..b229b97e1b2f829d8e9e9d0101eb3f6b8474c21e 100644 --- a/apps/scheduler/scheduler/message.py +++ b/apps/scheduler/scheduler/message.py @@ -70,66 +70,155 @@ async def push_rag_message( ) -> None: """推送RAG消息""" full_answer = "" + error_message = None + try: async for chunk in RAG.chat_with_llm_base_on_rag( user_sub, llm, history, doc_ids, rag_data, task.language ): - task, content_obj = await _push_rag_chunk(task, queue, chunk) - if content_obj.event_type == EventType.TEXT_ADD.value: - # 如果是文本消息,直接拼接到答案中 - full_answer += content_obj.content - elif content_obj.event_type == EventType.DOCUMENT_ADD.value: - task.runtime.documents.append(content_obj.content) - task.state.flow_status = FlowStatus.SUCCESS + try: + task, content_obj = await _push_rag_chunk(task, queue, chunk) + if content_obj and hasattr(content_obj, 'event_type'): + if content_obj.event_type == EventType.TEXT_ADD.value: + # 如果是文本消息,直接拼接到答案中 + full_answer += content_obj.content + elif content_obj.event_type == EventType.DOCUMENT_ADD.value: + task.runtime.documents.append(content_obj.content) + except Exception as chunk_error: + logger.error(f"[Scheduler] 处理RAG消息块失败: {chunk_error}") + # 继续处理其他块,不中断整个流程 + continue + + if full_answer.strip(): + task.state.flow_status = FlowStatus.SUCCESS + else: + logger.warning("[Scheduler] RAG服务返回空响应") + task.state.flow_status = FlowStatus.ERROR + error_message = "RAG服务返回空响应" + + except ValueError as e: + # 这通常是LLM相关的错误(如API认证、余额不足等) + logger.error(f"[Scheduler] RAG服务参数错误: {e}") + task.state.flow_status = FlowStatus.ERROR + error_message = str(e) + + # 推送错误消息到前端 + try: + from apps.schemas.message import TextAddContent + await queue.push_output( + task=task, + event_type=EventType.TEXT_ADD.value, + data=TextAddContent(text=f"❌ 错误: {error_message}").model_dump(exclude_none=True, by_alias=True), + ) + except Exception as push_error: + logger.error(f"[Scheduler] 推送错误消息失败: {push_error}") + + except ConnectionError as e: + logger.error(f"[Scheduler] RAG服务连接失败: {e}") + task.state.flow_status = FlowStatus.ERROR + error_message = "RAG服务连接失败,请检查网络连接" + + except TimeoutError as e: + logger.error(f"[Scheduler] RAG服务超时: {e}") + task.state.flow_status = FlowStatus.ERROR + error_message = "RAG服务响应超时,请稍后重试" + except Exception as e: - logger.error(f"[Scheduler] RAG服务发生错误: {e}") + logger.error(f"[Scheduler] RAG服务发生未知错误: {e}") task.state.flow_status = FlowStatus.ERROR - # 保存答案 + error_message = f"RAG服务发生未知错误: {e}" + + # 如果有错误,确保错误信息被保存 + if error_message and not full_answer: + full_answer = f"错误: {error_message}" + + # 保存答案和任务状态 task.runtime.answer = full_answer task.tokens.full_time = round(datetime.now(UTC).timestamp(), 2) - task.tokens.time - await TaskManager.save_task(task.id, task) + + try: + await TaskManager.save_task(task.id, task) + except Exception as save_error: + logger.error(f"[Scheduler] 保存任务失败: {save_error}") + # 任务保存失败不应该影响主流程 -async def _push_rag_chunk(task: Task, queue: MessageQueue, content: str) -> tuple[Task, RAGEventData]: +async def _push_rag_chunk(task: Task, queue: MessageQueue, content: str) -> tuple[Task, RAGEventData | None]: """推送RAG单个消息块""" - # 如果是换行 + # 如果是换行或空内容 if not content or not content.rstrip().rstrip("\n"): - return task, "" + return task, None try: - content_obj = RAGEventData.model_validate_json(dedent(content[6:]).rstrip("\n")) + # 解析RAG事件数据 + if len(content) < 6: + logger.warning("[Scheduler] RAG消息块内容过短: %s", content) + return task, None + + raw_content = dedent(content[6:]).rstrip("\n") + if not raw_content.strip(): + logger.debug("[Scheduler] RAG消息块为空") + return task, None + + try: + content_obj = RAGEventData.model_validate_json(raw_content) + except Exception as parse_error: + logger.error("[Scheduler] RAG事件数据解析失败: %s, 原始内容: %s", parse_error, raw_content[:100]) + return task, None + # 如果是空消息 if not content_obj.content: - return task, "" + logger.debug("[Scheduler] RAG事件内容为空") + return task, None - task.tokens.input_tokens = content_obj.input_tokens - task.tokens.output_tokens = content_obj.output_tokens + # 更新token统计 + if hasattr(content_obj, 'input_tokens') and content_obj.input_tokens is not None: + task.tokens.input_tokens = content_obj.input_tokens + if hasattr(content_obj, 'output_tokens') and content_obj.output_tokens is not None: + task.tokens.output_tokens = content_obj.output_tokens - await TaskManager.save_task(task.id, task) - # 推送消息 - if content_obj.event_type == EventType.TEXT_ADD.value: - await queue.push_output( - task=task, - event_type=content_obj.event_type, - data=TextAddContent(text=content_obj.content).model_dump(exclude_none=True, by_alias=True), - ) - elif content_obj.event_type == EventType.DOCUMENT_ADD.value: - await queue.push_output( - task=task, - event_type=content_obj.event_type, - data=DocumentAddContent( - documentId=content_obj.content.get("id", ""), - documentOrder=content_obj.content.get("order", 0), - documentAuthor=content_obj.content.get("author", ""), - documentName=content_obj.content.get("name", ""), - documentAbstract=content_obj.content.get("abstract", ""), - documentType=content_obj.content.get("extension", ""), - documentSize=content_obj.content.get("size", 0), - createdAt=round(content_obj.content.get("created_at", datetime.now(tz=UTC).timestamp()), 3), - ).model_dump(exclude_none=True, by_alias=True), - ) - except Exception: - logger.exception("[Scheduler] RAG服务返回错误数据") - return task, "" - else: - return task, content_obj \ No newline at end of file + # 保存任务状态 + try: + await TaskManager.save_task(task.id, task) + except Exception as save_error: + logger.warning("[Scheduler] 保存任务状态失败: %s", save_error) + # 保存失败不应该阻止消息推送 + + # 推送消息到前端 + try: + if content_obj.event_type == EventType.TEXT_ADD.value: + await queue.push_output( + task=task, + event_type=content_obj.event_type, + data=TextAddContent(text=content_obj.content).model_dump(exclude_none=True, by_alias=True), + ) + elif content_obj.event_type == EventType.DOCUMENT_ADD.value: + if isinstance(content_obj.content, dict): + await queue.push_output( + task=task, + event_type=content_obj.event_type, + data=DocumentAddContent( + documentId=content_obj.content.get("id", ""), + documentOrder=content_obj.content.get("order", 0), + documentAuthor=content_obj.content.get("author", ""), + documentName=content_obj.content.get("name", ""), + documentAbstract=content_obj.content.get("abstract", ""), + documentType=content_obj.content.get("extension", ""), + documentSize=content_obj.content.get("size", 0), + createdAt=round(content_obj.content.get("created_at", datetime.now(tz=UTC).timestamp()), 3), + ).model_dump(exclude_none=True, by_alias=True), + ) + else: + logger.warning("[Scheduler] DOCUMENT_ADD事件内容格式错误: %s", type(content_obj.content)) + else: + logger.warning("[Scheduler] 未知的事件类型: %s", content_obj.event_type) + + except Exception as push_error: + logger.error("[Scheduler] 推送消息到前端失败: %s", push_error) + # 推送失败不应该阻止返回结果 + + return task, content_obj + + except Exception as e: + logger.error("[Scheduler] 处理RAG消息块时发生未知错误: %s, 内容: %s", e, content[:100] if content else "None") + return task, None \ No newline at end of file diff --git a/apps/scheduler/scheduler/scheduler.py b/apps/scheduler/scheduler/scheduler.py index 35cd1edb14aeeb042067a7179b626ade637ca67f..ac27e5f709186958ce0c58e3e923f9c27e65a6ad 100644 --- a/apps/scheduler/scheduler/scheduler.py +++ b/apps/scheduler/scheduler/scheduler.py @@ -136,8 +136,7 @@ class Scheduler: if rag_method: llm = await self.get_llm_use_in_chat_with_rag() kb_ids = await self.get_kb_ids_use_in_chat_with_rag() - logger.error('here') - logger.error(kb_ids) + self.task = await push_init_message(self.task, self.queue, 3, is_flow=False) rag_data = RAGQueryReq( kbIds=kb_ids, diff --git a/apps/schemas/collection.py b/apps/schemas/collection.py index 9a5daaef63c6d71b05ab07bf5eea138463f708f9..3a522168d87861e5cab633098c503d989679f3b4 100644 --- a/apps/schemas/collection.py +++ b/apps/schemas/collection.py @@ -51,6 +51,7 @@ class User(BaseModel): """ id: str = Field(alias="_id") + user_name: str = Field(default="", description="用户名") last_login: float = Field(default_factory=lambda: round(datetime.now(tz=UTC).timestamp(), 3)) is_active: bool = False is_whitelisted: bool = False diff --git a/apps/schemas/config.py b/apps/schemas/config.py index d3dbf85927cfd49ad434b7c89bc1f0bb65d9af2a..f7bee3b50710fb4928154c17936259f11a135de7 100644 --- a/apps/schemas/config.py +++ b/apps/schemas/config.py @@ -30,6 +30,15 @@ class OIDCConfig(BaseModel): app_secret: str = Field(description="OIDC App Secret") +class AutheliaConfig(BaseModel): + """Authelia认证配置""" + + host: str = Field(description="Authelia服务路径") + client_id: str = Field(description="OIDC Client ID") + client_secret: str = Field(description="OIDC Client Secret") + redirect_uri: str = Field(description="重定向URI") + + class FixedUserConfig(BaseModel): """固定用户配置""" @@ -39,8 +48,8 @@ class FixedUserConfig(BaseModel): class LoginConfig(BaseModel): """OIDC配置""" - provider: Literal["authhub", "openeuler", "disable"] = Field(description="OIDC Provider", default="authhub") - settings: OIDCConfig | FixedUserConfig = Field(description="OIDC 配置") + provider: Literal["authhub", "openeuler", "authelia", "disable"] = Field(description="OIDC Provider", default="authhub") + settings: OIDCConfig | AutheliaConfig | FixedUserConfig = Field(description="OIDC 配置") class EmbeddingConfig(BaseModel): diff --git a/apps/schemas/response_data.py b/apps/schemas/response_data.py index 1c8ae72a51aae2134edd997af211ad15f4f5be38..dcb89198602b28eefd7b587246acbf19d581be85 100644 --- a/apps/schemas/response_data.py +++ b/apps/schemas/response_data.py @@ -54,6 +54,7 @@ class AuthUserMsg(BaseModel): """GET /api/auth/user Result数据结构""" user_sub: str + user_name: str revision: bool is_admin: bool auto_execute: bool diff --git a/apps/schemas/session.py b/apps/schemas/session.py index 361987cc23d93e428e7fee12a520f65435946f61..3684c052986e8515b87bbe4aa1b526a2a1ae3283 100644 --- a/apps/schemas/session.py +++ b/apps/schemas/session.py @@ -16,5 +16,6 @@ class Session(BaseModel): id: str = Field(alias="_id") ip: str user_sub: str | None = None + user_name: str | None = None nonce: str | None = None expired_at: datetime diff --git a/apps/services/session.py b/apps/services/session.py index 904ba26dc842bcb90c6027f35af25084164bcf44..ebb573218744a345f22193218833d79464aa60f7 100644 --- a/apps/services/session.py +++ b/apps/services/session.py @@ -20,7 +20,7 @@ class SessionManager: """浏览器Session管理""" @staticmethod - async def create_session(ip: str | None = None, user_sub: str | None = None) -> str: + async def create_session(ip: str | None = None, user_sub: str | None = None, user_name: str | None = None) -> str: """创建浏览器Session""" if not ip: err = "用户IP错误!" @@ -41,6 +41,9 @@ class SessionManager: if user_sub is not None: data.user_sub = user_sub + + if user_name is not None: + data.user_name = user_name collection = MongoDB().get_collection("session") await collection.insert_one(data.model_dump(exclude_none=True, by_alias=True)) diff --git a/apps/services/user.py b/apps/services/user.py index aaa220da1463244e501f7845ce78066aab96f9f6..36c04ea8e33bb62eb610aae78a72f641b4208f57 100644 --- a/apps/services/user.py +++ b/apps/services/user.py @@ -17,16 +17,18 @@ class UserManager: """用户相关操作""" @staticmethod - async def add_userinfo(user_sub: str) -> None: + async def add_userinfo(user_sub: str, user_name: str = "") -> None: """ 向数据库中添加用户信息 :param user_sub: 用户sub + :param user_name: 用户名 """ mongo = MongoDB() user_collection = mongo.get_collection("user") await user_collection.insert_one(User( _id=user_sub, + user_name=user_name, ).model_dump(by_alias=True)) @staticmethod @@ -78,18 +80,19 @@ class UserManager: await user_collection.update_one({"_id": user_sub}, update_dict) @staticmethod - async def update_refresh_revision_by_user_sub(user_sub: str, *, refresh_revision: bool = False) -> bool: + async def update_refresh_revision_by_user_sub(user_sub: str, *, refresh_revision: bool = False, user_name: str = "") -> bool: """ 根据用户sub更新用户信息 :param user_sub: 用户sub :param refresh_revision: 是否刷新revision + :param user_name: 用户名(仅在创建新用户时使用) :return: 更新后的用户信息 """ mongo = MongoDB() user_data = await UserManager.get_userinfo_by_user_sub(user_sub) if not user_data: - await UserManager.add_userinfo(user_sub) + await UserManager.add_userinfo(user_sub, user_name) return True update_dict = { @@ -142,16 +145,16 @@ class UserManager: mongo = MongoDB() user_collection = mongo.get_collection("user") - # 构建更新字典,只更新非None的字段 + # 构建更新字典,只更新非None的字段,使用别名字段名以保持与模型一致 preferences_update = {} if data.reasoning_model_preference is not None: - preferences_update["preferences.reasoning_model_preference"] = data.reasoning_model_preference.model_dump() + preferences_update["preferences.reasoningModelPreference"] = data.reasoning_model_preference.model_dump(by_alias=True) if data.embedding_model_preference is not None: - preferences_update["preferences.embedding_model_preference"] = data.embedding_model_preference.model_dump() + preferences_update["preferences.embeddingModelPreference"] = data.embedding_model_preference.model_dump(by_alias=True) if data.reranker_preference is not None: - preferences_update["preferences.reranker_preference"] = data.reranker_preference.model_dump() + preferences_update["preferences.rerankerPreference"] = data.reranker_preference.model_dump(by_alias=True) if data.chain_of_thought_preference is not None: - preferences_update["preferences.chain_of_thought_preference"] = data.chain_of_thought_preference + preferences_update["preferences.chainOfThoughtPreference"] = data.chain_of_thought_preference if preferences_update: update_dict = {"$set": preferences_update} @@ -169,7 +172,31 @@ class UserManager: user_collection = mongo.get_collection("user") user_data = await user_collection.find_one({"_id": user_sub}, {"preferences": 1}) if user_data and "preferences" in user_data: + preferences_data = user_data["preferences"] + + # 数据迁移:将旧的下划线格式字段名转换为驼峰格式 + migration_needed = False + if "reasoning_model_preference" in preferences_data: + preferences_data["reasoningModelPreference"] = preferences_data.pop("reasoning_model_preference") + migration_needed = True + if "embedding_model_preference" in preferences_data: + preferences_data["embeddingModelPreference"] = preferences_data.pop("embedding_model_preference") + migration_needed = True + if "reranker_preference" in preferences_data: + preferences_data["rerankerPreference"] = preferences_data.pop("reranker_preference") + migration_needed = True + if "chain_of_thought_preference" in preferences_data: + preferences_data["chainOfThoughtPreference"] = preferences_data.pop("chain_of_thought_preference") + migration_needed = True + + # 如果进行了迁移,更新数据库 + if migration_needed: + await user_collection.update_one( + {"_id": user_sub}, + {"$set": {"preferences": preferences_data}} + ) + # 使用model_validate来处理从数据库读取的数据,这样会正确处理别名映射 - return UserPreferences.model_validate(user_data["preferences"]) + return UserPreferences.model_validate(preferences_data) else: return UserPreferences() diff --git a/apps/templates/login_success.html.j2 b/apps/templates/login_success.html.j2 index 5702e6ff1f9a99e34419b8fe45f8dd82f28b4843..0f21ac6a572f28c019f15873602cd9fa1c52d530 100644 --- a/apps/templates/login_success.html.j2 +++ b/apps/templates/login_success.html.j2 @@ -16,18 +16,32 @@ window.onload = function() { try { const sessionId = "{{ current_session }}"; + console.log('Login success, sessionId:', sessionId); + document.getElementById('desc').innerText = "登录成功,正在跳转..."; + if (window.opener && window.opener !== window) { - // 使用 postMessage 发送 sessionId 到主窗口, 兼容 Electron 没有域名的情况 - window.opener.postMessage({type: 'auth_success', sessionId: sessionId}, '*'); + // 发送正确格式的 postMessage 给父窗口 + console.log('Sending postMessage to parent window'); + window.opener.postMessage({ + type: 'auth_success', + sessionId: sessionId + }, '*'); + document.getElementById('desc').innerText = "登录成功,窗口即将自动关闭…"; - setTimeout(window.close, 1500); + setTimeout(function() { + window.close(); + }, 1500); } else { - console.warn('未找到 window.opener 或 opener 等于自身,无法 postMessage。'); - document.getElementById('desc').innerText = "登录成功,但未能自动返回主页面,请手动关闭本窗口。"; + // 如果没有 opener,直接重定向到主页 + console.warn('未找到 window.opener,直接重定向到主页'); + document.getElementById('desc').innerText = "登录成功,正在跳转到主页..."; + setTimeout(function() { + window.location.href = '/'; + }, 1500); } } catch (e) { - console.error("postMessage 脚本出错:", e); - document.getElementById('desc').innerText = "登录流程发生异常,请关闭本窗口并重试。"; + console.error("登录成功脚本出错:", e); + document.getElementById('desc').innerText = "登录成功,请手动关闭本窗口或刷新主页面。"; } }; diff --git a/apps/utils/error_formatter.py b/apps/utils/error_formatter.py new file mode 100644 index 0000000000000000000000000000000000000000..f0f736a6719cf4e3f5bd96d4723c9c347606b62b --- /dev/null +++ b/apps/utils/error_formatter.py @@ -0,0 +1,170 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2023-2025. All rights reserved. +"""错误格式化工具""" + +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class ErrorFormatter: + """错误信息格式化器""" + + # 错误类型到用户友好消息的映射 + ERROR_MESSAGES = { + # API相关错误 + "AuthenticationError": "身份验证失败,请检查API密钥配置", + "PaymentRequired": "账户余额不足,请充值后重试", + "RateLimitError": "请求频率过高,请稍后重试", + "ConnectionError": "网络连接失败,请检查网络设置", + "TimeoutError": "请求超时,请稍后重试", + + # 业务逻辑错误 + "ValidationError": "请求参数不正确", + "PermissionError": "权限不足,无法执行此操作", + "NotFoundError": "请求的资源不存在", + + # 系统错误 + "InternalServerError": "系统内部错误,请稍后重试", + "ServiceUnavailable": "服务暂时不可用,请稍后重试", + } + + # 错误代码到建议操作的映射 + ERROR_SUGGESTIONS = { + 401: "请检查登录状态或API密钥配置", + 402: "请前往控制台充值账户余额", + 403: "请联系管理员获取相应权限", + 404: "请检查请求的资源是否存在", + 429: "请降低请求频率或稍后重试", + 500: "请稍后重试,如问题持续存在请联系技术支持", + 502: "服务网关错误,请稍后重试", + 503: "服务暂时不可用,请稍后重试", + 504: "服务响应超时,请稍后重试", + } + + @classmethod + def format_error_for_frontend( + cls, + error_code: int, + error_message: str, + error_type: Optional[str] = None, + error_details: Optional[Any] = None + ) -> Dict[str, Any]: + """ + 格式化错误信息供前端显示 + + Args: + error_code: HTTP状态码 + error_message: 错误消息 + error_type: 错误类型 + error_details: 错误详情 + + Returns: + 格式化后的错误信息字典 + """ + + # 获取用户友好的错误消息 + user_message = error_message + if error_type and error_type in cls.ERROR_MESSAGES: + user_message = cls.ERROR_MESSAGES[error_type] + + # 获取建议操作 + suggestion = cls.ERROR_SUGGESTIONS.get(error_code, "请稍后重试") + + # 确定错误级别 + if error_code >= 500: + level = "error" + elif error_code >= 400: + level = "warning" + else: + level = "info" + + # 构建返回结果 + result = { + "code": error_code, + "message": user_message, + "level": level, + "suggestion": suggestion, + "timestamp": None, # 前端会自动添加时间戳 + } + + # 添加原始错误信息(用于调试) + if error_message != user_message: + result["original_message"] = error_message + + # 添加错误类型 + if error_type: + result["type"] = error_type + + # 添加详细信息(如验证错误的具体字段) + if error_details: + result["details"] = error_details + + return result + + @classmethod + def format_llm_error(cls, error_message: str) -> Dict[str, Any]: + """ + 格式化LLM相关错误 + + Args: + error_message: 错误消息 + + Returns: + 格式化后的错误信息 + """ + + # 根据错误消息内容判断错误类型 + error_code = 500 + error_type = "InternalServerError" + suggestion = "请稍后重试" + + message_lower = error_message.lower() + + if "认证失败" in message_lower or "api密钥" in message_lower: + error_code = 401 + error_type = "AuthenticationError" + suggestion = "请检查API密钥配置是否正确" + elif "余额不足" in message_lower or "欠费" in message_lower: + error_code = 402 + error_type = "PaymentRequired" + suggestion = "请前往阿里云控制台充值账户余额" + elif "频率超限" in message_lower or "限制" in message_lower: + error_code = 429 + error_type = "RateLimitError" + suggestion = "请降低请求频率或稍后重试" + elif "连接" in message_lower or "网络" in message_lower: + error_code = 503 + error_type = "ConnectionError" + suggestion = "请检查网络连接或服务状态" + elif "超时" in message_lower: + error_code = 504 + error_type = "TimeoutError" + suggestion = "请稍后重试,如问题持续存在请联系技术支持" + + return cls.format_error_for_frontend( + error_code=error_code, + error_message=error_message, + error_type=error_type + ) + + @classmethod + def create_error_response(cls, error_info: Dict[str, Any]) -> str: + """ + 创建错误响应文本(用于流式响应) + + Args: + error_info: 错误信息字典 + + Returns: + 格式化的错误响应文本 + """ + + message = error_info.get("message", "发生未知错误") + suggestion = error_info.get("suggestion", "") + + error_text = f"❌ {message}" + if suggestion: + error_text += f"\n\n💡 建议: {suggestion}" + + return error_text diff --git a/deploy/README-authelia.md b/deploy/README-authelia.md new file mode 100644 index 0000000000000000000000000000000000000000..0ebcdbb06fbb6efe46a599b5a66e18f773106145 --- /dev/null +++ b/deploy/README-authelia.md @@ -0,0 +1,179 @@ +# Euler Copilot Framework - Authelia 集成指南 + +本文档介绍如何在 Euler Copilot Framework 中集成和使用 Authelia 作为身份认证和授权服务。 + +## 概述 + +Euler Copilot Framework 现在支持两种身份认证方式: +1. **AuthHub** - 传统的内置身份认证服务 +2. **Authelia** - 现代化的 OIDC 身份认证服务 + +## 新增功能 + +### 1. 代码沙箱服务 (OpenEuler Intelligence Sandbox) + +- **位置**: `deploy/chart/euler_copilot/templates/sandbox/` +- **配置**: `deploy/chart/euler_copilot/configs/sandbox/` +- **功能**: 提供安全的代码执行环境,支持 Python、JavaScript、Bash 等语言 + +### 2. Authelia 身份认证服务 + +- **位置**: `deploy/chart/authelia/` +- **功能**: 提供 OIDC 兼容的身份认证和授权服务 +- **特性**: + - 支持多因素认证(可配置) + - OIDC/OAuth2 兼容 + - 基于文件的用户管理 + - 会话管理 + +### 3. 灵活的鉴权服务部署 + +- **脚本**: `deploy/scripts/7-install-auth-service/install_auth_service.sh` +- **功能**: 支持选择部署 AuthHub 或 Authelia + +## 部署方式 + +### 方式一:使用部署脚本选择鉴权服务 + +```bash +cd /opt/euler-copilot-framework/deploy/scripts +./deploy.sh +# 选择 "7) 安装鉴权服务",然后选择要部署的服务类型 +``` + +### 方式二:直接使用鉴权服务安装脚本 + +```bash +# 部署 Authelia +cd /opt/euler-copilot-framework/deploy/scripts/7-install-auth-service +./install_auth_service.sh --service authelia --address http://your-host:30091 + +# 部署 AuthHub +./install_auth_service.sh --service authhub --address http://your-host:30081 +``` + +### 方式三:使用 Authelia 一键部署脚本 + +```bash +cd /opt/euler-copilot-framework/deploy/scripts/9-other-script +./deploy_with_authelia.sh --authelia_address http://your-host:30091 --euler_address http://your-host:30080 +``` + +## 配置说明 + +### Authelia 配置 + +在 `values.yaml` 中配置 Authelia: + +```yaml +# 登录设置 +login: + provider: authelia # 设置为 authelia + authelia: + client_id: euler-copilot + client_secret: your-client-secret-here + +# 域名设置 +domain: + euler_copilot: http://your-host:30080 + authelia: http://your-host:30091 +``` + +### 代码沙箱配置 + +```yaml +euler_copilot: + sandbox: + enabled: true + security: + enabled: true + maxExecutionTime: 30 + maxMemoryMB: 512 + queue: + maxSize: 100 + workerThreads: 4 +``` + +## 默认账号 + +### Authelia 默认账号 +- **管理员**: `admin` / `admin123` +- **普通用户**: `user` / `user123` + +### AuthHub 默认账号 +- **管理员**: `administrator` / `changeme` + +## 安全注意事项 + +⚠️ **生产环境重要提示**: + +1. **修改默认密码**: 所有默认密码都必须在生产环境中修改 +2. **更新密钥**: 修改 Authelia 配置中的所有密钥: + - `session.secret` + - `storage.encryption_key` + - `identity_validation.reset_password.jwt_secret` + - `identity_providers.oidc.hmac_secret` +3. **生成新的 OIDC 签名密钥**: 替换默认的 RSA 私钥 +4. **配置 TLS**: 在生产环境中启用 HTTPS +5. **限制访问**: 配置适当的网络策略和防火墙规则 + +## 服务端口 + +| 服务 | 默认端口 | 说明 | +|------|----------|------| +| EulerCopilot Web | 30080 | 主要的 Web 界面 | +| AuthHub | 30081 | AuthHub 认证服务 | +| Authelia | 30091 | Authelia 认证服务 | +| 代码沙箱 | 8000 | 代码执行服务(集群内部) | + +## 故障排除 + +### 1. 检查 Pod 状态 +```bash +kubectl get pods -n euler-copilot +kubectl logs -n euler-copilot +``` + +### 2. 检查服务状态 +```bash +kubectl get svc -n euler-copilot +``` + +### 3. 检查配置 +```bash +kubectl get configmap -n euler-copilot +kubectl describe configmap framework-config -n euler-copilot +``` + +### 4. 常见问题 + +**问题**: Authelia 登录后重定向失败 +**解决**: 检查 `redirect_uris` 配置是否与实际访问地址匹配 + +**问题**: 代码沙箱服务无法访问 +**解决**: 确认 sandbox 服务已启用且 Pod 正常运行 + +**问题**: 配置文件模板错误 +**解决**: 检查 values.yaml 中的配置是否正确,特别是域名和端口设置 + +## 升级指南 + +从 AuthHub 迁移到 Authelia: + +1. 备份现有配置和数据 +2. 更新 `values.yaml` 中的 `login.provider` 为 `authelia` +3. 配置 Authelia 相关参数 +4. 重新部署服务 +5. 更新用户访问方式 + +## 技术支持 + +如有问题,请检查: +1. Kubernetes 集群状态 +2. Helm Release 状态 +3. 网络连通性 +4. 配置文件语法 + +更多信息请参考: +- [Authelia 官方文档](https://www.authelia.com/) +- [Euler Copilot Framework 文档](../README.md) diff --git a/deploy/chart/authelia/Chart.yaml b/deploy/chart/authelia/Chart.yaml new file mode 100644 index 0000000000000000000000000000000000000000..37921a298de6b9ba75314c9d6a841fd62eb26c2b --- /dev/null +++ b/deploy/chart/authelia/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: authelia +description: Authelia authentication and authorization server for Euler Copilot Framework +type: application +version: 0.1.0 +appVersion: "4.39.13" +keywords: + - authentication + - authorization + - oidc + - security +home: https://www.authelia.com/ +sources: + - https://github.com/authelia/authelia +maintainers: + - name: Euler Copilot Team + email: euler-copilot@openeuler.org diff --git a/deploy/chart/authelia/templates/NOTES.txt b/deploy/chart/authelia/templates/NOTES.txt new file mode 100644 index 0000000000000000000000000000000000000000..45a77253a77758857c73307fd3541c311206f127 --- /dev/null +++ b/deploy/chart/authelia/templates/NOTES.txt @@ -0,0 +1,36 @@ +1. Get the application URL by running these commands: +{{- if eq (.Values.authelia.service.type | default "NodePort") "NodePort" }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "authelia.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if eq (.Values.authelia.service.type | default "NodePort") "LoadBalancer" }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "authelia.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "authelia.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.authelia.service.port | default 9091 }} +{{- else if eq (.Values.authelia.service.type | default "NodePort") "ClusterIP" }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "{{ include "authelia.selectorLabels" . }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:{{ .Values.authelia.service.port | default 9091 }} to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME {{ .Values.authelia.service.port | default 9091 }}:$CONTAINER_PORT +{{- end }} + +2. Default login credentials: + - Username: admin + - Password: admin123 + + - Username: user + - Password: user123 + +3. OIDC Configuration for Euler Copilot Framework: + - Client ID: {{ (index .Values.authelia.config.identity_providers.oidc.clients 0).client_id | default "euler-copilot" }} + - Client Secret: your-client-secret-here + - Authorization URL: {{ .Values.domain.authelia | default "http://127.0.0.1:30091" }}/api/oidc/authorization + - Token URL: {{ .Values.domain.authelia | default "http://127.0.0.1:30091" }}/api/oidc/token + - User Info URL: {{ .Values.domain.authelia | default "http://127.0.0.1:30091" }}/api/oidc/userinfo + +4. Important Security Notes: + - Please change all default secrets and passwords in production! + - Update the session domain to match your actual domain + - Generate new OIDC signing keys for production use + - Configure proper TLS certificates diff --git a/deploy/chart/authelia/templates/_helpers.tpl b/deploy/chart/authelia/templates/_helpers.tpl new file mode 100644 index 0000000000000000000000000000000000000000..ba3ad3e5e4000adfde7c2f5b6941674b49a0a322 --- /dev/null +++ b/deploy/chart/authelia/templates/_helpers.tpl @@ -0,0 +1,51 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "authelia.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "authelia.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "authelia.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "authelia.labels" -}} +helm.sh/chart: {{ include "authelia.chart" . }} +{{ include "authelia.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "authelia.selectorLabels" -}} +app.kubernetes.io/name: {{ include "authelia.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/chart/authelia/templates/configmap.yaml b/deploy/chart/authelia/templates/configmap.yaml new file mode 100644 index 0000000000000000000000000000000000000000..57bf2daef22ca9bf7efc428ff0f7f2b91a05589c --- /dev/null +++ b/deploy/chart/authelia/templates/configmap.yaml @@ -0,0 +1,134 @@ +{{- if .Values.authelia.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "authelia.fullname" . }}-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "authelia.labels" . | nindent 4 }} +data: + authelia.yml: | + server: + address: {{ .Values.authelia.config.server.address | default "tcp://0.0.0.0:9091/" | quote }} + {{- if .Values.authelia.config.server.tls.enabled }} + tls: + certificate: {{ .Values.authelia.config.server.tls.certificate | quote }} + key: {{ .Values.authelia.config.server.tls.key | quote }} + {{- end }} + + log: + level: {{ .Values.authelia.config.log.level | default "info" }} + format: {{ .Values.authelia.config.log.format | default "text" }} + + totp: + disable: {{ .Values.authelia.config.totp.disable | default true }} + + webauthn: + disable: {{ .Values.authelia.config.webauthn.disable | default true }} + + duo_api: + disable: {{ .Values.authelia.config.duo_api.disable | default true }} + + identity_validation: + reset_password: + jwt_secret: {{ .Values.authelia.config.identity_validation.reset_password.jwt_secret | quote }} + + authentication_backend: + password_reset: + disable: {{ .Values.authelia.config.authentication_backend.password_reset.disable }} + refresh_interval: {{ .Values.authelia.config.authentication_backend.refresh_interval }} + file: + path: {{ .Values.authelia.config.authentication_backend.file.path }} + password: + algorithm: {{ .Values.authelia.config.authentication_backend.file.password.algorithm }} + iterations: {{ .Values.authelia.config.authentication_backend.file.password.iterations }} + salt_length: {{ .Values.authelia.config.authentication_backend.file.password.salt_length }} + parallelism: {{ .Values.authelia.config.authentication_backend.file.password.parallelism }} + memory: {{ .Values.authelia.config.authentication_backend.file.password.memory }} + + access_control: + default_policy: {{ .Values.authelia.config.access_control.default_policy }} + + session: + name: {{ .Values.authelia.config.session.name | default "authelia_session" }} + domain: {{ .Values.authelia.config.session.domain | default "127.0.0.1" }} + same_site: {{ .Values.authelia.config.session.same_site | default "lax" }} + secret: {{ .Values.authelia.config.session.secret | default "insecure_session_secret_change_me_in_production" | quote }} + expiration: {{ .Values.authelia.config.session.expiration | default "1h" }} + inactivity: {{ .Values.authelia.config.session.inactivity | default "5m" }} + remember_me: {{ .Values.authelia.config.session.remember_me | default "1M" }} + + regulation: + max_retries: {{ .Values.authelia.config.regulation.max_retries }} + find_time: {{ .Values.authelia.config.regulation.find_time }} + ban_time: {{ .Values.authelia.config.regulation.ban_time }} + + storage: + encryption_key: {{ .Values.authelia.config.storage.encryption_key | quote }} + local: + path: {{ .Values.authelia.config.storage.local.path }} + + notifier: + disable_startup_check: {{ .Values.authelia.config.notifier.disable_startup_check }} + filesystem: + filename: {{ .Values.authelia.config.notifier.filesystem.filename }} + + ntp: + disable_startup_check: {{ .Values.authelia.config.ntp.disable_startup_check }} + + identity_providers: + oidc: + hmac_secret: {{ .Values.authelia.config.identity_providers.oidc.hmac_secret | quote }} + enable_client_debug_messages: {{ .Values.authelia.config.identity_providers.oidc.enable_client_debug_messages }} + jwks: + {{- range .Values.authelia.config.identity_providers.oidc.jwks }} + - key_id: {{ .key_id }} + algorithm: {{ .algorithm }} + key: | + {{- .key | nindent 14 }} + {{- end }} + clients: + {{- range .Values.authelia.config.identity_providers.oidc.clients }} + - client_id: {{ .client_id | quote }} + client_name: {{ .client_name | quote }} + client_secret: {{ .client_secret | quote }} + public: {{ .public }} + authorization_policy: {{ .authorization_policy }} + token_endpoint_auth_method: {{ .token_endpoint_auth_method }} + require_pkce: {{ .require_pkce }} + redirect_uris: + {{- range .redirect_uris }} + - {{ . | quote }} + {{- end }} + scopes: + {{- range .scopes }} + - {{ . }} + {{- end }} + response_types: + {{- range .response_types }} + - {{ . }} + {{- end }} + grant_types: + {{- range .grant_types }} + - {{ . }} + {{- end }} + response_modes: + {{- range .response_modes }} + - {{ . }} + {{- end }} + userinfo_signed_response_alg: {{ .userinfo_signed_response_alg }} + consent_mode: {{ .consent_mode }} + pre_configured_consent_duration: {{ .pre_configured_consent_duration }} + {{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "authelia.fullname" . }}-users + namespace: {{ .Release.Namespace }} + labels: + {{- include "authelia.labels" . | nindent 4 }} +data: + users.yml: | + {{- .Values.users.config | nindent 4 }} +{{- end }} diff --git a/deploy/chart/authelia/templates/deployment.yaml b/deploy/chart/authelia/templates/deployment.yaml new file mode 100644 index 0000000000000000000000000000000000000000..3b2de19d8e81147f9d9f0dadecbb3b9477e1d292 --- /dev/null +++ b/deploy/chart/authelia/templates/deployment.yaml @@ -0,0 +1,91 @@ +{{- if .Values.authelia.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "authelia.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "authelia.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.authelia.replicaCount | default (.Values.globals.replicaCount | default 1) }} + selector: + matchLabels: + {{- include "authelia.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "authelia.selectorLabels" . | nindent 8 }} + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + containers: + - name: authelia + image: "{{ .Values.authelia.image.repository | default "authelia/authelia" }}:{{ .Values.authelia.image.tag | default "4.39.13" }}" + imagePullPolicy: {{ .Values.authelia.image.pullPolicy | default (.Values.globals.imagePullPolicy | default "IfNotPresent") }} + ports: + - name: http + containerPort: 9091 + protocol: TCP + env: + - name: AUTHELIA_LOG_LEVEL + value: {{ .Values.authelia.config.log.level | default "info" | quote }} + volumeMounts: + - name: config + mountPath: /etc/authelia/authelia.yml + subPath: authelia.yml + readOnly: true + - name: users + mountPath: /etc/authelia/users.yml + subPath: users.yml + readOnly: true + {{- if .Values.authelia.persistence.enabled }} + - name: data + mountPath: /etc/authelia + {{- else }} + - name: data + mountPath: /etc/authelia + {{- end }} + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + {{- if .Values.authelia.resources }} + resources: + {{- toYaml .Values.authelia.resources | nindent 10 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "authelia.fullname" . }}-config + - name: users + configMap: + name: {{ include "authelia.fullname" . }}-users + {{- if .Values.authelia.persistence.enabled }} + - name: data + persistentVolumeClaim: + claimName: {{ include "authelia.fullname" . }}-data + {{- else }} + - name: data + emptyDir: {} + {{- end }} +{{- end }} diff --git a/deploy/chart/authelia/templates/pvc.yaml b/deploy/chart/authelia/templates/pvc.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6a9b08b4281fa6c660b554db786074165444b607 --- /dev/null +++ b/deploy/chart/authelia/templates/pvc.yaml @@ -0,0 +1,26 @@ +{{- if and .Values.authelia.enabled .Values.authelia.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "authelia.fullname" . }}-data + namespace: {{ .Release.Namespace }} + labels: + {{- include "authelia.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.authelia.persistence.accessMode | default "ReadWriteOnce" }} + resources: + requests: + storage: {{ .Values.authelia.persistence.size | default (.Values.storage.authelia | default "1Gi") }} + {{- if .Values.authelia.persistence.storageClass }} + {{- if (eq "-" .Values.authelia.persistence.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: {{ .Values.authelia.persistence.storageClass }} + {{- end }} + {{- else }} + {{- if .Values.globals.storageClass }} + storageClassName: {{ .Values.globals.storageClass }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/chart/authelia/templates/service.yaml b/deploy/chart/authelia/templates/service.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8122131fe33fa663e58f63fa7e7a931bde0acfaf --- /dev/null +++ b/deploy/chart/authelia/templates/service.yaml @@ -0,0 +1,21 @@ +{{- if .Values.authelia.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "authelia.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "authelia.labels" . | nindent 4 }} +spec: + type: {{ .Values.authelia.service.type | default "NodePort" }} + ports: + - port: {{ .Values.authelia.service.port | default 9091 }} + targetPort: http + protocol: TCP + name: http + {{- if and (eq (.Values.authelia.service.type | default "NodePort") "NodePort") (.Values.authelia.service.nodePort | default 30091) }} + nodePort: {{ .Values.authelia.service.nodePort | default 30091 }} + {{- end }} + selector: + {{- include "authelia.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/deploy/chart/authelia/values.yaml b/deploy/chart/authelia/values.yaml new file mode 100644 index 0000000000000000000000000000000000000000..09e1c5b25a697a99895dfe562c5927e02fc612d6 --- /dev/null +++ b/deploy/chart/authelia/values.yaml @@ -0,0 +1,281 @@ +# 全局设置 +globals: + # 节点架构:默认是x86 + # [必填] 节点设置:["x86", "arm"] + arch: + # 镜像拉取策略,默认为IfNotPresent + imagePullPolicy: + # 存储类;默认为local-path + storageClass: + +# 存储设置 +storage: + # Authelia数据存储大小,默认为1Gi + authelia: + +# 域名设置 +domain: + # [必填] Authelia的web前端url;默认为http://127.0.0.1:30091 + authelia: + +# Authelia 身份认证服务配置 +authelia: + # [必填] 是否启用 Authelia + enabled: true + + # 镜像配置 + image: + # 镜像仓库;默认为authelia/authelia + repository: authelia/authelia + # 镜像标签;默认为4.39.13 + tag: "4.39.13" + # 镜像拉取策略;默认继承globals.imagePullPolicy + pullPolicy: + + # 副本数量;默认为1 + replicaCount: + + # 性能限制设置 + resourceLimits: {} + + # Service设置 + service: + # Service类型,例如NodePort;默认为NodePort + type: NodePort + # 容器内部端口;默认为9091 + port: 9091 + # 当类型为NodePort时,填写主机的端口号;默认为30091 + nodePort: 30091 + + # 存储配置 + persistence: + # [必填] 是否启用持久化存储;默认为true + enabled: true + # 存储大小;默认继承storage.authelia或1Gi + size: + # 存储类;默认继承globals.storageClass + storageClass: + # 访问模式;默认为ReadWriteOnce + accessMode: ReadWriteOnce + + # 配置文件设置 + config: + # 服务器配置 + server: + # 服务监听地址;默认为tcp://0.0.0.0:9091/ + address: "tcp://0.0.0.0:9091/" + # TLS配置 + tls: + # 是否启用TLS;默认为false + enabled: false + # TLS证书路径(启用TLS时必填) + certificate: "" + # TLS私钥路径(启用TLS时必填) + key: "" + + # 日志配置 + log: + # 日志级别;默认为info,可选:trace, debug, info, warn, error + level: info + # 日志格式;默认为text,可选:text, json + format: text + + # 会话配置 + session: + # 会话cookie名称;默认为authelia_session + name: authelia_session + # [必填] 会话域名,需要与实际域名匹配;默认为127.0.0.1 + domain: "127.0.0.1" + # SameSite策略;默认为lax,可选:strict, lax, none + same_site: lax + # [必填] 会话密钥,生产环境请修改 + secret: "insecure_session_secret_change_me_in_production" + # 会话过期时间;默认为1h + expiration: 1h + # 会话非活跃超时时间;默认为5m + inactivity: 5m + # 记住我功能过期时间;默认为1M + remember_me: 1M + + # 身份验证后端配置 + authentication_backend: + # 密码重置配置 + password_reset: + # 是否禁用密码重置;默认为true + disable: true + # 刷新间隔;默认为5m + refresh_interval: 5m + # 基于文件的用户配置 + file: + # 用户文件路径;默认为/etc/authelia/users.yml + path: /etc/authelia/users.yml + # 密码加密配置 + password: + # 加密算法;默认为argon2id + algorithm: argon2id + # 迭代次数;默认为1 + iterations: 1 + # 盐长度;默认为16 + salt_length: 16 + # 并行度;默认为8 + parallelism: 8 + # 内存使用量(KB);默认为1024 + memory: 1024 + + # 访问控制配置 + access_control: + # 默认策略;默认为one_factor,可选:bypass, one_factor, two_factor, deny + default_policy: one_factor + + # 存储配置 + storage: + # [必填] 存储加密密钥,生产环境请修改 + encryption_key: "insecure_storage_encryption_key_change_me_in_production" + # 本地SQLite存储配置 + local: + # 数据库文件路径;默认为/etc/authelia/db.sqlite3 + path: /etc/authelia/db.sqlite3 + + # 通知配置 + notifier: + # 是否禁用启动检查;默认为true + disable_startup_check: true + # 基于文件系统的通知配置 + filesystem: + # 通知文件路径;默认为/etc/authelia/notification.txt + filename: /etc/authelia/notification.txt + + # NTP配置 + ntp: + # 是否禁用启动检查;默认为true + disable_startup_check: true + + # 身份验证配置 + identity_validation: + # 密码重置配置 + reset_password: + # [必填] JWT密钥,生产环境请修改 + jwt_secret: "a_very_important_secret_for_jwt_tokens_change_me_in_production" + + # 访问限制配置 + regulation: + # 最大重试次数;默认为3 + max_retries: 3 + # 查找时间窗口(秒);默认为120 + find_time: 120 + # 封禁时间(秒);默认为300 + ban_time: 300 + + # 禁用的功能配置 + totp: + # 是否禁用TOTP;默认为true + disable: true + webauthn: + # 是否禁用WebAuthn;默认为true + disable: true + duo_api: + # 是否禁用Duo API;默认为true + disable: true + + # OIDC 身份提供者配置 + identity_providers: + oidc: + # [必填] HMAC密钥,生产环境请修改 + hmac_secret: "this_is_a_secret_abc123abc123abc123" + # 是否启用客户端调试消息;默认为true + enable_client_debug_messages: true + # JWT签名密钥配置 + jwks: + - # 密钥ID;默认为main-signing-key + key_id: main-signing-key + # 签名算法;默认为RS256 + algorithm: RS256 + # [必填] RSA私钥,生产环境请使用真实的私钥 + key: | + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCssLU+8eRFwGVA + E/8BPxpneEzp0rbfM1m+osuedZ+nad8L1CQompCmx8A/hXHynUGgCxS6QJj+5qNH + ZXqSIr+JDfixDqb2CpkUMBhrG7JIYZCyLQevBR5sGPwGXMeHyTGfWI6hoSUCfbqG + Hg+40n/2shisbqUyIQiR934RaA32Wn42fqcXyzaUU0xqy55aKyXBhAojsrDT09Mv + vNJce9tO9D7e0p1n0YQJdDFVyCZzCnVWL3i+iyf5rkK9OzS7TBAiQLZWfMcCBOZC + t0XBsQj91klwp17dDWzKM9seBfB83riMRqxBoSQ2NUFU27h6eJwUpOBc4PMpORlH + BBhaaHUPAgMBAAECggEAOWOaQBpcHbAUFejvSGdDq5onmVcs7d0fWIK6f2Ugkx7U + gJZWE+ZV5w8f/RwoY5POMNUt5L29+owEPCBlzPXeSDpL6O9xHfgkqjhXjRTNuU8v + Kn9be9cMJqlg6+5eYupCYu2nrOAkMAE/gP2xhN7zprTGDVvR62hd9EBW9YrqhPE+ + gsPzRr4Gg9MRiUesS+pOFkhseb+1gvNozBRhKxRs7zm2KlFpZMK6vAgG9/ZSVCxq + SbTp7NwFJsBEuVPgEWWDexflsbBxgkQ44q5VYK5MjEf0pr0IWEhIZNM+1fUnrYV0 + fhlEfT4G/gdIcEXKvzo5r3wzr1+sdIWO+Fe9JG3ZBQKBgQDmmkxektOmBt5yEdEA + aXNJBWxTQFU/vb7tm+B+/yR3jgTn8jMPmH87gl+C8SfufbGEsOatP44k2NUVad4R + yf2xtoeWuRlPw4akLWXzZdSlRzF5Q7Y4aRxzKQWcsEdKKAOvsDsuoHOQGdeE79Xw + xNqu1c2aui0qqKVBUbXDWE/H6wKBgQC/tZqAS+umFoVYfT/4t3xnW0X8z8rJqfA0 + BmosDaKdvzkUpGYaWjsUpETqGrlVdh+OfbGlfdlMCslluhaW1GVR/tD4Ji6WWt10 + EavaBnt9xYHE153FYJqshV5eku9egussRLNzccp4cltAE3InQkYGMPRlmCGNMz2j + eh0qHwGCbQKBgBPmzSB8W3fAsAH4N6lpcGGk7ixhKPpPTeMDyOQs8ODAiPvbkzyN + VK22GrgaR+/1ORTSj5X0Hjhf3kPy1w+B9zsXHayMXPrdTQluQZY3+5ooAsUMavWD + XMkziSB0tjJYMbk/5FupzU9qa4c1i6kz1AuyuAPafXtpApoYiy9It9nxAoGBAKH9 + QNTh0ffglcJE61YtLNhk3omVx0OJ7eb3+KTKzLrAhunzVDc2QS/a8kRiWnJlQprz + iLVO1tsTTkQ/7rB6Pjb/uvHDyZ/Qncli2TR8P8Lxrgp3KuBKFchrVWdSfyL8Ot2I + G54T68LE1mgZRl73+BVpLkneN5OJVa8aEySxWGQtAoGAUnguyoO+Hs73Vyz22KLi + b5ieWQghi63GaoGHA0qeUMy5njgHKUAcuSBB9TIZGxnnDA9u0hHhOieCmhAJU21h + zoKntzIFuaZgt4Tgat72oPBw1hGE+2bN5lHmC+SsL9oxyvsH2gDFjSiLWpg7GTC6 + 2mFOM+pjK2mWWQe1ADC6tbA= + -----END PRIVATE KEY----- + # OIDC客户端配置 + clients: + - # OIDC客户端ID;默认为euler-copilot + client_id: "euler-copilot" + # 客户端显示名称;默认为Euler Copilot Framework + client_name: "Euler Copilot Framework" + # [必填] 客户端密钥,生产环境请修改 + client_secret: "$argon2id$v=19$m=65536,t=3,p=4$XYeq1N+rtYxALylzWxOxCQ$mWQaPRuYGuNa9d4rnAc7eq25HIJc0dwOYQrlEzJ792k" + # 是否为公共客户端;默认为false + public: false + # 授权策略;默认为one_factor + authorization_policy: one_factor + # Token端点认证方法;默认为client_secret_post + token_endpoint_auth_method: client_secret_post + # 是否需要PKCE;默认为false + require_pkce: false + # 重定向URI列表 + redirect_uris: + - "https://127.0.0.1/api/auth/login" + - "https://localhost/api/auth/login" + # 授权范围 + scopes: + - openid + - profile + - email + # 响应类型 + response_types: + - code + # 授权类型 + grant_types: + - authorization_code + # 响应模式 + response_modes: + - query + # 用户信息签名算法;默认为none + userinfo_signed_response_alg: none + # 同意模式;默认为implicit + consent_mode: implicit + # 预配置同意持续时间;默认为1y + pre_configured_consent_duration: 1y + +# 用户配置 +users: + # 默认用户配置文件 + config: | + users: + admin: + displayname: "Administrator" + password: "$argon2id$v=19$m=65536,t=3,p=4$XYeq1N+rtYxALylzWxOxCQ$mWQaPRuYGuNa9d4rnAc7eq25HIJc0dwOYQrlEzJ792k" # password: admin123 + email: admin@example.com + groups: + - admins + - dev + user: + displayname: "User" + password: "$argon2id$v=19$m=65536,t=3,p=4$XYeq1N+rtYxALylzWxOxCQ$mWQaPRuYGuNa9d4rnAc7eq25HIJc0dwOYQrlEzJ792k" # password: user123 + email: user@example.com + groups: + - dev \ No newline at end of file diff --git a/deploy/chart/euler_copilot/configs/framework/config-authelia.toml b/deploy/chart/euler_copilot/configs/framework/config-authelia.toml new file mode 100644 index 0000000000000000000000000000000000000000..4dec5a616c2fb9939500078171b461a4fdfc686f --- /dev/null +++ b/deploy/chart/euler_copilot/configs/framework/config-authelia.toml @@ -0,0 +1,72 @@ +[deploy] +mode = 'local' +cookie = 'domain' +data_dir = '/app/data' + +[login] +provider = 'authelia' +[login.settings] +host = '{{ .Values.domain.authelia | default "http://127.0.0.1:30091" }}' +client_id = '{{ .Values.login.authelia.client_id | default "euler-copilot" }}' +client_secret = '{{ .Values.login.authelia.client_secret | default "your-client-secret-here" }}' +redirect_uri = '{{ .Values.domain.euler_copilot | default "http://127.0.0.1:30080" }}/api/auth/login' +authorization_endpoint = '{{ .Values.domain.authelia | default "http://127.0.0.1:30091" }}/api/oidc/authorization' +token_endpoint = '{{ .Values.domain.authelia | default "http://127.0.0.1:30091" }}/api/oidc/token' +userinfo_endpoint = '{{ .Values.domain.authelia | default "http://127.0.0.1:30091" }}/api/oidc/userinfo' +scopes = 'openid profile email' + +[fastapi] +domain = '{{ regexFind "^[^:]+" (.Values.domain.euler_copilot | default "http://127.0.0.1:30080" | replace "http://" "" | replace "https://" "") }}' + +[security] +half_key1 = '${halfKey1}' +half_key2 = '${halfKey2}' +half_key3 = '${halfKey3}' +jwt_key = '${jwtKey}' + +[embedding] +type = '{{ default "openai" .Values.models.embedding.type }}' +endpoint = '{{ .Values.models.embedding.endpoint }}' +api_key = '{{ .Values.models.embedding.key }}' +model = '{{ default "bge-m3" .Values.models.embedding.name }}' + +[rag] +rag_service = 'http://rag-service.{{ .Release.Namespace }}.svc.cluster.local:9988' + +[mongodb] +host = 'mongo-db.{{ .Release.Namespace }}.svc.cluster.local' +port = 27017 +user = 'euler_copilot' +password = '${mongo-password}' +database = 'euler_copilot' + +[minio] +endpoint = 'minio-service.{{ .Release.Namespace }}.svc.cluster.local:9000' +access_key = 'minioadmin' +secret_key = '${minio-password}' +secure = false + +[llm] +endpoint = '{{ .Values.models.answer.endpoint }}' +key = '{{ .Values.models.answer.key }}' +model = '{{ .Values.models.answer.name }}' +max_tokens = {{ default 8192 .Values.models.answer.maxTokens }} +temperature = {{ default 0.7 .Values.models.answer.temperature }} + +[function_call] +backend = '{{ default "ollama" .Values.models.functionCall.backend }}' +endpoint = '{{ default .Values.models.answer.endpoint .Values.models.functionCall.endpoint }}' +model = '{{ default .Values.models.answer.name .Values.models.functionCall.name }}' +api_key = '{{ default .Values.models.answer.key .Values.models.functionCall.key }}' +max_tokens = {{ default .Values.models.answer.maxTokens .Values.models.functionCall.maxTokens }} +temperature = {{ default 0.7 .Values.models.functionCall.temperature }} + +[check] +enable = false +words_list = "" + +[sandbox] +sandbox_service = 'http://euler-copilot-sandbox-service.{{ .Release.Namespace }}.svc.cluster.local:8000' + +[extra] +sql_url = '' diff --git a/deploy/chart/euler_copilot/configs/sandbox/sandbox-config.yaml b/deploy/chart/euler_copilot/configs/sandbox/sandbox-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a591be9f5decfea90b5c6d1b5e91e678d39e30c2 --- /dev/null +++ b/deploy/chart/euler_copilot/configs/sandbox/sandbox-config.yaml @@ -0,0 +1,37 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "euler_copilot.fullname" . }}-sandbox-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "euler_copilot.labels" . | nindent 4 }} + app.kubernetes.io/component: sandbox +data: + config.py: | + # OpenEuler Intelligence Sandbox Configuration + import os + + # 服务配置 + HOST = os.getenv('SANDBOX_HOST', '0.0.0.0') + PORT = int(os.getenv('SANDBOX_PORT', '8000')) + + # 日志配置 + LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') + QUIET_MODE = os.getenv('QUIET_MODE', 'false').lower() == 'true' + + # 安全配置 + ENABLE_SECURITY = os.getenv('ENABLE_SECURITY', 'true').lower() == 'true' + MAX_EXECUTION_TIME = int(os.getenv('MAX_EXECUTION_TIME', '30')) + MAX_MEMORY_MB = int(os.getenv('MAX_MEMORY_MB', '512')) + + # 队列配置 + MAX_QUEUE_SIZE = int(os.getenv('MAX_QUEUE_SIZE', '100')) + WORKER_THREADS = int(os.getenv('WORKER_THREADS', '4')) + + # 支持的语言 + SUPPORTED_LANGUAGES = ['python', 'javascript', 'bash'] + + # 容器配置(如果使用容器执行) + CONTAINER_TIMEOUT = int(os.getenv('CONTAINER_TIMEOUT', '60')) + CONTAINER_MEMORY_LIMIT = os.getenv('CONTAINER_MEMORY_LIMIT', '512m') + CONTAINER_CPU_LIMIT = os.getenv('CONTAINER_CPU_LIMIT', '0.5') diff --git a/deploy/chart/euler_copilot/templates/framework/framework-config.yaml b/deploy/chart/euler_copilot/templates/framework/framework-config.yaml index 328803d4f537c5285baf0e8190d9a8a7d11f0237..27e5a1c55cbfc8a5657a2255f5a88351638ca0e1 100644 --- a/deploy/chart/euler_copilot/templates/framework/framework-config.yaml +++ b/deploy/chart/euler_copilot/templates/framework/framework-config.yaml @@ -6,7 +6,11 @@ metadata: namespace: {{ .Release.Namespace }} data: config.toml: |- +{{- if eq (.Values.login.provider | default "authhub") "authelia" }} +{{ tpl (.Files.Get "configs/framework/config-authelia.toml") . | indent 4 }} +{{- else }} {{ tpl (.Files.Get "configs/framework/config.toml") . | indent 4 }} +{{- end }} copy-config.yaml: |- copy: - from: /config/config.toml diff --git a/deploy/chart/euler_copilot/templates/sandbox/sandbox-config.yaml b/deploy/chart/euler_copilot/templates/sandbox/sandbox-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..3dc598f213875ce595b9a31d2c7a888c8e35e734 --- /dev/null +++ b/deploy/chart/euler_copilot/templates/sandbox/sandbox-config.yaml @@ -0,0 +1,39 @@ +{{- if .Values.euler_copilot.sandbox.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "euler_copilot.fullname" . }}-sandbox-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "euler_copilot.labels" . | nindent 4 }} + app.kubernetes.io/component: sandbox +data: + config.py: | + # OpenEuler Intelligence Sandbox Configuration + import os + + # 服务配置 + HOST = '0.0.0.0' + PORT = 8000 + + # 日志配置 + LOG_LEVEL = '{{ .Values.euler_copilot.sandbox.logLevel | default "INFO" }}' + QUIET_MODE = {{ .Values.euler_copilot.sandbox.quietMode | default false }} + + # 安全配置 + ENABLE_SECURITY = {{ .Values.euler_copilot.sandbox.security.enabled | default true }} + MAX_EXECUTION_TIME = {{ .Values.euler_copilot.sandbox.security.maxExecutionTime | default 30 }} + MAX_MEMORY_MB = {{ .Values.euler_copilot.sandbox.security.maxMemoryMB | default 512 }} + + # 队列配置 + MAX_QUEUE_SIZE = {{ .Values.euler_copilot.sandbox.queue.maxSize | default 100 }} + WORKER_THREADS = {{ .Values.euler_copilot.sandbox.queue.workerThreads | default 4 }} + + # 支持的语言 + SUPPORTED_LANGUAGES = {{ .Values.euler_copilot.sandbox.supportedLanguages | default (list "python" "javascript" "bash") | toJson }} + + # 容器配置 + CONTAINER_TIMEOUT = {{ .Values.euler_copilot.sandbox.container.timeout | default 60 }} + CONTAINER_MEMORY_LIMIT = '{{ .Values.euler_copilot.sandbox.container.memoryLimit | default "512m" }}' + CONTAINER_CPU_LIMIT = '{{ .Values.euler_copilot.sandbox.container.cpuLimit | default "0.5" }}' +{{- end }} diff --git a/deploy/chart/euler_copilot/templates/sandbox/sandbox.yaml b/deploy/chart/euler_copilot/templates/sandbox/sandbox.yaml new file mode 100644 index 0000000000000000000000000000000000000000..382ff00f1059545ea9bcbddf797cc6333d379966 --- /dev/null +++ b/deploy/chart/euler_copilot/templates/sandbox/sandbox.yaml @@ -0,0 +1,132 @@ +{{- if .Values.euler_copilot.sandbox.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "euler_copilot.fullname" . }}-sandbox-deploy + namespace: {{ .Release.Namespace }} + labels: + {{- include "euler_copilot.labels" . | nindent 4 }} + app.kubernetes.io/component: sandbox +spec: + replicas: {{ .Values.euler_copilot.sandbox.replicas | default 1 }} + selector: + matchLabels: + {{- include "euler_copilot.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: sandbox + template: + metadata: + labels: + {{- include "euler_copilot.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: sandbox + spec: + {{- if .Values.euler_copilot.sandbox.readOnly }} + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + {{- end }} + containers: + - name: sandbox + image: {{ .Values.euler_copilot.sandbox.image | default (printf "hub.oepkgs.net/neocopilot/openeuler-intelligence-sandbox:%s-%s" (.Values.euler_copilot.sandbox.version | default "latest") (.Values.globals.arch | default "x86")) }} + imagePullPolicy: {{ .Values.globals.imagePullPolicy | default "IfNotPresent" }} + ports: + - containerPort: 8000 + name: http + protocol: TCP + env: + - name: SANDBOX_HOST + value: "0.0.0.0" + - name: SANDBOX_PORT + value: "8000" + - name: LOG_LEVEL + value: {{ .Values.euler_copilot.sandbox.logLevel | default "INFO" | quote }} + - name: QUIET_MODE + value: {{ .Values.euler_copilot.sandbox.quietMode | default false | quote }} + - name: ENABLE_SECURITY + value: {{ .Values.euler_copilot.sandbox.security.enabled | default true | quote }} + - name: MAX_EXECUTION_TIME + value: {{ .Values.euler_copilot.sandbox.security.maxExecutionTime | default 30 | quote }} + - name: MAX_MEMORY_MB + value: {{ .Values.euler_copilot.sandbox.security.maxMemoryMB | default 512 | quote }} + - name: MAX_QUEUE_SIZE + value: {{ .Values.euler_copilot.sandbox.queue.maxSize | default 100 | quote }} + - name: WORKER_THREADS + value: {{ .Values.euler_copilot.sandbox.queue.workerThreads | default 4 | quote }} + - name: CONTAINER_TIMEOUT + value: {{ .Values.euler_copilot.sandbox.container.timeout | default 60 | quote }} + - name: CONTAINER_MEMORY_LIMIT + value: {{ .Values.euler_copilot.sandbox.container.memoryLimit | default "512m" | quote }} + - name: CONTAINER_CPU_LIMIT + value: {{ .Values.euler_copilot.sandbox.container.cpuLimit | default "0.5" | quote }} + {{- if .Values.euler_copilot.sandbox.readOnly }} + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + {{- end }} + volumeMounts: + - name: config + mountPath: /app/config.py + subPath: config.py + readOnly: true + {{- if .Values.euler_copilot.sandbox.readOnly }} + - name: tmp + mountPath: /tmp + - name: app-tmp + mountPath: /app/tmp + {{- end }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + {{- if .Values.euler_copilot.sandbox.resourceLimits }} + resources: + {{- toYaml .Values.euler_copilot.sandbox.resourceLimits | nindent 10 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "euler_copilot.fullname" . }}-sandbox-config + {{- if .Values.euler_copilot.sandbox.readOnly }} + - name: tmp + emptyDir: {} + - name: app-tmp + emptyDir: {} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "euler_copilot.fullname" . }}-sandbox-service + namespace: {{ .Release.Namespace }} + labels: + {{- include "euler_copilot.labels" . | nindent 4 }} + app.kubernetes.io/component: sandbox +spec: + type: {{ .Values.euler_copilot.sandbox.service.type | default "ClusterIP" }} + ports: + - port: 8000 + targetPort: http + protocol: TCP + name: http + {{- if and (eq (.Values.euler_copilot.sandbox.service.type | default "ClusterIP") "NodePort") .Values.euler_copilot.sandbox.service.nodePort }} + nodePort: {{ .Values.euler_copilot.sandbox.service.nodePort }} + {{- end }} + selector: + {{- include "euler_copilot.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: sandbox +{{- end }} diff --git a/deploy/chart/euler_copilot/values.yaml b/deploy/chart/euler_copilot/values.yaml index e3722b771c5bb34f4f894de69e0418dd2498de57..3b38bccf2ba1e3d32948ec115d2cd5d180ebe865 100644 --- a/deploy/chart/euler_copilot/values.yaml +++ b/deploy/chart/euler_copilot/values.yaml @@ -52,12 +52,22 @@ models: # 登录设置 login: - # 客户端ID设置,仅在type为authhub时有效 + # [必填] 登录提供者类型:["authhub", "authelia"] + provider: authhub + + # AuthHub客户端ID设置,仅在provider为authhub时有效 client: # [必填] 客户端ID id: # [必填] 客户端密钥 secret: + + # Authelia OIDC设置,仅在provider为authelia时有效 + authelia: + # [必填] OIDC客户端ID + client_id: euler-copilot + # [必填] OIDC客户端密钥 + client_secret: your-client-secret-here #域名设置 domain: @@ -65,6 +75,8 @@ domain: euler_copilot: # [必填] authhub的web前端url;默认为http://127.0.0.1:30081 authhub: + # [必填] authelia的web前端url;默认为http://127.0.0.1:30091 + authelia: # 存储设置 storage: @@ -153,3 +165,55 @@ euler_copilot: type: # 当类型为NodePort时,填写主机的端口号 nodePort: + + sandbox: + # [必填] 是否部署代码沙箱服务 + enabled: true + # 镜像设置;默认为hub.oepkgs.net/neocopilot/openeuler-intelligence-sandbox:latest-x86 + # 镜像标签:["latest-x86", "latest-arm"] + image: + # 镜像版本 + version: + # 副本数量 + replicas: 1 + # 容器根目录只读 + readOnly: + # 日志级别 + logLevel: INFO + # 静默模式 + quietMode: false + # 安全配置 + security: + # 启用安全限制 + enabled: true + # 最大执行时间(秒) + maxExecutionTime: 30 + # 最大内存使用(MB) + maxMemoryMB: 512 + # 队列配置 + queue: + # 最大队列大小 + maxSize: 100 + # 工作线程数 + workerThreads: 4 + # 容器配置 + container: + # 容器超时时间(秒) + timeout: 60 + # 内存限制 + memoryLimit: "512m" + # CPU限制 + cpuLimit: "0.5" + # 支持的编程语言 + supportedLanguages: + - python + - javascript + - bash + # 性能限制设置 + resourceLimits: {} + # Service设置 + service: + # Service类型,例如NodePort + type: + # 当类型为NodePort时,填写主机的端口号 + nodePort: diff --git a/deploy/scripts/0-one-click-deploy/one-click-deploy.sh b/deploy/scripts/0-one-click-deploy/one-click-deploy.sh index 9e7ff827bf6d051fab9833a2c131c26e9f1451ed..ed7203162b6883ebed1aaea2f8410f5ee21569de 100755 --- a/deploy/scripts/0-one-click-deploy/one-click-deploy.sh +++ b/deploy/scripts/0-one-click-deploy/one-click-deploy.sh @@ -239,7 +239,7 @@ start_deployment() { "../4-deploy-deepseek/deploy_deepseek.sh Deepseek模型部署 false" "../5-deploy-embedding/deploy-embedding.sh Embedding服务部署 false" "../6-install-databases/install_databases.sh 数据库集群部署 false" - "../7-install-authhub/install_authhub.sh Authhub部署 true --authhub_address ${authhub_address}" + "../7-install-auth-service/install_auth_service.sh 鉴权服务部署 true --service authhub --address ${authhub_address}" "_conditional_eulercopilot_step EulerCopilot部署 true" ) diff --git a/deploy/scripts/7-install-auth-service/install_auth_service.sh b/deploy/scripts/7-install-auth-service/install_auth_service.sh new file mode 100755 index 0000000000000000000000000000000000000000..88522a2543b9eae3f04628974d1bdb557a07be1c --- /dev/null +++ b/deploy/scripts/7-install-auth-service/install_auth_service.sh @@ -0,0 +1,343 @@ +#!/bin/bash + +set -eo pipefail + +RED='\033[31m' +GREEN='\033[32m' +YELLOW='\033[33m' +BLUE='\033[34m' +NC='\033[0m' + +SCRIPT_PATH="$( + cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 + pwd +)/$(basename "${BASH_SOURCE[0]}")" + +CHART_DIR="$( + canonical_path=$(readlink -f "$SCRIPT_PATH" 2>/dev/null || echo "$SCRIPT_PATH") + dirname "$(dirname "$(dirname "$canonical_path")")" +)/chart" + +# 全局变量 +AUTH_SERVICE="" +AUTH_ADDRESS="" + +# 打印帮助信息 +print_help() { + echo -e "${GREEN}用法: $0 [选项]" + echo -e "选项:" + echo -e " --help 显示帮助信息" + echo -e " --service <服务类型> 指定鉴权服务类型 (authhub|authelia)" + echo -e " --address <地址> 指定鉴权服务的访问地址" + echo -e "" + echo -e "示例:" + echo -e " $0 --service authhub --address http://myhost:30081" + echo -e " $0 --service authelia --address http://myhost:30091${NC}" + exit 0 +} + +# 获取系统架构 +get_architecture() { + local arch=$(uname -m) + case "$arch" in + x86_64) + arch="x86" + ;; + aarch64) + arch="arm" + ;; + *) + echo -e "${RED}错误:不支持的架构 $arch${NC}" >&2 + return 1 + ;; + esac + echo -e "${GREEN}检测到系统架构:$(uname -m)${NC}" >&2 + echo "$arch" +} + +create_namespace() { + echo -e "${BLUE}==> 检查命名空间 euler-copilot...${NC}" + if ! kubectl get namespace euler-copilot &> /dev/null; then + kubectl create namespace euler-copilot || { + echo -e "${RED}命名空间创建失败!${NC}" + return 1 + } + echo -e "${GREEN}命名空间创建成功${NC}" + else + echo -e "${YELLOW}命名空间已存在,跳过创建${NC}" + fi +} + +# 选择鉴权服务 +select_auth_service() { + if [ -n "$AUTH_SERVICE" ]; then + echo -e "${GREEN}使用参数指定的鉴权服务:$AUTH_SERVICE${NC}" + return 0 + fi + + echo -e "${BLUE}请选择要部署的鉴权服务:${NC}" + echo "1) AuthHub" + echo "2) Authelia" + echo -n "请输入选项编号(1-2): " + + local choice + read -r choice + + case $choice in + 1) + AUTH_SERVICE="authhub" + echo -e "${GREEN}选择了 AuthHub${NC}" + ;; + 2) + AUTH_SERVICE="authelia" + echo -e "${GREEN}选择了 Authelia${NC}" + ;; + *) + echo -e "${RED}无效的选项,请输入1或2${NC}" + return 1 + ;; + esac +} + +# 获取鉴权服务地址 +get_auth_address() { + local default_address + + if [ "$AUTH_SERVICE" = "authhub" ]; then + default_address="http://127.0.0.1:30081" + else + default_address="http://127.0.0.1:30091" + fi + + if [ -n "$AUTH_ADDRESS" ]; then + echo -e "${GREEN}使用参数指定的鉴权服务地址:$AUTH_ADDRESS${NC}" + return 0 + fi + + echo -e "${BLUE}请输入 ${AUTH_SERVICE} 的访问地址(IP或域名,直接回车使用默认值 ${default_address}):${NC}" + read -p "${AUTH_SERVICE} 地址: " AUTH_ADDRESS + + # 处理空输入情况 + if [[ -z "$AUTH_ADDRESS" ]]; then + AUTH_ADDRESS="$default_address" + echo -e "${GREEN}使用默认地址:${AUTH_ADDRESS}${NC}" + else + echo -e "${GREEN}输入地址:${AUTH_ADDRESS}${NC}" + fi + + return 0 +} + +# 清理现有资源 +uninstall_auth_services() { + echo -e "${BLUE}==> 清理现有鉴权服务资源...${NC}" + + local RELEASES + RELEASES=$(helm list -n euler-copilot --short | grep -E "(authhub|authelia)" || true) + + if [ -n "$RELEASES" ]; then + echo -e "${YELLOW}找到以下Helm Release,开始清理...${NC}" + for release in $RELEASES; do + echo -e "${BLUE}正在删除Helm Release: ${release}${NC}" + helm uninstall "$release" -n euler-copilot || echo -e "${RED}删除Helm Release失败,继续执行...${NC}" + done + else + echo -e "${YELLOW}未找到需要清理的鉴权服务Helm Release${NC}" + fi + + # 清理PVC + local pvc_list + pvc_list=$(kubectl get pvc -n euler-copilot | grep -E "(mysql-pvc|authelia.*data)" 2>/dev/null || true) + + if [ -n "$pvc_list" ]; then + echo -e "${YELLOW}找到以下PVC,开始清理...${NC}" + kubectl get pvc -n euler-copilot | grep -E "(mysql-pvc|authelia.*data)" | awk '{print $1}' | while read pvc; do + kubectl delete pvc "$pvc" -n euler-copilot --force --grace-period=0 || echo -e "${RED}PVC删除失败,继续执行...${NC}" + done + else + echo -e "${YELLOW}未找到需要清理的PVC${NC}" + fi + + # 清理Secrets + local secret_list=("authhub-secret") + for secret in "${secret_list[@]}"; do + if kubectl get secret "$secret" -n euler-copilot &>/dev/null; then + echo -e "${YELLOW}找到Secret: ${secret},开始清理...${NC}" + kubectl delete secret "$secret" -n euler-copilot || echo -e "${RED}删除Secret失败,继续执行...${NC}" + fi + done + + echo -e "${GREEN}资源清理完成${NC}" +} + +# 安装AuthHub +install_authhub() { + local arch="$1" + echo -e "${BLUE}==> 安装 AuthHub...${NC}" + + helm upgrade --install authhub -n euler-copilot "${CHART_DIR}/authhub" \ + --set globals.arch="$arch" \ + --set domain.authhub="${AUTH_ADDRESS}" || { + echo -e "${RED}Helm 安装 authhub 失败!${NC}" + return 1 + } + + echo -e "${GREEN}AuthHub 安装完成!${NC}" + echo -e "${GREEN}登录地址: ${AUTH_ADDRESS}${NC}" + echo -e "${GREEN}默认账号密码: administrator/changeme${NC}" +} + +# 安装Authelia +install_authelia() { + local arch="$1" + echo -e "${BLUE}==> 安装 Authelia...${NC}" + + # 提取域名/IP用于会话配置 + local domain + domain=$(echo "$AUTH_ADDRESS" | sed -E 's|^https?://([^:/]+).*|\1|') + + helm upgrade --install authelia -n euler-copilot "${CHART_DIR}/authelia" \ + --set globals.arch="$arch" \ + --set domain.authelia="$AUTH_ADDRESS" \ + --set authelia.config.session.domain="$domain" || { + echo -e "${RED}Helm 安装 authelia 失败!${NC}" + return 1 + } + + echo -e "${GREEN}Authelia 安装完成!${NC}" + echo -e "${GREEN}登录地址: ${AUTH_ADDRESS}${NC}" + echo -e "${GREEN}默认管理员账号: admin/admin123${NC}" + echo -e "${GREEN}默认用户账号: user/user123${NC}" + echo -e "${YELLOW}重要提示:生产环境请修改默认密码和密钥!${NC}" +} + +# Helm安装 +helm_install() { + local arch="$1" + echo -e "${BLUE}==> 进入部署目录...${NC}" + [ ! -d "${CHART_DIR}" ] && { + echo -e "${RED}错误:部署目录不存在 ${CHART_DIR} ${NC}" + return 1 + } + cd "${CHART_DIR}" + + case "$AUTH_SERVICE" in + "authhub") + install_authhub "$arch" + ;; + "authelia") + install_authelia "$arch" + ;; + *) + echo -e "${RED}错误:未知的鉴权服务类型 $AUTH_SERVICE${NC}" + return 1 + ;; + esac +} + +check_pods_status() { + echo -e "${BLUE}==> 等待初始化就绪(30秒)...${NC}" >&2 + sleep 30 + + local timeout=300 + local start_time=$(date +%s) + + echo -e "${BLUE}开始监控Pod状态(总超时时间300秒)...${NC}" >&2 + + while true; do + local current_time=$(date +%s) + local elapsed=$((current_time - start_time)) + + if [ $elapsed -gt $timeout ]; then + echo -e "${YELLOW}警告:部署超时!请检查以下资源:${NC}" >&2 + kubectl get pods -n euler-copilot -o wide + echo -e "\n${YELLOW}建议检查:${NC}" + echo "1. 查看未就绪Pod的日志: kubectl logs -n euler-copilot " + echo "2. 检查PVC状态: kubectl get pvc -n euler-copilot" + echo "3. 检查Service状态: kubectl get svc -n euler-copilot" + return 1 + fi + + local not_running=$(kubectl get pods -n euler-copilot -o jsonpath='{range .items[*]}{.metadata.name} {.status.phase} {.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}' \ + | awk '$2 != "Running" || $3 != "True" {print $1 " " $2}') + + if [ -z "$not_running" ]; then + echo -e "${GREEN}所有Pod已正常运行!${NC}" >&2 + kubectl get pods -n euler-copilot -o wide + return 0 + else + echo "等待Pod就绪(已等待 ${elapsed} 秒)..." + echo "当前未就绪Pod:" + echo "$not_running" | awk '{print " - " $1 " (" $2 ")"}' + sleep 10 + fi + done +} + +deploy() { + local arch + arch=$(get_architecture) || exit 1 + create_namespace || exit 1 + uninstall_auth_services || exit 1 + + # 选择鉴权服务 + select_auth_service || exit 1 + + # 获取鉴权服务地址 + get_auth_address || exit 1 + + helm_install "$arch" || exit 1 + check_pods_status || { + echo -e "${RED}部署失败:Pod状态检查未通过!${NC}" + exit 1 + } + + echo -e "\n${GREEN}=========================" + echo -e "鉴权服务 ($AUTH_SERVICE) 部署完成!" + echo -e "查看pod状态:kubectl get pod -n euler-copilot" + echo -e "服务访问地址: $AUTH_ADDRESS" + echo -e "=========================${NC}" +} + +# 解析命令行参数 +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --help) + print_help + exit 0 + ;; + --service) + if [ -n "$2" ] && [[ "$2" =~ ^(authhub|authelia)$ ]]; then + AUTH_SERVICE="$2" + shift 2 + else + echo -e "${RED}错误:--service 需要提供有效参数 (authhub|authelia)${NC}" >&2 + exit 1 + fi + ;; + --address) + if [ -n "$2" ]; then + AUTH_ADDRESS="$2" + shift 2 + else + echo -e "${RED}错误:--address 需要提供一个参数${NC}" >&2 + exit 1 + fi + ;; + *) + echo -e "${RED}未知参数: $1${NC}" >&2 + print_help + exit 1 + ;; + esac + done +} + +main() { + parse_args "$@" + deploy +} + +trap 'echo -e "${RED}操作被中断!${NC}"; exit 1' INT +main "$@" diff --git a/deploy/scripts/7-install-authhub/install_authhub.sh b/deploy/scripts/7-install-auth-service/install_authhub.sh similarity index 100% rename from deploy/scripts/7-install-authhub/install_authhub.sh rename to deploy/scripts/7-install-auth-service/install_authhub.sh diff --git a/deploy/scripts/9-other-script/deploy_with_authelia.sh b/deploy/scripts/9-other-script/deploy_with_authelia.sh new file mode 100755 index 0000000000000000000000000000000000000000..a1480b777f2dcaedfac37bb5547475a88d5a553f --- /dev/null +++ b/deploy/scripts/9-other-script/deploy_with_authelia.sh @@ -0,0 +1,227 @@ +#!/bin/bash + +set -eo pipefail + +RED='\033[31m' +GREEN='\033[32m' +YELLOW='\033[33m' +BLUE='\033[34m' +NC='\033[0m' + +SCRIPT_PATH="$( + cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 + pwd +)/$(basename "${BASH_SOURCE[0]}")" + +CHART_DIR="$( + canonical_path=$(readlink -f "$SCRIPT_PATH" 2>/dev/null || echo "$SCRIPT_PATH") + dirname "$(dirname "$(dirname "$canonical_path")")" +)/chart" + +# 打印帮助信息 +print_help() { + echo -e "${GREEN}用法: $0 [选项]" + echo -e "选项:" + echo -e " --help 显示帮助信息" + echo -e " --authelia_address <地址> 指定Authelia的访问地址(例如:http://myhost:30091)" + echo -e " --euler_address <地址> 指定EulerCopilot的访问地址(例如:http://myhost:30080)" + echo -e "" + echo -e "示例:" + echo -e " $0 --authelia_address http://myhost:30091 --euler_address http://myhost:30080${NC}" + exit 0 +} + +# 获取系统架构 +get_architecture() { + local arch=$(uname -m) + case "$arch" in + x86_64) + arch="x86" + ;; + aarch64) + arch="arm" + ;; + *) + echo -e "${RED}错误:不支持的架构 $arch${NC}" >&2 + return 1 + ;; + esac + echo -e "${GREEN}检测到系统架构:$(uname -m)${NC}" >&2 + echo "$arch" +} + +create_namespace() { + echo -e "${BLUE}==> 检查命名空间 euler-copilot...${NC}" + if ! kubectl get namespace euler-copilot &> /dev/null; then + kubectl create namespace euler-copilot || { + echo -e "${RED}命名空间创建失败!${NC}" + return 1 + } + echo -e "${GREEN}命名空间创建成功${NC}" + else + echo -e "${YELLOW}命名空间已存在,跳过创建${NC}" + fi +} + +# 部署Authelia +deploy_authelia() { + local arch="$1" + local authelia_address="$2" + + echo -e "${BLUE}==> 部署 Authelia...${NC}" + + # 提取域名/IP用于会话配置 + local domain + domain=$(echo "$authelia_address" | sed -E 's|^https?://([^:/]+).*|\1|') + + cd "${CHART_DIR}" + helm upgrade --install authelia -n euler-copilot ./authelia \ + --set globals.arch="$arch" \ + --set domain.authelia="$authelia_address" \ + --set authelia.config.session.domain="$domain" || { + echo -e "${RED}Authelia 部署失败!${NC}" + return 1 + } + + echo -e "${GREEN}Authelia 部署完成!${NC}" +} + +# 部署EulerCopilot Framework with Authelia +deploy_euler_copilot() { + local arch="$1" + local authelia_address="$2" + local euler_address="$3" + + echo -e "${BLUE}==> 部署 EulerCopilot Framework (使用Authelia鉴权)...${NC}" + + cd "${CHART_DIR}" + + # 这里需要用户提供实际的模型配置 + echo -e "${YELLOW}注意:请确保已正确配置模型相关参数${NC}" + + helm upgrade --install euler-copilot -n euler-copilot ./euler_copilot \ + --set globals.arch="$arch" \ + --set login.provider="authelia" \ + --set login.authelia.client_id="euler-copilot" \ + --set login.authelia.client_secret="your-client-secret-here" \ + --set domain.euler_copilot="$euler_address" \ + --set domain.authelia="$authelia_address" \ + --set euler_copilot.sandbox.enabled=true \ + --set models.answer.endpoint="http://localhost:11434" \ + --set models.answer.key="ollama" \ + --set models.answer.name="qwen2.5:14b" \ + --set models.embedding.type="openai" \ + --set models.embedding.endpoint="http://localhost:11434" \ + --set models.embedding.key="ollama" \ + --set models.embedding.name="bge-m3" || { + echo -e "${RED}EulerCopilot Framework 部署失败!${NC}" + return 1 + } + + echo -e "${GREEN}EulerCopilot Framework 部署完成!${NC}" +} + +check_pods_status() { + echo -e "${BLUE}==> 等待初始化就绪(30秒)...${NC}" >&2 + sleep 30 + + local timeout=300 + local start_time=$(date +%s) + + echo -e "${BLUE}开始监控Pod状态(总超时时间300秒)...${NC}" >&2 + + while true; do + local current_time=$(date +%s) + local elapsed=$((current_time - start_time)) + + if [ $elapsed -gt $timeout ]; then + echo -e "${YELLOW}警告:部署超时!请检查以下资源:${NC}" >&2 + kubectl get pods -n euler-copilot -o wide + return 1 + fi + + local not_running=$(kubectl get pods -n euler-copilot -o jsonpath='{range .items[*]}{.metadata.name} {.status.phase} {.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}' \ + | awk '$2 != "Running" || $3 != "True" {print $1 " " $2}') + + if [ -z "$not_running" ]; then + echo -e "${GREEN}所有Pod已正常运行!${NC}" >&2 + kubectl get pods -n euler-copilot -o wide + return 0 + else + echo "等待Pod就绪(已等待 ${elapsed} 秒)..." + echo "当前未就绪Pod:" + echo "$not_running" | awk '{print " - " $1 " (" $2 ")"}' + sleep 10 + fi + done +} + +deploy() { + local arch + arch=$(get_architecture) || exit 1 + + local authelia_address="${authelia_address:-http://127.0.0.1:30091}" + local euler_address="${euler_address:-http://127.0.0.1:30080}" + + create_namespace || exit 1 + deploy_authelia "$arch" "$authelia_address" || exit 1 + deploy_euler_copilot "$arch" "$authelia_address" "$euler_address" || exit 1 + check_pods_status || { + echo -e "${RED}部署失败:Pod状态检查未通过!${NC}" + exit 1 + } + + echo -e "\n${GREEN}=========================" + echo -e "Euler Copilot Framework (Authelia版) 部署完成!" + echo -e "Authelia 登录地址: $authelia_address" + echo -e "EulerCopilot 访问地址: $euler_address" + echo -e "默认Authelia账号: admin/admin123 或 user/user123" + echo -e "=========================${NC}" + echo -e "\n${YELLOW}重要提示:${NC}" + echo -e "1. 生产环境请修改Authelia的默认密码和密钥" + echo -e "2. 请确保模型服务已正确部署和配置" + echo -e "3. 首次访问需要通过Authelia进行身份验证" +} + +# 解析命令行参数 +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --help) + print_help + exit 0 + ;; + --authelia_address) + if [ -n "$2" ]; then + authelia_address="$2" + shift 2 + else + echo -e "${RED}错误:--authelia_address 需要提供一个参数${NC}" >&2 + exit 1 + fi + ;; + --euler_address) + if [ -n "$2" ]; then + euler_address="$2" + shift 2 + else + echo -e "${RED}错误:--euler_address 需要提供一个参数${NC}" >&2 + exit 1 + fi + ;; + *) + echo -e "${RED}未知参数: $1${NC}" >&2 + print_help + exit 1 + ;; + esac + done +} + +main() { + parse_args "$@" + deploy +} + +trap 'echo -e "${RED}操作被中断!${NC}"; exit 1' INT +main "$@" diff --git a/deploy/scripts/9-other-script/get_client_id_and_secret.py b/deploy/scripts/9-other-script/get_client_id_and_secret.py index 901856c69130b30699b01e88c95a29523cd7e006..c8796d89b813068f4a45dfe634dd8ecea06fec30 100755 --- a/deploy/scripts/9-other-script/get_client_id_and_secret.py +++ b/deploy/scripts/9-other-script/get_client_id_and_secret.py @@ -78,6 +78,7 @@ def register_or_update_app(authhub_web_url, user_token, client_name, client_url, response = requests.put( url, json={ + "client_name": client_name, "client_uri": client_url, "redirect_uris": redirect_urls, "register_callback_uris": [], diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index 1b4d69ceeb404832455fd792d2a39dbb05f2e94b..2ba879b7bc647b139830083e67fb31eee3b576d5 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -28,7 +28,7 @@ show_sub_menu() { echo "4) 部署Deepseek模型" echo "5) 部署Embedding模型" echo "6) 安装数据库" - echo "7) 安装AuthHub" + echo "7) 安装鉴权服务" echo "8) 安装EulerCopilot" echo "9) 返回主菜单" echo "==============================" @@ -95,7 +95,7 @@ run_sub_script() { run_script_with_check "./6-install-databases/install_databases.sh" "数据库安装脚本" ;; 7) - run_script_with_check "./7-install-authhub/install_authhub.sh" "AuthHub安装脚本" + run_script_with_check "./7-install-auth-service/install_auth_service.sh" "鉴权服务安装脚本" ;; 8) run_script_with_check "./8-install-EulerCopilot/install_eulercopilot.sh" "EulerCopilot安装脚本"