Skip to content
Open
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: 5 additions & 4 deletions .agents/skills/validate-opensecret/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,13 @@ Health probes are preliminary only:

```sh
curl --fail --silent --show-error http://127.0.0.1:3000/health-check
curl --fail --silent --show-error http://127.0.0.1:3000/health-check-extended
```

The first is process liveness; the second checks Tinfoil model connectivity.
Neither proves PostgreSQL, auth, encryption, persistence, routing, billing,
flags, or a user flow.
This reports process liveness with `status` and `version` JSON. It does not call
a provider or probe PostgreSQL, and does not prove provider availability, auth,
encryption, persistence, routing, billing, flags, or a user flow. Provider
availability checks belong to separate diagnostics, not load-balancer origin
health.

Exercise protected routes through the monorepo-root `sdk/` directory or through
the corresponding Maple application client:
Expand Down
7 changes: 7 additions & 0 deletions services/opensecret/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ OpenAI-compatible wire endpoint. Use an OpenSecret SDK or Maple for
protected-route integration tests; plain `curl` is suitable only for public
health probes.

`GET /health-check` returns HTTP 200 with `{"status":"pass","version":"v1"}`
when the server can respond. It does not contact Tinfoil, other providers, or
PostgreSQL; provider outages must not remove responsive enclaves from
load-balancer rotation. Monitor provider availability separately from origin
liveness. `/health-check-extended` has been removed; migrate any remaining
monitors or deployment scripts to `/health-check` before deploying this change.

Contributor and coding-agent standards live in [`AGENTS.md`](AGENTS.md).
Task-specific development, API, provider, security, and validation workflows
live under the monorepo-root [`.agents/skills/`](../../.agents/skills/).
Expand Down
10 changes: 5 additions & 5 deletions services/opensecret/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ use crate::sqs::SqsEventPublisher;
use crate::web::openai_auth::{validate_openai_auth, validate_optional_openai_auth};
use crate::web::platform_login_routes;
use crate::web::{
conversation_projects_routes, conversations_routes, health_routes_with_state,
instructions_routes, login_routes, native_handoff_routes, oauth_routes, openai_models_routes,
openai_routes, protected_routes, responses_routes, web_routes,
conversation_projects_routes, conversations_routes, health_routes, instructions_routes,
login_routes, native_handoff_routes, oauth_routes, openai_models_routes, openai_routes,
protected_routes, responses_routes, web_routes,
};
use crate::{attestation_routes::SessionState, web::platform_routes};
use bounded_ttl_cache::BoundedTtlCache;
Expand Down Expand Up @@ -4226,14 +4226,14 @@ async fn main() -> Result<(), Error> {
let application = application_routes(app_state.clone());
let v2_application = application
.clone()
.merge(health_routes_with_state(app_state.clone()))
.merge(health_routes())
.merge(native_handoff_routes(app_state.clone()))
.layer(from_fn(add_error_contract_header));
let transport_v2_gateway =
transport_v2::gateway::TransportV2Gateway::new(app_state.clone(), v2_application);

let app = application
.merge(health_routes_with_state(app_state.clone()))
.merge(health_routes())
.merge(attestation_routes::router(app_state.clone()))
.merge(transport_v2_gateway.router())
.layer(cors)
Expand Down
162 changes: 53 additions & 109 deletions services/opensecret/src/web/health_routes.rs
Original file line number Diff line number Diff line change
@@ -1,126 +1,70 @@
use crate::{
provider_client::{ProviderClient, ProviderRequest},
AppState,
};
use axum::{http::StatusCode, Router};
use axum::{routing::get, Json};
use reqwest::Method;
use axum::{routing::get, Json, Router};
use serde::Serialize;
use std::sync::Arc;
use std::time::Duration;

const API_VERSION: &str = "v1";

pub fn router_with_state(state: Arc<AppState>) -> Router<()> {
Router::new()
.route("/health-check", get(health_check))
.route("/health-check-extended", get(health_check_extended))
.with_state(state)
/// Process liveness only: an upstream outage must not remove a healthy enclave
/// from the load balancer.
pub fn router() -> Router {
Router::new().route("/health-check", get(health_check))
}

#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
struct HealthResponse {
status: &'static str,
version: &'static str,
}

#[derive(Serialize)]
pub struct ExtendedHealthResponse {
pub status: String,
pub version: String,
pub outbound_connectivity: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_check: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
async fn health_check() -> Json<HealthResponse> {
Json(HealthResponse {
status: "pass",
version: API_VERSION,
})
}

impl HealthResponse {
/// Fabricate a status: pass response without checking database connectivity
pub fn new_ok() -> Self {
Self {
status: String::from("pass"),
version: String::from(API_VERSION),
}
}
}

/// Health check endpoint following the IETF draft standard
/// <https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check>
pub async fn health_check() -> Result<Json<HealthResponse>, (StatusCode, String)> {
Ok(Json(HealthResponse::new_ok()))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{header, StatusCode};
use serde_json::json;
use std::time::Duration;

/// Extended health check that tests outbound connectivity via model listing
pub async fn health_check_extended(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
) -> Result<Json<ExtendedHealthResponse>, (StatusCode, String)> {
let tinfoil_proxy = state.proxy_router.get_tinfoil_proxy();
let result = tokio::time::timeout(
Duration::from_secs(5),
fetch_models_directly(&state.provider_client, &tinfoil_proxy),
)
.await;
#[tokio::test]
async fn health_is_dependency_free_and_extended_health_is_removed() {
// Deliberately construct only the production health router: no
// AppState, database, provider client, credentials, or model service.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, router()).await.unwrap();
});
let client = crate::http_client::client_builder()
.no_proxy()
.timeout(Duration::from_secs(2))
.build()
.unwrap();

match result {
Ok(Ok(model_count)) => Ok(Json(ExtendedHealthResponse {
status: "pass".to_string(),
version: API_VERSION.to_string(),
outbound_connectivity: true,
model_check: Some(format!(
"Successfully fetched {} models from {}",
model_count, tinfoil_proxy.provider_name
)),
error: None,
})),
Ok(Err(e)) => {
// Failed to fetch models
Err((
StatusCode::SERVICE_UNAVAILABLE,
format!(
"Failed to fetch models from {}: {}",
tinfoil_proxy.provider_name, e
),
))
}
Err(_) => Err((
StatusCode::SERVICE_UNAVAILABLE,
format!(
"Model fetch from {} timed out after 5 seconds",
tinfoil_proxy.provider_name
),
)),
}
}
let response = client
.get(format!("http://{address}/health-check"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json");
assert_eq!(
response.json::<serde_json::Value>().await.unwrap(),
json!({"status": "pass", "version": "v1"}),
"liveness must not claim outbound connectivity or model health"
);

/// Helper function to fetch models directly without caching
async fn fetch_models_directly(
client: &ProviderClient,
proxy_config: &crate::proxy_config::ProxyConfig,
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
let res = client
.send(
proxy_config,
ProviderRequest::new(Method::GET, "/v1/models", Duration::from_secs(5)),
)
.await?;
let removed = client
.get(format!("http://{address}/health-check-extended"))
.send()
.await
.unwrap();
assert_eq!(removed.status(), StatusCode::NOT_FOUND);

if !res.is_success() {
let status = res.status_code();
let body_bytes = res.bytes().await?;
let body_str = String::from_utf8_lossy(&body_bytes);
return Err(format!("HTTP {}: {}", status, body_str).into());
server.abort();
assert!(server.await.unwrap_err().is_cancelled());
}

let body_bytes = res.bytes().await?;
let models_response: serde_json::Value = serde_json::from_slice(&body_bytes)?;

// Count the models
let model_count = models_response
.get("data")
.and_then(|d| d.as_array())
.map(|arr| arr.len())
.unwrap_or(0);

Ok(model_count)
}
2 changes: 1 addition & 1 deletion services/opensecret/src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub mod responses;
pub mod web_routes;
pub(crate) mod web_safety;

pub use health_routes::router_with_state as health_routes_with_state;
pub use health_routes::router as health_routes;
pub use login_routes::router as login_routes;
pub(crate) use native_handoff_routes::router as native_handoff_routes;
pub use oauth_routes::router as oauth_routes;
Expand Down
Loading