diff options
-rw-r--r-- | doc/api/garage-admin-v0.yml | 6 | ||||
-rw-r--r-- | doc/book/connect/_index.md | 7 | ||||
-rw-r--r-- | doc/book/connect/apps/index.md | 65 | ||||
-rw-r--r-- | doc/book/connect/observability.md | 57 | ||||
-rw-r--r-- | src/api/admin/api_server.rs | 48 | ||||
-rw-r--r-- | src/api/admin/router.rs | 3 | ||||
-rw-r--r-- | src/garage/tests/common/garage.rs | 2 | ||||
-rw-r--r-- | src/garage/tests/s3/website.rs | 136 |
8 files changed, 319 insertions, 5 deletions
diff --git a/doc/api/garage-admin-v0.yml b/doc/api/garage-admin-v0.yml index a841f8d9..51968894 100644 --- a/doc/api/garage-admin-v0.yml +++ b/doc/api/garage-admin-v0.yml @@ -678,10 +678,12 @@ paths: properties: maxSize: type: integer + format: int64 nullable: true example: 19029801 maxObjects: type: integer + format: int64 nullable: true example: null @@ -1158,9 +1160,11 @@ components: $ref: '#/components/schemas/BucketKeyInfo' objects: type: integer + format: int64 example: 14827 bytes: type: integer + format: int64 example: 13189855625 unfinishedUploads: type: integer @@ -1171,10 +1175,12 @@ components: maxSize: nullable: true type: integer + format: int64 example: null maxObjects: nullable: true type: integer + format: int64 example: null diff --git a/doc/book/connect/_index.md b/doc/book/connect/_index.md index ca44ac17..93a2b87e 100644 --- a/doc/book/connect/_index.md +++ b/doc/book/connect/_index.md @@ -10,11 +10,12 @@ Garage implements the Amazon S3 protocol, which makes it compatible with many ex In particular, you will find here instructions to connect it with: - - [Browsing tools](@/documentation/connect/cli.md) - [Applications](@/documentation/connect/apps/index.md) - - [Website hosting](@/documentation/connect/websites.md) - - [Software repositories](@/documentation/connect/repositories.md) + - [Browsing tools](@/documentation/connect/cli.md) - [FUSE](@/documentation/connect/fs.md) + - [Observability](@/documentation/connect/observability.md) + - [Software repositories](@/documentation/connect/repositories.md) + - [Website hosting](@/documentation/connect/websites.md) ### Generic instructions diff --git a/doc/book/connect/apps/index.md b/doc/book/connect/apps/index.md index 737351a0..4d556ff8 100644 --- a/doc/book/connect/apps/index.md +++ b/doc/book/connect/apps/index.md @@ -13,7 +13,7 @@ In this section, we cover the following web applications: | [Matrix](#matrix) | ✅ | Tested with `synapse-s3-storage-provider` | | [Pixelfed](#pixelfed) | ❓ | Not yet tested | | [Pleroma](#pleroma) | ❓ | Not yet tested | -| [Lemmy](#lemmy) | ❓ | Not yet tested | +| [Lemmy](#lemmy) | ✅ | Supported with pict-rs | | [Funkwhale](#funkwhale) | ❓ | Not yet tested | | [Misskey](#misskey) | ❓ | Not yet tested | | [Prismo](#prismo) | ❓ | Not yet tested | @@ -484,7 +484,68 @@ And add a new line. For example, to run it every 10 minutes: ## Lemmy -Lemmy uses pict-rs that [supports S3 backends](https://git.asonix.dog/asonix/pict-rs/commit/f9f4fc63d670f357c93f24147c2ee3e1278e2d97) +Lemmy uses pict-rs that [supports S3 backends](https://git.asonix.dog/asonix/pict-rs/commit/f9f4fc63d670f357c93f24147c2ee3e1278e2d97). +This feature requires `pict-rs >= 4.0.0`. + +### Creating your bucket + +This is the usual Garage setup: + +```bash +garage key new --name pictrs-key +garage bucket create pictrs-data +garage bucket allow pictrs-data --read --write --key pictrs-key +``` + +Note the Key ID and Secret Key. + +### Migrating your data + +If your pict-rs instance holds existing data, you first need to migrate to the S3 bucket. + +Stop pict-rs, then run the migration utility from local filesystem to the bucket: + +``` +pict-rs \ + filesystem -p /path/to/existing/files \ + object-store \ + -e my-garage-instance.mydomain.tld:3900 \ + -b pictrs-data \ + -r garage \ + -a GK... \ + -s abcdef0123456789... +``` + +This is pretty slow, so hold on while migrating. + +### Running pict-rs with an S3 backend + +Pict-rs supports both a configuration file and environment variables. + +Either set the following section in your `pict-rs.toml`: + +``` +[store] +type = 'object_storage' +endpoint = 'http://my-garage-instance.mydomain.tld:3900' +bucket_name = 'pictrs-data' +region = 'garage' +access_key = 'GK...' +secret_key = 'abcdef0123456789...' +``` + +... or set these environment variables: + + +``` +PICTRS__STORE__TYPE=object_storage +PICTRS__STORE__ENDPOINT=http:/my-garage-instance.mydomain.tld:3900 +PICTRS__STORE__BUCKET_NAME=pictrs-data +PICTRS__STORE__REGION=garage +PICTRS__STORE__ACCESS_KEY=GK... +PICTRS__STORE__SECRET_KEY=abcdef0123456789... +``` + ## Funkwhale diff --git a/doc/book/connect/observability.md b/doc/book/connect/observability.md new file mode 100644 index 00000000..c5037fa4 --- /dev/null +++ b/doc/book/connect/observability.md @@ -0,0 +1,57 @@ ++++ +title = "Observability" +weight = 25 ++++ + +An object store can be used as data storage location for metrics, and logs which +can then be leveraged for systems observability. + +## Metrics + +### Prometheus + +Prometheus itself has no object store capabilities, however two projects exist +which support storing metrics in an object store: + + - [Cortex](https://cortexmetrics.io/) + - [Thanos](https://thanos.io/) + +## System logs + +### Vector + +[Vector](https://vector.dev/) natively supports S3 as a +[data sink](https://vector.dev/docs/reference/configuration/sinks/aws_s3/) +(and [source](https://vector.dev/docs/reference/configuration/sources/aws_s3/)). + +This can be configured with Garage with the following: + +```bash +garage key new --name vector-system-logs +garage bucket create system-logs +garage bucket allow system-logs --read --write --key vector-system-logs +``` + +The `vector.toml` can then be configured as follows: + +```toml +[sources.journald] +type = "journald" +current_boot_only = true + +[sinks.out] +encoding.codec = "json" +type = "aws_s3" +inputs = [ "journald" ] +bucket = "system-logs" +key_prefix = "%F/" +compression = "none" +region = "garage" +endpoint = "https://my-garage-instance.mydomain.tld" +auth.access_key_id = "" +auth.secret_access_key = "" +``` + +This is an example configuration - please refer to the Vector documentation for +all configuration and transformation possibilities. Also note that Garage +performs its own compression, so this should be disabled in Vector. diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 2d325fb1..7a534f32 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -77,6 +77,53 @@ impl AdminApiServer { .body(Body::empty())?) } + async fn handle_check_website_enabled( + &self, + req: Request<Body>, + ) -> Result<Response<Body>, Error> { + let has_domain_header = req.headers().contains_key("domain"); + + if !has_domain_header { + return Err(Error::bad_request("No domain header found")); + } + + let domain = &req + .headers() + .get("domain") + .ok_or_internal_error("Could not parse domain header")?; + + let domain_string = String::from( + domain + .to_str() + .ok_or_bad_request("Invalid characters found in domain header")?, + ); + + let bucket_id = self + .garage + .bucket_helper() + .resolve_global_bucket_name(&domain_string) + .await? + .ok_or_else(|| HelperError::NoSuchBucket(domain_string))?; + + let bucket = self + .garage + .bucket_helper() + .get_existing_bucket(bucket_id) + .await?; + + let bucket_state = bucket.state.as_option().unwrap(); + let bucket_website_config = bucket_state.website_config.get(); + + match bucket_website_config { + Some(_v) => Ok(Response::builder() + .status(StatusCode::OK) + .body(Body::from("Bucket authorized for website hosting"))?), + None => Err(Error::bad_request( + "Bucket is not authorized for website hosting", + )), + } + } + fn handle_health(&self) -> Result<Response<Body>, Error> { let health = self.garage.system.health(); @@ -174,6 +221,7 @@ impl ApiHandler for AdminApiServer { match endpoint { Endpoint::Options => self.handle_options(&req), + Endpoint::CheckWebsiteEnabled => self.handle_check_website_enabled(req).await, Endpoint::Health => self.handle_health(), Endpoint::Metrics => self.handle_metrics(), Endpoint::GetClusterStatus => handle_get_cluster_status(&self.garage).await, diff --git a/src/api/admin/router.rs b/src/api/admin/router.rs index 62e6abc3..0dcb1546 100644 --- a/src/api/admin/router.rs +++ b/src/api/admin/router.rs @@ -17,6 +17,7 @@ router_match! {@func #[derive(Debug, Clone, PartialEq, Eq)] pub enum Endpoint { Options, + CheckWebsiteEnabled, Health, Metrics, GetClusterStatus, @@ -91,6 +92,7 @@ impl Endpoint { let res = router_match!(@gen_path_parser (req.method(), path, query) [ OPTIONS _ => Options, + GET "/check" => CheckWebsiteEnabled, GET "/health" => Health, GET "/metrics" => Metrics, GET "/v0/status" => GetClusterStatus, @@ -136,6 +138,7 @@ impl Endpoint { pub fn authorization_type(&self) -> Authorization { match self { Self::Health => Authorization::None, + Self::CheckWebsiteEnabled => Authorization::None, Self::Metrics => Authorization::MetricsToken, _ => Authorization::AdminToken, } diff --git a/src/garage/tests/common/garage.rs b/src/garage/tests/common/garage.rs index 44d727f9..8f994f49 100644 --- a/src/garage/tests/common/garage.rs +++ b/src/garage/tests/common/garage.rs @@ -25,6 +25,7 @@ pub struct Instance { pub s3_port: u16, pub k2v_port: u16, pub web_port: u16, + pub admin_port: u16, } impl Instance { @@ -105,6 +106,7 @@ api_bind_addr = "127.0.0.1:{admin_port}" s3_port: port, k2v_port: port + 1, web_port: port + 3, + admin_port: port + 4, } } diff --git a/src/garage/tests/s3/website.rs b/src/garage/tests/s3/website.rs index 244a2fa0..f57e31ee 100644 --- a/src/garage/tests/s3/website.rs +++ b/src/garage/tests/s3/website.rs @@ -1,5 +1,8 @@ use crate::common; use crate::common::ext::*; +use crate::k2v::json_body; + +use assert_json_diff::assert_json_eq; use aws_sdk_s3::{ model::{CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, WebsiteConfiguration}, types::ByteStream, @@ -9,6 +12,7 @@ use hyper::{ body::{to_bytes, Body}, Client, }; +use serde_json::json; const BODY: &[u8; 16] = b"<h1>bonjour</h1>"; const BODY_ERR: &[u8; 6] = b"erreur"; @@ -49,6 +53,28 @@ async fn test_website() { BODY.as_ref() ); /* check that we do not leak body */ + let admin_req = || { + Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/check", ctx.garage.admin_port)) + .header("domain", format!("{}", BCKT_NAME)) + .body(Body::empty()) + .unwrap() + }; + + let admin_resp = client.request(admin_req()).await.unwrap(); + assert_eq!(admin_resp.status(), StatusCode::BAD_REQUEST); + let res_body = json_body(admin_resp).await; + assert_json_eq!( + res_body, + json!({ + "code": "InvalidRequest", + "message": "Bad request: Bucket is not authorized for website hosting", + "region": "garage-integ-test", + "path": "/check", + }) + ); + ctx.garage .command() .args(["bucket", "website", "--allow", BCKT_NAME]) @@ -62,6 +88,22 @@ async fn test_website() { BODY.as_ref() ); + let admin_req = || { + Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/check", ctx.garage.admin_port)) + .header("domain", format!("{}", BCKT_NAME)) + .body(Body::empty()) + .unwrap() + }; + + let mut admin_resp = client.request(admin_req()).await.unwrap(); + assert_eq!(admin_resp.status(), StatusCode::OK); + assert_eq!( + to_bytes(admin_resp.body_mut()).await.unwrap().as_ref(), + b"Bucket authorized for website hosting" + ); + ctx.garage .command() .args(["bucket", "website", "--deny", BCKT_NAME]) @@ -74,6 +116,28 @@ async fn test_website() { to_bytes(resp.body_mut()).await.unwrap().as_ref(), BODY.as_ref() ); /* check that we do not leak body */ + + let admin_req = || { + Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/check", ctx.garage.admin_port)) + .header("domain", format!("{}", BCKT_NAME)) + .body(Body::empty()) + .unwrap() + }; + + let admin_resp = client.request(admin_req()).await.unwrap(); + assert_eq!(admin_resp.status(), StatusCode::BAD_REQUEST); + let res_body = json_body(admin_resp).await; + assert_json_eq!( + res_body, + json!({ + "code": "InvalidRequest", + "message": "Bad request: Bucket is not authorized for website hosting", + "region": "garage-integ-test", + "path": "/check", + }) + ); } #[tokio::test] @@ -322,3 +386,75 @@ async fn test_website_s3_api() { ); } } + +#[tokio::test] +async fn test_website_check_website_enabled() { + let ctx = common::context(); + + let client = Client::new(); + + let admin_req = || { + Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/check", ctx.garage.admin_port)) + .body(Body::empty()) + .unwrap() + }; + + let admin_resp = client.request(admin_req()).await.unwrap(); + assert_eq!(admin_resp.status(), StatusCode::BAD_REQUEST); + let res_body = json_body(admin_resp).await; + assert_json_eq!( + res_body, + json!({ + "code": "InvalidRequest", + "message": "Bad request: No domain header found", + "region": "garage-integ-test", + "path": "/check", + }) + ); + + let admin_req = || { + Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/check", ctx.garage.admin_port)) + .header("domain", "foobar") + .body(Body::empty()) + .unwrap() + }; + + let admin_resp = client.request(admin_req()).await.unwrap(); + assert_eq!(admin_resp.status(), StatusCode::NOT_FOUND); + let res_body = json_body(admin_resp).await; + assert_json_eq!( + res_body, + json!({ + "code": "NoSuchBucket", + "message": "Bucket not found: foobar", + "region": "garage-integ-test", + "path": "/check", + }) + ); + + let admin_req = || { + Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/check", ctx.garage.admin_port)) + .header("domain", "☹") + .body(Body::empty()) + .unwrap() + }; + + let admin_resp = client.request(admin_req()).await.unwrap(); + assert_eq!(admin_resp.status(), StatusCode::BAD_REQUEST); + let res_body = json_body(admin_resp).await; + assert_json_eq!( + res_body, + json!({ + "code": "InvalidRequest", + "message": "Bad request: Invalid characters found in domain header: failed to convert header to a str", + "region": "garage-integ-test", + "path": "/check", + }) + ); +} |