Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ mcp-server -> (独立应用,无内部模块依赖)
- **模型路由与容错**:Chat / Embedding / Rerank 均为候选模型配置驱动,含优先级、失败阈值、熔断恢复;供应商差异留在 infra-ai
- **入库管线**:文档入库为 `IngestionNode` 节点编排 Pipeline(解析→增强→分块→向量化→写库)

## 配置与凭据

application.yaml 携带本地开发默认凭据,生产部署必须覆盖。`ProductionCredentialGuard`(`bootstrap` 模块,`EnvironmentPostProcessor`,Boot 3 机制注册)在启动最前置阶段执行 fail-fast:

- **放行条件**:未显式激活任何 profile,或激活 profile 集合含 `local` / `dev` / `test` 任一。本地直接启动(无 profile)行为保持不变。
- **检查时机**:其他 profile(如 `prod`)下,对以下敏感键检查生效值,命中开发默认值即抛 `IllegalStateException` 中断启动(消息只含键名,不含值):
- `spring.datasource.username`
- `spring.datasource.password`
- `spring.data.redis.password`
- `rag.storage.s3.access-key`
- `rag.storage.s3.secret-key`
- **占位符检查**:值以 `${` 开头且无默认值时(如 `${DB_PASSWORD}`),若环境变量/secret 无法解析同样中断启动;`${KEY:默认值}` 的默认值命中开发默认凭据集合也会中断。
- **模型 API key 走环境变量属正向机制**(`BAILIAN_API_KEY`、`SILICONFLOW_API_KEY`、`AIHUBMIX_API_KEY`、`MINERU_API_KEY`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY`、`YDC_API_KEY` 等),不参与守卫检查,但生产必须提供。

生产部署的完整键清单与示例见 `docs/production-configuration.md`。密钥轮换属部署侧运维,守卫不参与;轮换时保证新值先注入、旧值下线,避免空窗期。

## 扩展点

按 Spring Bean 自动发现,新增能力优先走扩展点而非改核心分发逻辑:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.nageoffer.ai.ragent.config;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;

import java.util.Arrays;
import java.util.Map;
import java.util.Set;

/**
* 生产 profile 下的开发默认凭据 fail-fast 守卫。
*
* <p>application.yaml 携带一组本地开发默认凭据(postgres / 123456 / rustfsadmin 等)。未显式激活
* profile 或激活集合含 local / dev / test 时完全放行,本地直接启动行为不变;其他 profile(如 prod)
* 下检查敏感键的生效值:命中已知开发默认值、或占位符无默认值且无法解析时抛
* {@link IllegalStateException} 中断启动,避免弱凭据静默上线。</p>
*
* <p>通过 {@code META-INF/spring.factories} 注册(EnvironmentPostProcessor 在 Spring Boot 3 只从
* spring.factories 加载,不走 auto-configuration 的 .imports 机制),排到
* {@link Ordered#LOWEST_PRECEDENCE} 保证晚于 ConfigDataEnvironmentPostProcessor,能读到
* application.yaml 与 profile 解析结果。</p>
*
* <p>异常消息只含键名与提示,绝不包含配置值。</p>
*/
public class ProductionCredentialGuard implements EnvironmentPostProcessor, Ordered {

private static final Set<String> DEV_PROFILES = Set.of("local", "dev", "test");

private static final Map<String, Set<String>> DEV_DEFAULT_CREDENTIALS = Map.of(
"spring.datasource.username", Set.of("postgres", "root"),
"spring.datasource.password", Set.of("postgres", "123456", "root", "password"),
"spring.data.redis.password", Set.of("123456", "password", "redis"),
"rag.storage.s3.access-key", Set.of("rustfsadmin", "minioadmin"),
"rag.storage.s3.secret-key", Set.of("rustfsadmin", "minioadmin"));

@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
if (isDevLaunch(environment)) {
return;
}
DEV_DEFAULT_CREDENTIALS.forEach((key, defaults) -> check(environment, key, defaults));
}

@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}

private boolean isDevLaunch(ConfigurableEnvironment environment) {
String active = environment.getProperty("spring.profiles.active");
if (active == null || active.isBlank()) {
return true;
}
return Arrays.stream(active.split(","))
.map(String::trim)
.filter(profile -> !profile.isEmpty())
.anyMatch(DEV_PROFILES::contains);
}

private void check(ConfigurableEnvironment environment, String key, Set<String> devDefaults) {
String value;
try {
value = environment.getProperty(key);
} catch (IllegalArgumentException e) {
throw new IllegalStateException(
"配置键 [" + key + "] 的占位符未配置且无默认值,请通过环境变量或 secret 覆盖后重启");
}
if (value != null && devDefaults.contains(value)) {
throw new IllegalStateException(
"配置键 [" + key + "] 检测到开发默认值,请通过环境变量或 secret 覆盖后重启");
}
}
}
3 changes: 2 additions & 1 deletion bootstrap/src/main/resources/META-INF/spring.factories
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 检索通道配置一致性校验:启动最前置的环境后置处理器(EnvironmentPostProcessor 仅从 spring.factories 加载,不走 .imports)
org.springframework.boot.env.EnvironmentPostProcessor=\
com.nageoffer.ai.ragent.rag.config.validation.RetrievalConfigEnvironmentPostProcessor
com.nageoffer.ai.ragent.rag.config.validation.RetrievalConfigEnvironmentPostProcessor,\
com.nageoffer.ai.ragent.config.ProductionCredentialGuard

# 检索通道配置矛盾的启动诊断器(把 RetrievalConfigException 渲染成 APPLICATION FAILED TO START 框)
org.springframework.boot.diagnostics.FailureAnalyzer=\
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.nageoffer.ai.ragent.config;

import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ProductionCredentialGuardTest {

private final ProductionCredentialGuard guard = new ProductionCredentialGuard();

@Test
void noProfileAllowsDevDefaults() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.datasource.password", "postgres")
.withProperty("spring.data.redis.password", "123456");
assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
}

@Test
void devProfileAllowsDevDefaults() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.profiles.active", "dev")
.withProperty("spring.datasource.password", "postgres");
assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
}

@Test
void localProfileAllowsDevDefaults() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.profiles.active", "local")
.withProperty("rag.storage.s3.secret-key", "rustfsadmin");
assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
}

@Test
void prodProfileWithDevCredentialFails() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.profiles.active", "prod")
.withProperty("spring.datasource.password", "postgres");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> guard.postProcessEnvironment(environment, null));
assertTrue(ex.getMessage().contains("spring.datasource.password"));
assertFalse(ex.getMessage().contains("postgres"));
}

@Test
void prodProfileWithEnvOverridePasses() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.profiles.active", "prod")
.withProperty("spring.datasource.password", "S3cret-Override!");
assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
}

@Test
void prodProfileWithUnresolvablePlaceholderFails() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.profiles.active", "prod")
.withProperty("spring.datasource.password", "${DB_PASSWORD}");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> guard.postProcessEnvironment(environment, null));
assertTrue(ex.getMessage().contains("spring.datasource.password"));
assertFalse(ex.getMessage().contains("DB_PASSWORD"));
}

@Test
void prodProfileWithResolvablePlaceholderPasses() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.profiles.active", "prod")
.withProperty("DB_PASSWORD", "S3cret-Override!")
.withProperty("spring.datasource.password", "${DB_PASSWORD}");
assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
}

@Test
void prodProfileWithWeakPlaceholderDefaultFails() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.profiles.active", "prod")
.withProperty("spring.datasource.password", "${DB_PASSWORD:postgres}");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> guard.postProcessEnvironment(environment, null));
assertTrue(ex.getMessage().contains("spring.datasource.password"));
assertFalse(ex.getMessage().contains("postgres"));
}

@Test
void prodProfileWithEmptyPlaceholderDefaultPasses() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.profiles.active", "prod")
.withProperty("spring.datasource.password", "${DB_PASSWORD:}");
assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
}
}
64 changes: 64 additions & 0 deletions docs/production-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 生产部署配置说明

本文档说明 fork 在生产 profile 下的凭据覆盖要求与 fail-fast 守卫行为。所有示例均使用占位符,不包含真实凭据。

## 1. 配置来源与开发默认值

`bootstrap/src/main/resources/application.yaml` 为本地开发默认配置,包含一组开发默认凭据(本地数据库口令、对象存储访问密钥等)。这些值仅用于本地起服务,生产环境必须覆盖。

模型供应商 API key(百炼、SiliconFlow、AIHubMix、MinerU、OSS、You.com 等)一律通过环境变量注入(如 `BAILIAN_API_KEY`、`SILICONFLOW_API_KEY`),yaml 中只保留 `${XXX_API_KEY:}` 占位符,属正向机制,不参与守卫检查。

## 2. Fail-fast 守卫机制

`ProductionCredentialGuard`(`com.nageoffer.ai.ragent.config`)是 Spring Boot 3 `EnvironmentPostProcessor`,通过 `bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor` 注册,在应用启动最前置阶段(早于任何 bean 与连接建立)执行:

| 场景 | 行为 |
|:---|:---|
| 未激活任何 profile | 放行(本地直接启动体验不变) |
| 激活集合含 `local` / `dev` / `test` 任一 | 放行 |
| 其他 profile(如 `prod`)且生效值命中开发默认凭据 | 启动失败 |
| 其他 profile 且占位符无默认值、无法从环境变量解析 | 启动失败 |
| 其他 profile 且 `${KEY:默认值}` 的默认值命中开发默认凭据 | 启动失败 |

失败消息只含配置键名与提示("请通过环境变量或 secret 覆盖"),不输出任何配置值。

## 3. 生产必须覆盖的键

启动 profile 为生产(非 local/dev/test)时,以下键的生效值不得命中开发默认凭据集合:

| 键 | 环境变量注入方式(示例) |
|:---|:---|
| `spring.datasource.username` | `SPRING_DATASOURCE_USERNAME` |
| `spring.datasource.password` | `SPRING_DATASOURCE_PASSWORD` |
| `spring.data.redis.password` | `SPRING_DATA_REDIS_PASSWORD` |
| `rag.storage.s3.access-key` | `RAG_STORAGE_S3_ACCESS_KEY` |
| `rag.storage.s3.secret-key` | `RAG_STORAGE_S3_SECRET_KEY` |

Spring Boot 的宽松绑定会把环境变量名映射到对应键;也可以使用 `--spring.datasource.password=...` 命令行参数或外部化配置文件。

生产还应根据部署形态覆盖非凭据项(不触发守卫,但影响正确性):数据源 URL、Redis host/port、S3 endpoint、`rag.vector.type`、`rag.graph.type` 等。

## 4. 启动示例

```bash
# 生产 profile 启动(凭据来自环境变量,示例占位符)
export SPRING_PROFILES_ACTIVE=prod
export SPRING_DATASOURCE_USERNAME='<db-user>'
export SPRING_DATASOURCE_PASSWORD='<db-password>'
export SPRING_DATA_REDIS_PASSWORD='<redis-password>'
export RAG_STORAGE_S3_ACCESS_KEY='<access-key>'
export RAG_STORAGE_S3_SECRET_KEY='<secret-key>'
export BAILIAN_API_KEY='<bailian-key>'
export SILICONFLOW_API_KEY='<siliconflow-key>'
./mvnw -B -ntp -pl bootstrap spring-boot:run
```

若任一敏感键仍为开发默认值,启动会立即失败并提示对应键名,不会带弱凭据上线。

## 5. 密钥轮换提醒

守卫只在启动时检查,不参与运行期密钥轮换:

- 轮换采用"先注入新值、验证生效、再下线旧值"的顺序,避免空窗期;
- 环境变量/secret 变更后需重启应用(或在支持动态刷新的场景单独处理);
- 数据库、Redis、对象存储的凭据轮换建议与各自平台的密钥管理策略配合(Vault、KMS 等),本项目不做配置加密与密钥托管。
Loading