Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ repos:
types: [rust]
pass_filenames: false

- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.37.4
hooks:
- id: check-jsonschema
name: Validate Render Blueprint
files: ^(render\.yaml|schemas/render-blueprint\.schema\.json)$
pass_filenames: false
args: [--schemafile, schemas/render-blueprint.schema.json, render.yaml]

ci:
skip: [rustfmt]
autofix_commit_msg: '[pre-commit.ci] Auto format from pre-commit.com hooks'
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ rmcp-reqwest = { package = "reqwest", version = "0.13.2", default-features = fal
rmcp = { version = "1.8", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yml = "0.0.12"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ uv pip install -r docs/requirements.txt
uv run mkdocs serve
```

For deployment, see the [production container](docs/deploying/container.md) and
[Render Blueprint](docs/deploying/render.md) guides.

Design and migration decisions are tracked as ADRs in [docs/adr/](docs/adr/), with deeper design notes in [docs/design/](docs/design/). See the full [ROADMAP](ROADMAP.md) for where the project is heading, and [CONTRIBUTING](CONTRIBUTING.md) to get involved.

## 🗺️ Roadmap at a Glance
Expand Down
2 changes: 1 addition & 1 deletion crates/agentic-server-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ uuid = { version = "1", features = ["v7", "serde"] }
axum.workspace = true
criterion = { workspace = true }
serde_yaml = "0.9"
serde_yml = "0.0.12"
serde_yml.workspace = true
tokio = { workspace = true, features = ["full"] }

[[bench]]
Expand Down
1 change: 1 addition & 0 deletions crates/agentic-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ criterion.workspace = true
futures.workspace = true
reqwest = { workspace = true, features = ["json"] }
serde_json.workspace = true
serde_yml.workspace = true
tokio = { workspace = true, features = ["test-util"] }
tokio-tungstenite.workspace = true
uuid = { version = "1", features = ["v7"] }
Expand Down
12 changes: 11 additions & 1 deletion crates/agentic-server/src/handler/http/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,17 @@ pub async fn ready(State(state): State<AppState>) -> impl IntoResponse {
return StatusCode::SERVICE_UNAVAILABLE;
};

match client.get(&url).send().await {
let mut request = client.get(&url);
if let Some(key) = state
.openai_api_key
.as_deref()
.map(str::trim)
.filter(|key| !key.is_empty())
{
request = request.bearer_auth(key);
}

match request.send().await {
Ok(resp) if resp.status().is_success() => StatusCode::OK,
Ok(resp) => {
warn!("LLM backend not ready: {}", resp.status());
Expand Down
5 changes: 5 additions & 0 deletions crates/agentic-server/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
use std::time::Duration;

pub mod app;
pub mod handler;

/// Maximum time allowed for in-flight requests and `WebSockets` to drain during shutdown.
pub const GATEWAY_DRAIN_TIMEOUT: Duration = Duration::from_secs(8);
4 changes: 1 addition & 3 deletions crates/agentic-server/src/server.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;

use agentic_core::config::Config;
use agentic_core::error::Error;
use agentic_core::executor::ExecutionContext;
use agentic_core::proxy::ProxyState;
use agentic_core::readiness::wait_llm_ready;
use agentic_server::GATEWAY_DRAIN_TIMEOUT;
use agentic_server::app::{AppState, ServerConfig, WebSocketTracker, build_router};
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};

const GATEWAY_DRAIN_TIMEOUT: Duration = Duration::from_secs(8);

async fn build_state(config: &Config, shutdown_token: CancellationToken) -> Result<AppState, Error> {
let proxy_state = ProxyState::new(config.clone())?;
let exec_ctx = Arc::new(ExecutionContext::from_config(config).await?);
Expand Down
54 changes: 45 additions & 9 deletions crates/agentic-server/tests/health_test.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
mod common;

use agentic_core::config::Config;
use axum::Router;
use axum::http::HeaderMap;
use axum::routing::get;
use common::{spawn_gateway, spawn_mock_llm, test_config, test_state};
use http::StatusCode;
use tokio::net::TcpListener;

fn test_config_no_key(llm_url: &str) -> Config {
Config {
Expand All @@ -10,6 +15,33 @@ fn test_config_no_key(llm_url: &str) -> Config {
}
}

async fn spawn_health_mock(status: StatusCode) -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().route("/health", get(move || async move { status }));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), handle)
}

async fn spawn_authenticated_health_mock() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().route(
"/health",
get(|headers: HeaderMap| async move {
match headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
{
Some("Bearer test-key") => StatusCode::OK,
_ => StatusCode::UNAUTHORIZED,
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), handle)
}

#[tokio::test]
async fn test_health_returns_200() {
let (llm_url, _h1) = spawn_mock_llm().await;
Expand All @@ -20,10 +52,8 @@ async fn test_health_returns_200() {

#[tokio::test]
async fn test_health_returns_200_even_when_llm_down() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let dead_addr = listener.local_addr().unwrap();
drop(listener);
let (gw_url, _h2) = spawn_gateway(test_state(&test_config_no_key(&format!("http://{dead_addr}")))).await;
let (llm_url, _h1) = spawn_health_mock(StatusCode::SERVICE_UNAVAILABLE).await;
let (gw_url, _h2) = spawn_gateway(test_state(&test_config_no_key(&llm_url))).await;
let resp = reqwest::get(format!("{gw_url}/health")).await.unwrap();
assert_eq!(resp.status(), 200);
}
Expand All @@ -37,11 +67,17 @@ async fn test_ready_returns_200_when_llm_healthy() {
}

#[tokio::test]
async fn test_ready_returns_503_when_llm_unreachable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let dead_addr = listener.local_addr().unwrap();
drop(listener);
let (gw_url, _h2) = spawn_gateway(test_state(&test_config_no_key(&format!("http://{dead_addr}")))).await;
async fn test_ready_returns_503_when_llm_unhealthy() {
let (llm_url, _h1) = spawn_health_mock(StatusCode::SERVICE_UNAVAILABLE).await;
let (gw_url, _h2) = spawn_gateway(test_state(&test_config_no_key(&llm_url))).await;
let resp = reqwest::get(format!("{gw_url}/ready")).await.unwrap();
assert_eq!(resp.status(), 503);
}

#[tokio::test]
async fn test_ready_forwards_configured_api_key() {
let (llm_url, _h1) = spawn_authenticated_health_mock().await;
let (gw_url, _h2) = spawn_gateway(test_state(&test_config(&llm_url))).await;
let resp = reqwest::get(format!("{gw_url}/ready")).await.unwrap();
assert_eq!(resp.status(), 200);
}
147 changes: 147 additions & 0 deletions crates/agentic-server/tests/render_blueprint_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
use std::fs;
use std::path::PathBuf;

use serde::Deserialize;

use agentic_server::GATEWAY_DRAIN_TIMEOUT;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Blueprint {
projects: Vec<Project>,
}

#[derive(Debug, Deserialize)]
struct Project {
name: String,
environments: Vec<Environment>,
}

#[derive(Debug, Deserialize)]
struct Environment {
name: String,
networking: Networking,
services: Vec<Service>,
databases: Vec<Database>,
}

#[derive(Debug, Deserialize)]
struct Networking {
isolation: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Service {
name: String,
#[serde(rename = "type")]
kind: String,
runtime: String,
plan: String,
dockerfile_path: String,
docker_context: String,
auto_deploy_trigger: String,
health_check_path: Option<String>,
max_shutdown_delay_seconds: u16,
env_vars: Vec<EnvVar>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EnvVar {
key: String,
value: Option<String>,
sync: Option<bool>,
from_database: Option<DatabaseReference>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DatabaseReference {
name: String,
property: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Database {
name: String,
plan: String,
ip_allow_list: Vec<serde_yml::Value>,
}

fn load_blueprint() -> Blueprint {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../render.yaml");
let yaml = fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
serde_yml::from_str(&yaml).unwrap_or_else(|error| panic!("parse {}: {error}", path.display()))
}

fn env_var<'a>(service: &'a Service, key: &str) -> &'a EnvVar {
service
.env_vars
.iter()
.find(|env_var| env_var.key == key)
.unwrap_or_else(|| panic!("{key} must be configured"))
}

#[test]
fn render_blueprint_wires_the_gateway_to_managed_postgres() {
let blueprint = load_blueprint();
let [project] = blueprint.projects.as_slice() else {
panic!("blueprint must define exactly one project");
};
let [environment] = project.environments.as_slice() else {
panic!("project must define exactly one environment");
};
let [service] = environment.services.as_slice() else {
panic!("blueprint must define exactly one service");
};
let [database] = environment.databases.as_slice() else {
panic!("blueprint must define exactly one database");
};

assert_eq!(project.name, "agentic-api");
assert_eq!(environment.name, "production");
assert_eq!(environment.networking.isolation, "enabled");

assert_eq!(service.name, "agentic-api");
assert_eq!(service.kind, "pserv");
assert_eq!(service.runtime, "docker");
assert_eq!(service.plan, "starter");
assert_eq!(service.dockerfile_path, "./Dockerfile");
assert_eq!(service.docker_context, ".");
assert_eq!(service.auto_deploy_trigger, "checksPass");
assert_eq!(
service.health_check_path, None,
"private services only support TCP health checks"
);
assert!(u64::from(service.max_shutdown_delay_seconds) > GATEWAY_DRAIN_TIMEOUT.as_secs());

assert_eq!(env_var(service, "GATEWAY_PORT").value.as_deref(), Some("9000"));

let llm_api_base = env_var(service, "LLM_API_BASE");
assert_eq!(llm_api_base.sync, Some(false));
assert_eq!(llm_api_base.value, None);

let upstream_key = env_var(service, "OPENAI_API_KEY");
assert_eq!(upstream_key.sync, Some(false));
assert_eq!(upstream_key.value, None);

let skip_llm_ready_check = env_var(service, "SKIP_LLM_READY_CHECK");
assert_eq!(skip_llm_ready_check.sync, Some(false));
assert_eq!(skip_llm_ready_check.value, None);

let database_url = env_var(service, "DATABASE_URL")
.from_database
.as_ref()
.expect("DATABASE_URL must reference managed Postgres");
assert_eq!(database_url.name, database.name);
assert_eq!(database_url.property, "connectionString");

assert_eq!(database.name, "agentic-api-postgres");
assert_eq!(database.plan, "basic-256mb");
assert!(
database.ip_allow_list.is_empty(),
"managed Postgres must not accept public connections"
);
}
2 changes: 1 addition & 1 deletion docs/deploying/container.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The image starts `agentic-server` in standalone mode. At minimum, set `LLM_API_B
| `GATEWAY_PORT` | `9000` | Listen port |
| `DATABASE_URL` | `sqlite://./agentic_api.db` | SQLite or PostgreSQL persistence URL |
| `OPENAI_API_KEY` | none | Credential sent to the upstream service when the client does not supply one |
| `SKIP_LLM_READY_CHECK` | `false` | Skip the startup probe for hosted providers without `/health` |
| `SKIP_LLM_READY_CHECK` | `false` | Skip the startup probe for providers without `/health` |
| `CORS_ALLOWED_ORIGINS` | none | Comma-separated browser origins |

The container entrypoint rejects percent-encoded SQLite paths because SQLx decodes them before opening the database. Use a literal filesystem path or PostgreSQL instead.
Expand Down
Loading