diff --git a/Cargo.lock b/Cargo.lock index 5e2882962..43015bcdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4550,6 +4550,41 @@ dependencies = [ "serde", ] +[[package]] +name = "rust-embed" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b68543d5527e158213414a92832d2aab11a84d2571a5eb021ebe22c43aab066" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4e0f0ced47ded9a68374ac145edd65a6c1fa13a96447b873660b2a568a0fd7" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 1.0.109", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512b0ab6853f7e14e3c8754acb43d6f748bb9ced66aa5915a6553ac8213f7731" +dependencies = [ + "sha2 0.10.6", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.21" @@ -5110,6 +5145,7 @@ dependencies = [ "tracing-opentelemetry", "tracing-subscriber", "ttl_cache", + "utoipa", "uuid", ] @@ -5158,6 +5194,8 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", "uuid", ] @@ -5208,6 +5246,8 @@ dependencies = [ "tracing-opentelemetry", "tracing-subscriber", "ttl_cache", + "utoipa", + "utoipa-swagger-ui", "uuid", "x509-parser", ] @@ -6529,6 +6569,47 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "utoipa" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e7ee17c9ef094b86e1e04170d90765bd76cb381921dacb4d3e175a267bdae6" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6f458e5abc811d44aca28455efc4163fb7565a7af2aa32d17611f3d1d9794d" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.5", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062bba5a3568e126ac72049a63254f4cb1da2eb713db0c1ab2a4c76be191db8c" +dependencies = [ + "axum", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.3.0" @@ -7386,6 +7467,18 @@ version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" +[[package]] +name = "zip" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0445d0fbc924bb93539b4316c11afb121ea39296f99a3c4c9edad09e3658cdef" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zstd" version = "0.11.2+zstd.1.5.2" diff --git a/Cargo.toml b/Cargo.toml index 727450226..5575d6a86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,4 +93,6 @@ tracing-subscriber = { version = "0.3.16", default-features = false, features = "std", ] } ttl_cache = "0.5.1" +utoipa = { version = "3.2.1", features = [ "uuid", "chrono" ] } +utoipa-swagger-ui = { version = "3.1.3", features = ["axum"] } uuid = "1.2.2" diff --git a/common/Cargo.toml b/common/Cargo.toml index 3c80d8e01..3277b1c17 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -40,6 +40,7 @@ tracing = { workspace = true, features = ["std"] } tracing-opentelemetry = { workspace = true, optional = true } tracing-subscriber = { workspace = true, optional = true } ttl_cache = { workspace = true, optional = true } +utoipa = { workspace = true } uuid = { workspace = true, features = ["v4", "serde"], optional = true } [features] diff --git a/common/src/database.rs b/common/src/database.rs index 1d3956ea0..f864b5f78 100644 --- a/common/src/database.rs +++ b/common/src/database.rs @@ -2,15 +2,17 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; use strum::Display; +use utoipa::ToSchema; -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, ToSchema)] #[serde(rename_all = "lowercase")] +#[schema(as = shuttle_common::database::Type)] pub enum Type { AwsRds(AwsRdsEngine), Shared(SharedEngine), } -#[derive(Clone, Debug, Deserialize, Display, Serialize, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Display, Serialize, Eq, PartialEq, ToSchema)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum AwsRdsEngine { @@ -19,7 +21,7 @@ pub enum AwsRdsEngine { MariaDB, } -#[derive(Clone, Debug, Deserialize, Display, Serialize, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Display, Serialize, Eq, PartialEq, ToSchema)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum SharedEngine { diff --git a/common/src/deployment.rs b/common/src/deployment.rs index 9b924709e..ed5736c62 100644 --- a/common/src/deployment.rs +++ b/common/src/deployment.rs @@ -1,9 +1,11 @@ use serde::{Deserialize, Serialize}; use strum::Display; +use utoipa::ToSchema; -#[derive(Clone, Debug, Deserialize, Display, Serialize)] +#[derive(Clone, Debug, Deserialize, Display, Serialize, ToSchema)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] +#[schema(as = shuttle_common::deployment::State)] pub enum State { Queued, Building, diff --git a/common/src/log.rs b/common/src/log.rs index 8e13e2fb3..b93fe0a0c 100644 --- a/common/src/log.rs +++ b/common/src/log.rs @@ -5,17 +5,23 @@ use chrono::{DateTime, Utc}; #[cfg(feature = "display")] use crossterm::style::{StyledContent, Stylize}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use uuid::Uuid; use crate::deployment::State; pub const STATE_MESSAGE: &str = "NEW STATE"; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::log::Item)] pub struct Item { + #[schema(value_type = KnownFormat::Uuid)] pub id: Uuid, + #[schema(value_type = KnownFormat::DateTime)] pub timestamp: DateTime, + #[schema(value_type = shuttle_common::deployment::State)] pub state: State, + #[schema(value_type = shuttle_common::log::Level)] pub level: Level, pub file: Option, pub line: Option, @@ -77,8 +83,9 @@ impl std::fmt::Display for Item { } } -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, ToSchema)] #[serde(rename_all = "lowercase")] +#[schema(as = shuttle_common::log::Level)] pub enum Level { Trace, Debug, diff --git a/common/src/models/deployment.rs b/common/src/models/deployment.rs index 22c186c15..c2f8f49cb 100644 --- a/common/src/models/deployment.rs +++ b/common/src/models/deployment.rs @@ -7,15 +7,21 @@ use comfy_table::{ }; use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use uuid::Uuid; use crate::deployment::State; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::models::deployment::Response)] pub struct Response { + #[schema(value_type = KnownFormat::Uuid)] pub id: Uuid, + #[schema(value_type = KnownFormat::Uuid)] pub service_id: Uuid, + #[schema(value_type = shuttle_common::deployment::State)] pub state: State, + #[schema(value_type = KnownFormat::DateTime)] pub last_update: DateTime, } diff --git a/common/src/models/project.rs b/common/src/models/project.rs index 70f14e137..2286f3abb 100644 --- a/common/src/models/project.rs +++ b/common/src/models/project.rs @@ -6,18 +6,22 @@ use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; use strum::EnumString; +use utoipa::ToSchema; // Timeframe before a project is considered idle pub const IDLE_MINUTES: u64 = 30; -#[derive(Deserialize, Serialize, Clone)] +#[derive(Deserialize, Serialize, Clone, ToSchema)] +#[schema(as = shuttle_common::models::project::Response)] pub struct Response { pub name: String, + #[schema(value_type = shuttle_common::models::project::State)] pub state: State, } -#[derive(Clone, Debug, Deserialize, Serialize, EnumString)] +#[derive(Clone, Debug, Deserialize, Serialize, EnumString, ToSchema)] #[serde(rename_all = "lowercase")] +#[schema(as = shuttle_common::models::project::State)] pub enum State { Creating { recreate_count: usize }, Attaching { recreate_count: usize }, @@ -164,7 +168,8 @@ pub struct Config { pub idle_minutes: u64, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::models::project::AdminResponse)] pub struct AdminResponse { pub project_name: String, pub account_name: String, diff --git a/common/src/models/secret.rs b/common/src/models/secret.rs index e03664e33..fdb4c8dcf 100644 --- a/common/src/models/secret.rs +++ b/common/src/models/secret.rs @@ -5,10 +5,13 @@ use comfy_table::{ }; use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::models::secret::Response)] pub struct Response { pub key: String, + #[schema(value_type = KnownFormat::DateTime)] pub last_update: DateTime, } diff --git a/common/src/models/service.rs b/common/src/models/service.rs index 4eff0ad6f..53bcd2cf8 100644 --- a/common/src/models/service.rs +++ b/common/src/models/service.rs @@ -1,19 +1,24 @@ -use crate::models::deployment; - use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; use std::fmt::Display; +use utoipa::ToSchema; use uuid::Uuid; -#[derive(Deserialize, Serialize)] +use crate::models::deployment; + +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::models::service::Response)] pub struct Response { + #[schema(value_type = KnownFormat::Uuid)] pub id: Uuid, pub name: String, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::models::service::Summary)] pub struct Summary { pub name: String, + #[schema(value_type = shuttle_common::models::deployment::Response)] pub deployment: Option, pub uri: String, } diff --git a/common/src/models/stats.rs b/common/src/models/stats.rs index a4a5035d4..41d0852a8 100644 --- a/common/src/models/stats.rs +++ b/common/src/models/stats.rs @@ -1,12 +1,15 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use uuid::Uuid; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::models::stats::LoadRequest)] pub struct LoadRequest { pub id: Uuid, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::models::stats::LoadResponse)] pub struct LoadResponse { pub builds_count: usize, pub has_capacity: bool, diff --git a/common/src/resource.rs b/common/src/resource.rs index cec5af3e8..6da342354 100644 --- a/common/src/resource.rs +++ b/common/src/resource.rs @@ -2,25 +2,32 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; use serde_json::Value; +use utoipa::ToSchema; use crate::database; /// Common type to hold all the information we need for a generic resource -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize, ToSchema)] +#[schema(as = shuttle_common::resource::Response)] pub struct Response { /// The type of this resource. + #[schema(value_type = shuttle_common::resource::Type)] pub r#type: Type, /// The config used when creating this resource. Use the [Self::r#type] to know how to parse this data. + #[schema(value_type = Object)] pub config: Value, /// The data associated with this resource. Use the [Self::r#type] to know how to parse this data. + #[schema(value_type = Object)] pub data: Value, } -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, ToSchema)] #[serde(rename_all = "lowercase")] +#[schema(as = shuttle_common::resource::Type)] pub enum Type { + #[schema(value_type = shuttle_common::database::Type)] Database(database::Type), Secrets, StaticFolder, diff --git a/deployer/Cargo.toml b/deployer/Cargo.toml index c7f99a769..deba2cb8b 100644 --- a/deployer/Cargo.toml +++ b/deployer/Cargo.toml @@ -52,6 +52,8 @@ tracing-subscriber = { workspace = true, features = [ "env-filter", "fmt", ] } +utoipa = { workspace = true } +utoipa-swagger-ui = { workspace = true } uuid = { workspace = true, features = ["v4"] } [dependencies.shuttle-common] diff --git a/deployer/src/handlers/error.rs b/deployer/src/handlers/error.rs index 09bedcfce..02a4b7a23 100644 --- a/deployer/src/handlers/error.rs +++ b/deployer/src/handlers/error.rs @@ -7,8 +7,9 @@ use axum::Json; use serde::{ser::SerializeMap, Serialize}; use shuttle_common::models::error::ApiError; use tracing::error; +use utoipa::ToSchema; -#[derive(thiserror::Error, Debug)] +#[derive(thiserror::Error, Debug, ToSchema)] pub enum Error { #[error("Streaming error: {0}")] Streaming(#[from] axum::Error), diff --git a/deployer/src/handlers/mod.rs b/deployer/src/handlers/mod.rs index 2fd1fc692..4082715da 100644 --- a/deployer/src/handlers/mod.rs +++ b/deployer/src/handlers/mod.rs @@ -24,6 +24,9 @@ use shuttle_common::storage_manager::StorageManager; use shuttle_common::{request_span, LogItem}; use shuttle_service::builder::clean_crate; use tracing::{debug, error, field, instrument, trace}; +use utoipa::OpenApi; + +use utoipa_swagger_ui::SwaggerUi; use uuid::Uuid; use crate::deployment::{DeploymentManager, Queued}; @@ -35,6 +38,40 @@ pub use {self::error::Error, self::error::Result}; mod project; +#[derive(OpenApi)] +#[openapi( + paths( + get_services, + get_service, + create_service, + stop_service, + get_service_resources, + get_deployments, + get_deployment, + delete_deployment, + get_logs_subscribe, + get_logs, + get_secrets, + clean_project + ), + components(schemas( + shuttle_common::models::service::Summary, + shuttle_common::resource::Response, + shuttle_common::resource::Type, + shuttle_common::database::Type, + shuttle_common::database::AwsRdsEngine, + shuttle_common::database::SharedEngine, + shuttle_common::models::service::Response, + shuttle_common::models::secret::Response, + shuttle_common::models::deployment::Response, + shuttle_common::log::Item, + shuttle_common::models::secret::Response, + shuttle_common::log::Level, + shuttle_common::deployment::State + )) +)] +pub struct ApiDoc; + pub async fn make_router( persistence: Persistence, deployment_manager: DeploymentManager, @@ -44,14 +81,20 @@ pub async fn make_router( project_name: ProjectName, ) -> Router { Router::new() + // TODO: The `/swagger-ui` responds with a 303 See Other response which is followed in + // browsers but leads to 404 Not Found. This must be investigated. + .merge(SwaggerUi::new("/projects/:project_name/swagger-ui").url( + "/projects/:project_name/api-docs/openapi.json", + ApiDoc::openapi(), + )) .route( "/projects/:project_name/services", - get(list_services.layer(ScopedLayer::new(vec![Scope::Service]))), + get(get_services.layer(ScopedLayer::new(vec![Scope::Service]))), ) .route( "/projects/:project_name/services/:service_name", get(get_service.layer(ScopedLayer::new(vec![Scope::Service]))) - .post(post_service.layer(ScopedLayer::new(vec![Scope::ServiceCreate]))) + .post(create_service.layer(ScopedLayer::new(vec![Scope::ServiceCreate]))) .delete(stop_service.layer(ScopedLayer::new(vec![Scope::ServiceCreate]))), ) .route( @@ -81,7 +124,7 @@ pub async fn make_router( ) .route( "/projects/:project_name/clean", - post(post_clean.layer(ScopedLayer::new(vec![Scope::DeploymentPush]))), + post(clean_project.layer(ScopedLayer::new(vec![Scope::DeploymentPush]))), ) .layer(Extension(persistence)) .layer(Extension(deployment_manager)) @@ -114,7 +157,18 @@ pub async fn make_router( } #[instrument(skip_all)] -async fn list_services( +#[utoipa::path( + get, + path = "/projects/{project_name}/services", + responses( + (status = 200, description = "Lists the services owned by a project.", body = [shuttle_common::models::service::Response]), + (status = 500, description = "Database error.", body = String) + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the services."), + ) +)] +pub async fn get_services( Extension(persistence): Extension, ) -> Result>> { let services = persistence @@ -128,7 +182,21 @@ async fn list_services( } #[instrument(skip_all, fields(%project_name, %service_name))] -async fn get_service( +#[instrument(skip_all)] +#[utoipa::path( + get, + path = "/projects/{project_name}/services/{service_name}", + responses( + (status = 200, description = "Gets a specific service summary.", body = shuttle_common::models::service::Summary), + (status = 500, description = "Database error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the service."), + ("service_name" = String, Path, description = "Name of the service.") + ) +)] +pub async fn get_service( Extension(persistence): Extension, Extension(proxy_fqdn): Extension, Path((project_name, service_name)): Path<(String, String)>, @@ -152,7 +220,20 @@ async fn get_service( } #[instrument(skip_all, fields(%project_name, %service_name))] -async fn get_service_resources( +#[utoipa::path( + get, + path = "/projects/{project_name}/services/{service_name}/resources", + responses( + (status = 200, description = "Gets a specific service resources.", body = shuttle_common::resource::Response), + (status = 500, description = "Database error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the service."), + ("service_name" = String, Path, description = "Name of the service.") + ) +)] +pub async fn get_service_resources( Extension(persistence): Extension, Path((project_name, service_name)): Path<(String, String)>, ) -> Result>> { @@ -171,7 +252,20 @@ async fn get_service_resources( } #[instrument(skip_all, fields(%project_name, %service_name))] -async fn post_service( +#[utoipa::path( + post, + path = "/projects/{project_name}/services/{service_name}", + responses( + (status = 200, description = "Creates a specific service owned by a specific project.", body = shuttle_common::models::deployment::Response), + (status = 500, description = "Database or streaming error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the service."), + ("service_name" = String, Path, description = "Name of the service.") + ) +)] +pub async fn create_service( Extension(persistence): Extension, Extension(deployment_manager): Extension, Extension(claim): Extension, @@ -217,7 +311,20 @@ async fn post_service( } #[instrument(skip_all, fields(%project_name, %service_name))] -async fn stop_service( +#[utoipa::path( + delete, + path = "/projects/{project_name}/services/{service_name}", + responses( + (status = 200, description = "Stops a specific service owned by a specific project.", body = shuttle_common::models::service::Summary), + (status = 500, description = "Database error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the service."), + ("service_name" = String, Path, description = "Name of the service.") + ) +)] +pub async fn stop_service( Extension(persistence): Extension, Extension(deployment_manager): Extension, Extension(proxy_fqdn): Extension, @@ -245,7 +352,19 @@ async fn stop_service( } #[instrument(skip(persistence))] -async fn get_deployments( +#[utoipa::path( + get, + path = "/projects/{project_name}/deployments", + responses( + (status = 200, description = "Gets deployments information associated to a specific project.", body = shuttle_common::models::deployment::Response), + (status = 500, description = "Database error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the deployments.") + ) +)] +pub async fn get_deployments( Extension(persistence): Extension, Path(project_name): Path, ) -> Result>> { @@ -264,7 +383,21 @@ async fn get_deployments( } #[instrument(skip_all, fields(%project_name, %deployment_id))] -async fn get_deployment( +#[instrument(skip(persistence))] +#[utoipa::path( + get, + path = "/projects/{project_name}/deployments/{deployment_id}", + responses( + (status = 200, description = "Gets a specific deployment information.", body = shuttle_common::models::deployment::Response), + (status = 500, description = "Database or streaming error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the deployment."), + ("deployment_id" = String, Path, description = "The deployment id in uuid format.") + ) +)] +pub async fn get_deployment( Extension(persistence): Extension, Path((project_name, deployment_id)): Path<(String, Uuid)>, ) -> Result> { @@ -276,7 +409,20 @@ async fn get_deployment( } #[instrument(skip_all, fields(%project_name, %deployment_id))] -async fn delete_deployment( +#[utoipa::path( + delete, + path = "/projects/{project_name}/deployments/{deployment_id}", + responses( + (status = 200, description = "Deletes a specific deployment.", body = shuttle_common::models::deployment::Response), + (status = 500, description = "Database or streaming error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the deployment."), + ("deployment_id" = String, Path, description = "The deployment id in uuid format.") + ) +)] +pub async fn delete_deployment( Extension(deployment_manager): Extension, Extension(persistence): Extension, Path((project_name, deployment_id)): Path<(String, Uuid)>, @@ -291,7 +437,20 @@ async fn delete_deployment( } #[instrument(skip_all, fields(%project_name, %deployment_id))] -async fn get_logs( +#[utoipa::path( + get, + path = "/projects/{project_name}/ws/deployments/{deployment_id}/logs", + responses( + (status = 200, description = "Gets the logs a specific deployment.", body = [shuttle_common::log::Item]), + (status = 500, description = "Database or streaming error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the deployment."), + ("deployment_id" = String, Path, description = "The deployment id in uuid format.") + ) +)] +pub async fn get_logs( Extension(persistence): Extension, Path((project_name, deployment_id)): Path<(String, Uuid)>, ) -> Result>> { @@ -309,7 +468,18 @@ async fn get_logs( } } -async fn get_logs_subscribe( +#[utoipa::path( + get, + path = "/projects/{project_name}/deployments/{deployment_id}/logs", + responses( + (status = 200, description = "Subscribes to a specific deployment logs.") + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the deployment."), + ("deployment_id" = String, Path, description = "The deployment id in uuid format.") + ) +)] +pub async fn get_logs_subscribe( Extension(persistence): Extension, Path((_project_name, deployment_id)): Path<(String, Uuid)>, ws_upgrade: ws::WebSocketUpgrade, @@ -371,7 +541,20 @@ async fn logs_websocket_handler(mut s: WebSocket, persistence: Persistence, id: } #[instrument(skip_all, fields(%project_name, %service_name))] -async fn get_secrets( +#[utoipa::path( + get, + path = "/projects/{project_name}/secrets/{service_name}", + responses( + (status = 200, description = "Gets the secrets a specific service.", body = [shuttle_common::models::secret::Response]), + (status = 500, description = "Database or streaming error.", body = String), + (status = 404, description = "Record could not be found.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the service."), + ("service_name" = String, Path, description = "Name of the service.") + ) +)] +pub async fn get_secrets( Extension(persistence): Extension, Path((project_name, service_name)): Path<(String, String)>, ) -> Result>> { @@ -389,7 +572,18 @@ async fn get_secrets( } } -async fn post_clean( +#[utoipa::path( + post, + path = "/projects/{project_name}/clean", + responses( + (status = 200, description = "Clean a specific project build artifacts.", body = [String]), + (status = 500, description = "Clean project error.", body = String), + ), + params( + ("project_name" = String, Path, description = "Name of the project that owns the service."), + ) +)] +pub async fn clean_project( Extension(deployment_manager): Extension, Path(project_name): Path, ) -> Result>> { diff --git a/deployer/src/lib.rs b/deployer/src/lib.rs index 73912cb76..39bede711 100644 --- a/deployer/src/lib.rs +++ b/deployer/src/lib.rs @@ -19,7 +19,7 @@ use crate::deployment::gateway_client::GatewayClient; mod args; mod deployment; mod error; -mod handlers; +pub mod handlers; mod persistence; mod proxy; mod runtime_manager; diff --git a/deployer/src/persistence/deployment.rs b/deployer/src/persistence/deployment.rs index b7e50f8b8..c79a0ee23 100644 --- a/deployer/src/persistence/deployment.rs +++ b/deployer/src/persistence/deployment.rs @@ -4,11 +4,12 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::{sqlite::SqliteRow, FromRow, Row}; use tracing::error; +use utoipa::ToSchema; use uuid::Uuid; use super::state::State; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, ToSchema)] pub struct Deployment { pub id: Uuid, pub service_id: Uuid, diff --git a/deployer/src/persistence/mod.rs b/deployer/src/persistence/mod.rs index 1a2abc3ce..51d5a5b81 100644 --- a/deployer/src/persistence/mod.rs +++ b/deployer/src/persistence/mod.rs @@ -1,9 +1,9 @@ -mod deployment; +pub mod deployment; mod error; -mod log; +pub mod log; mod resource; mod secret; -mod service; +pub mod service; mod state; mod user; diff --git a/deployer/src/persistence/state.rs b/deployer/src/persistence/state.rs index 77df04e9e..eb6331bf8 100644 --- a/deployer/src/persistence/state.rs +++ b/deployer/src/persistence/state.rs @@ -1,7 +1,8 @@ use strum::{Display, EnumString}; +use utoipa::ToSchema; /// States a deployment can be in -#[derive(sqlx::Type, Debug, Display, Clone, Copy, EnumString, PartialEq, Eq)] +#[derive(sqlx::Type, Debug, Display, Clone, Copy, EnumString, PartialEq, Eq, ToSchema)] pub enum State { /// Deployment is queued to be build Queued, diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index d2db165d7..aebc49b52 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -47,6 +47,8 @@ tracing = { workspace = true, features = ["default"] } tracing-opentelemetry = { workspace = true } tracing-subscriber = { workspace = true, features = ["default", "env-filter"] } ttl_cache = { workspace = true } +utoipa = { workspace = true } +utoipa-swagger-ui = { workspace = true } uuid = { workspace = true, features = ["v4"] } x509-parser = "0.14.0" diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index 472c62f8b..8aebff559 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -28,6 +28,10 @@ use tokio::sync::mpsc::Sender; use tokio::sync::{Mutex, MutexGuard}; use tracing::{field, instrument, trace}; use ttl_cache::TtlCache; + +use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; +use utoipa::{Modify, OpenApi}; +use utoipa_swagger_ui::SwaggerUi; use uuid::Uuid; use x509_parser::nom::AsBytes; use x509_parser::parse_x509_certificate; @@ -81,6 +85,17 @@ impl StatusResponse { } #[instrument(skip(service))] +#[utoipa::path( + get, + path = "/projects/{project_name}", + responses( + (status = 200, description = "Successfully got a specific project information.", body = shuttle_common::models::project::Response), + (status = 500, description = "Server internal error.") + ), + params( + ("project_name" = String, Path, description = "The name of the project."), + ) +)] async fn get_project( State(RouterState { service, .. }): State, ScopedUser { scope, .. }: ScopedUser, @@ -94,6 +109,14 @@ async fn get_project( Ok(AxumJson(response)) } +#[utoipa::path( + get, + path = "/projects", + responses( + (status = 200, description = "Successfully got the projects list.", body = [shuttle_common::models::project::Response]), + (status = 500, description = "Server internal error.") + ) +)] async fn get_projects_list( State(RouterState { service, .. }): State, User { name, .. }: User, @@ -129,7 +152,18 @@ async fn get_projects_list( // } #[instrument(skip_all, fields(%project))] -async fn post_project( +#[utoipa::path( + post, + path = "/projects/{project_name}", + responses( + (status = 200, description = "Successfully created a specific project.", body = shuttle_common::models::project::Response), + (status = 500, description = "Server internal error.") + ), + params( + ("project_name" = String, Path, description = "The name of the project."), + ) +)] +async fn create_project( State(RouterState { service, sender, .. }): State, @@ -158,7 +192,18 @@ async fn post_project( } #[instrument(skip_all, fields(%project))] -async fn delete_project( +#[utoipa::path( + delete, + path = "/projects/{project_name}", + responses( + (status = 200, description = "Successfully destroyed a specific project.", body = shuttle_common::models::project::Response), + (status = 500, description = "Server internal error.") + ), + params( + ("project_name" = String, Path, description = "The name of the project."), + ) +)] +async fn destroy_project( State(RouterState { service, sender, .. }): State, @@ -204,6 +249,14 @@ async fn route_project( .await } +#[utoipa::path( + get, + path = "/", + responses( + (status = 200, description = "Get the gateway operational status."), + (status = 500, description = "Server internal error.") + ) +)] async fn get_status(State(RouterState { sender, .. }): State) -> Response { let (status, body) = if sender.is_closed() || sender.capacity() == 0 { ( @@ -224,6 +277,14 @@ async fn get_status(State(RouterState { sender, .. }): State) -> Re } #[instrument(skip_all)] +#[utoipa::path( + post, + path = "/stats/load", + responses( + (status = 200, description = "Successfully fetched the build queue load.", body = shuttle_common::models::stats::LoadResponse), + (status = 500, description = "Server internal error.") + ) +)] async fn post_load( State(RouterState { running_builds, .. }): State, AxumJson(build): AxumJson, @@ -246,6 +307,14 @@ async fn post_load( } #[instrument(skip_all)] +#[utoipa::path( + delete, + path = "/stats/load", + responses( + (status = 200, description = "Successfully removed the build with the ID specified in the load request from the build queue.", body = shuttle_common::models::stats::LoadResponse), + (status = 500, description = "Server internal error.") + ) +)] async fn delete_load( State(RouterState { running_builds, .. }): State, AxumJson(build): AxumJson, @@ -260,6 +329,14 @@ async fn delete_load( } #[instrument(skip_all)] +#[utoipa::path( + get, + path = "/admin/stats/load", + responses( + (status = 200, description = "Successfully gets the build queue load as an admin.", body = shuttle_common::models::stats::LoadResponse), + (status = 500, description = "Server internal error.") + ) +)] async fn get_load_admin( State(RouterState { running_builds, .. }): State, ) -> Result, Error> { @@ -271,6 +348,14 @@ async fn get_load_admin( } #[instrument(skip_all)] +#[utoipa::path( + delete, + path = "/admin/stats/load", + responses( + (status = 200, description = "Successfully clears the build queue.", body = shuttle_common::models::stats::LoadResponse), + (status = 500, description = "Server internal error.") + ) +)] async fn delete_load_admin( State(RouterState { running_builds, .. }): State, ) -> Result, Error> { @@ -294,6 +379,14 @@ fn calculate_capacity(running_builds: &mut MutexGuard>) -> st } #[instrument(skip_all)] +#[utoipa::path( + post, + path = "/admin/revive", + responses( + (status = 200, description = "Successfully revived stopped or errored projects."), + (status = 500, description = "Server internal error.") + ) +)] async fn revive_projects( State(RouterState { service, sender, .. @@ -305,6 +398,14 @@ async fn revive_projects( } #[instrument(skip_all)] +#[utoipa::path( + post, + path = "/admin/destroy", + responses( + (status = 200, description = "Successfully destroyed the projects."), + (status = 500, description = "Server internal error.") + ) +)] async fn destroy_projects( State(RouterState { service, sender, .. @@ -316,6 +417,18 @@ async fn destroy_projects( } #[instrument(skip_all, fields(%email, ?acme_server))] +#[utoipa::path( + post, + path = "/admin/acme/{email}", + responses( + (status = 200, description = "Created an acme account.", content_type = "application/json", body = String), + (status = 500, description = "Server internal error.") + ), + params( + ("email" = String, Path, description = "An email the acme account binds to."), + ), + +)] async fn create_acme_account( Extension(acme_client): Extension, Path(email): Path, @@ -327,6 +440,18 @@ async fn create_acme_account( } #[instrument(skip_all, fields(%project_name, %fqdn))] +#[utoipa::path( + post, + path = "/admin/acme/request/{project_name}/{fqdn}", + responses( + (status = 200, description = "Successfully requested a custom domain for the the project."), + (status = 500, description = "Server internal error.") + ), + params( + ("project_name" = String, Path, description = "The project name associated to the requested custom domain."), + ("fqdn" = String, Path, description = "The fqdn that represents the requested custom domain."), + ) +)] async fn request_custom_domain_acme_certificate( State(RouterState { service, sender, .. @@ -383,6 +508,18 @@ async fn request_custom_domain_acme_certificate( } #[instrument(skip_all, fields(%project_name, %fqdn))] +#[utoipa::path( + post, + path = "/admin/acme/renew/{project_name}/{fqdn}", + responses( + (status = 200, description = "Successfully renewed the project TLS certificate for the appointed custom domain fqdn."), + (status = 500, description = "Server internal error.") + ), + params( + ("project_name" = String, Path, description = "The project name associated to the requested custom domain."), + ("fqdn" = String, Path, description = "The fqdn that represents the requested custom domain."), + ) +)] async fn renew_custom_domain_acme_certificate( State(RouterState { service, .. }): State, Extension(acme_client): Extension, @@ -449,6 +586,14 @@ async fn renew_custom_domain_acme_certificate( } #[instrument(skip_all)] +#[utoipa::path( + post, + path = "/admin/acme/gateway/renew", + responses( + (status = 200, description = "Successfully renewed the gateway TLS certificate."), + (status = 500, description = "Server internal error.") + ) +)] async fn renew_gateway_acme_certificate( State(RouterState { service, .. }): State, Extension(acme_client): Extension, @@ -461,6 +606,14 @@ async fn renew_gateway_acme_certificate( Ok(r#""Renewed the gateway certificate.""#.to_string()) } +#[utoipa::path( + post, + path = "/admin/projects", + responses( + (status = 200, description = "Successfully fetched the projects list.", body = shuttle_common::models::project::AdminResponse), + (status = 500, description = "Server internal error.") + ) +)] async fn get_projects( State(RouterState { service, .. }): State, ) -> Result>, Error> { @@ -473,6 +626,50 @@ async fn get_projects( Ok(AxumJson(projects)) } +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "Gateway API Key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Bearer"))), + ) + } + } +} + +#[derive(OpenApi)] +#[openapi( + paths( + create_acme_account, + request_custom_domain_acme_certificate, + renew_custom_domain_acme_certificate, + renew_gateway_acme_certificate, + get_status, + get_projects_list, + get_project, + destroy_project, + create_project, + post_load, + delete_load, + get_projects, + revive_projects, + destroy_projects, + get_load_admin, + delete_load_admin + ), + modifiers(&SecurityAddon), + components(schemas( + shuttle_common::models::project::Response, + shuttle_common::models::stats::LoadResponse, + shuttle_common::models::project::AdminResponse, + shuttle_common::models::stats::LoadResponse, + shuttle_common::models::project::State + )) +)] +pub struct ApiDoc; + #[derive(Clone)] pub(crate) struct RouterState { pub service: Arc, @@ -582,8 +779,8 @@ impl ApiBuilder { .route( "/projects/:project_name", get(get_project.layer(ScopedLayer::new(vec![Scope::Project]))) - .delete(delete_project.layer(ScopedLayer::new(vec![Scope::ProjectCreate]))) - .post(post_project.layer(ScopedLayer::new(vec![Scope::ProjectCreate]))), + .delete(destroy_project.layer(ScopedLayer::new(vec![Scope::ProjectCreate]))) + .post(create_project.layer(ScopedLayer::new(vec![Scope::ProjectCreate]))), ) .route("/projects/:project_name/*any", any(route_project)) .route("/stats/load", post(post_load).delete(delete_load)) @@ -604,7 +801,14 @@ impl ApiBuilder { get(get_load_admin) .delete(delete_load_admin) .layer(ScopedLayer::new(vec![Scope::Admin])), - ); + ) + // TODO: The `/admin/swagger-ui` responds with a 303 See Other response which is followed in + // browsers but leads to 404 Not Found. This must be investigated. + .merge( + SwaggerUi::new("/admin/swagger-ui") + .url("/admin/api-docs/openapi.json", ApiDoc::openapi()), + ) + .layer(ScopedLayer::new(vec![Scope::Admin])); self }