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
3 changes: 3 additions & 0 deletions crates/api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ use utoipa::OpenApi;
crate::routes::agents::start_instance,
crate::routes::agents::stop_instance,
crate::routes::agents::restart_instance,
crate::routes::agents::upgrade_instance,
crate::routes::agents::check_upgrade_available,
crate::routes::admin::admin_create_backup,
crate::routes::admin::admin_list_backups,
crate::routes::admin::admin_get_backup,
Expand Down Expand Up @@ -187,6 +189,7 @@ use utoipa::OpenApi;
crate::models::UsageResponse,
crate::models::BalanceResponse,
crate::models::UsageQueryParams,
crate::routes::agents::UpgradeAvailabilityResponse,
// BI metrics (admin)
crate::routes::admin::BiDeploymentQuery,
crate::routes::admin::BiSummaryQuery,
Expand Down
156 changes: 156 additions & 0 deletions crates/api/src/routes/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,11 @@ pub fn create_agent_router() -> Router<AppState> {
.route("/instances/{id}/start", post(start_instance))
.route("/instances/{id}/stop", post(stop_instance))
.route("/instances/{id}/restart", post(restart_instance))
.route("/instances/{id}/upgrade", post(upgrade_instance))
.route(
"/instances/{id}/upgrade-available",
get(check_upgrade_available),
)
.route("/instances/{id}", delete(delete_instance))
}

Expand Down Expand Up @@ -982,3 +987,154 @@ pub async fn restart_instance(
.body(axum::body::Body::empty())
.map_err(|_| ApiError::internal_server_error("Failed to construct response"))
}

/// Upgrade an agent instance to the latest image
#[utoipa::path(
post,
path = "/v1/agents/instances/{id}/upgrade",
tag = "Agents",
params(
("id" = String, Path, description = "Instance ID")
),
responses(
(status = 200, description = "Instance upgraded"),
(status = 401, description = "Unauthorized", body = crate::error::ApiErrorResponse),
(status = 403, description = "Forbidden - not your instance", body = crate::error::ApiErrorResponse),
(status = 404, description = "Instance not found", body = crate::error::ApiErrorResponse),
(status = 500, description = "Internal server error", body = crate::error::ApiErrorResponse)
),
security(("session_token" = []))
)]
pub async fn upgrade_instance(
State(app_state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(instance_id): Path<String>,
) -> Result<Response, ApiError> {
let instance_uuid = Uuid::parse_str(&instance_id)
.map_err(|_| ApiError::bad_request("Invalid instance ID format"))?;

tracing::debug!(
"Upgrading instance: instance_id={}, user_id={}",
instance_uuid,
user.user_id
);

let instance = app_state
.agent_repository
.get_instance(instance_uuid)
.await
.map_err(|e| {
tracing::error!(
"Failed to fetch instance: instance_id={}, error={}",
instance_uuid,
e
);
ApiError::internal_server_error("Failed to fetch instance")
})?
.ok_or_else(|| ApiError::not_found("Instance not found"))?;

if instance.user_id != user.user_id {
return Err(ApiError::forbidden("This instance does not belong to you"));
}

app_state
.agent_service
.upgrade_instance(instance_uuid, user.user_id)
.await
.map_err(|e| {
tracing::error!(
"Failed to upgrade instance: instance_id={}, error={}",
instance_uuid,
e
);
ApiError::internal_server_error("Failed to upgrade instance")
})?;

Response::builder()
.status(StatusCode::OK)
.body(axum::body::Body::empty())
.map_err(|_| ApiError::internal_server_error("Failed to construct response"))
}

/// Check if an upgrade is available for an agent instance
#[utoipa::path(
get,
path = "/v1/agents/instances/{id}/upgrade-available",
tag = "Agents",
params(
("id" = String, Path, description = "Instance ID")
),
responses(
(status = 200, description = "Upgrade availability info", body = crate::routes::agents::UpgradeAvailabilityResponse),
(status = 401, description = "Unauthorized", body = crate::error::ApiErrorResponse),
(status = 403, description = "Forbidden - not your instance", body = crate::error::ApiErrorResponse),
(status = 404, description = "Instance not found", body = crate::error::ApiErrorResponse),
(status = 500, description = "Internal server error", body = crate::error::ApiErrorResponse)
),
security(("session_token" = []))
)]
pub async fn check_upgrade_available(
State(app_state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(instance_id): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let instance_uuid = Uuid::parse_str(&instance_id)
.map_err(|_| ApiError::bad_request("Invalid instance ID format"))?;

tracing::debug!(
"Checking upgrade availability: instance_id={}, user_id={}",
instance_uuid,
user.user_id
);

let instance = app_state
.agent_repository
.get_instance(instance_uuid)
.await
.map_err(|e| {
tracing::error!(
"Failed to fetch instance: instance_id={}, error={}",
instance_uuid,
e
);
ApiError::internal_server_error("Failed to fetch instance")
})?
.ok_or_else(|| ApiError::not_found("Instance not found"))?;

if instance.user_id != user.user_id {
return Err(ApiError::forbidden("This instance does not belong to you"));
}

let upgrade_info = app_state
.agent_service
.check_upgrade_available(instance_uuid, user.user_id)
.await
.map_err(|e| {
tracing::error!(
"Failed to check upgrade availability: instance_id={}, error={}",
instance_uuid,
e
);
ApiError::internal_server_error("Failed to check upgrade availability")
})?;

Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::to_string(&UpgradeAvailabilityResponse {
has_upgrade: upgrade_info.has_upgrade,
current_image: upgrade_info.current_image,
latest_image: upgrade_info.latest_image,
})
.map_err(|_| ApiError::internal_server_error("Failed to serialize response"))?,
))
.map_err(|_| ApiError::internal_server_error("Failed to construct response"))
}

#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct UpgradeAvailabilityResponse {
pub has_upgrade: bool,
pub current_image: Option<String>,
pub latest_image: String,
}
5 changes: 4 additions & 1 deletion crates/api/tests/agent_e2e_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
mod common;

use common::{create_test_server_and_db, mock_login, TestServerConfig};
use common::{create_test_server_and_db, insert_test_subscription, mock_login, TestServerConfig};
use serde_json::json;
use uuid::Uuid;
use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
Expand Down Expand Up @@ -46,6 +46,9 @@ async fn test_agent_complete_workflow() {
let _admin_token = mock_login(&server, admin_email).await;
let user_token = mock_login(&server, user_email).await;

// Set up a test subscription for the user to avoid 402 errors
insert_test_subscription(&server, &db, user_email, false).await;

// Get user_id for the regular user (needed for instance creation)
let user_response = server
.get("/v1/users/me")
Expand Down
25 changes: 25 additions & 0 deletions crates/api/tests/agent_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ async fn test_create_api_key_requires_auth() {
);
}

/// Test check upgrade available requires authentication
#[tokio::test]
async fn test_check_upgrade_available_requires_auth() {
let server = create_test_server_and_db(Default::default()).await.0;

let fake_instance_id = Uuid::new_v4().to_string();

// Try without auth
let response = server
.get(&format!(
"/v1/agents/instances/{}/upgrade-available",
fake_instance_id
))
.await;

assert_eq!(
response.status_code(),
401,
"Should require authentication to check upgrade"
);
}

// Note: Chat completions endpoint tests require actual Agent instances with proper
// connection information (instance_url, instance_token). These are integration tests
// that would need mock/stub Agent infrastructure. See INTEGRATION_TESTS section below.
Expand Down Expand Up @@ -244,6 +266,7 @@ async fn test_create_instance_rejects_unsubscribed_user() {
common::cleanup_user_subscriptions(&db, user_email).await;

// Set subscription plans (for subscribed users); unsubscribed users get 0 instances
// Note: set_subscription_plans overwrites any existing plans, so no need to clear first
common::set_subscription_plans(
&server,
json!({
Expand Down Expand Up @@ -297,6 +320,8 @@ async fn test_create_instance_respects_agent_instance_limit_max_1() {

// Set up subscription with agent_instances limit of 1
// NOTE: insert_test_subscription uses price_id "price_test_basic"
// Note: set_subscription_plans overwrites any existing plans, so no need to clear first
tracing::info!("Setting up subscription plans");
common::set_subscription_plans(
&server,
json!({
Expand Down
Loading