From fd0e23e984a2a9fcc728e09111cbd2491c7ec751 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 6 Apr 2025 13:23:25 +0200 Subject: [PATCH] admin api: implement InspectObject (fix #892) --- doc/api/garage-admin-v2.json | 164 ++++++++++++++++++++++++++++++++ src/api/admin/api.rs | 43 +++++++++ src/api/admin/bucket.rs | 123 ++++++++++++++++++++++++ src/api/admin/openapi.rs | 15 +++ src/api/admin/router_v2.rs | 5 +- src/garage/cli/remote/bucket.rs | 70 ++++++++++++++ src/garage/cli/structs.rs | 12 +++ 7 files changed, 431 insertions(+), 1 deletion(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 31aaa915..a7bea179 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -1054,6 +1054,48 @@ } } }, + "/v2/InspectObject": { + "get": { + "tags": [ + "Bucket" + ], + "description": "\nReturns detailed information about an object in a bucket, including its internal state in Garage.\n ", + "operationId": "InspectObject", + "parameters": [ + { + "name": "bucketId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Returns exhaustive information about the object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InspectObjectResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/LaunchRepairOperation": { "post": { "tags": [ @@ -2571,6 +2613,128 @@ "ImportKeyResponse": { "$ref": "#/components/schemas/GetKeyInfoResponse" }, + "InspectObjectBlock": { + "type": "object", + "required": [ + "partNumber", + "offset", + "hash", + "size" + ], + "properties": { + "hash": { + "type": "string" + }, + "offset": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "partNumber": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "InspectObjectResponse": { + "type": "object", + "required": [ + "bucketId", + "key", + "versions" + ], + "properties": { + "bucketId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectObjectVersion" + } + } + } + }, + "InspectObjectVersion": { + "type": "object", + "required": [ + "uuid", + "timestamp", + "encrypted", + "uploading", + "aborted", + "deleteMarker", + "inline" + ], + "properties": { + "aborted": { + "type": "boolean" + }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectObjectBlock" + } + }, + "deleteMarker": { + "type": "boolean" + }, + "encrypted": { + "type": "boolean" + }, + "etag": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": "array", + "items": { + "type": "array", + "items": false, + "prefixItems": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + }, + "inline": { + "type": "boolean" + }, + "size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "uploading": { + "type": "boolean" + }, + "uuid": { + "type": "string" + } + } + }, "KeyInfoBucketResponse": { "type": "object", "required": [ diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index b865ac88..97f4583b 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -80,6 +80,7 @@ admin_endpoints![ UpdateBucket, DeleteBucket, CleanupIncompleteUploads, + InspectObject, // Operations on permissions for keys on buckets AllowBucketKey, @@ -907,6 +908,48 @@ pub struct CleanupIncompleteUploadsResponse { pub uploads_deleted: u64, } +#[derive(Debug, Clone, Serialize, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct InspectObjectRequest { + pub bucket_id: String, + pub key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct InspectObjectResponse { + pub bucket_id: String, + pub key: String, + pub versions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct InspectObjectVersion { + pub uuid: String, + pub timestamp: chrono::DateTime, + pub encrypted: bool, + pub uploading: bool, + pub aborted: bool, + pub delete_marker: bool, + pub inline: bool, + pub size: Option, + pub etag: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub headers: Vec<(String, String)>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct InspectObjectBlock { + pub part_number: u64, + pub offset: u64, + pub hash: String, + pub size: u64, +} + // ********************************************** // Operations on permissions for keys on buckets // ********************************************** diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index ce12b4cf..d825dfb4 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use chrono::DateTime; + use garage_util::crdt::*; use garage_util::data::*; use garage_util::time::*; @@ -349,6 +351,127 @@ impl RequestHandler for CleanupIncompleteUploadsRequest { } } +impl RequestHandler for InspectObjectRequest { + type Response = InspectObjectResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let bucket_id = parse_bucket_id(&self.bucket_id)?; + + let object = garage + .object_table + .get(&bucket_id, &self.key) + .await? + .ok_or_else(|| Error::bad_request("object not found"))?; + + let mut versions = vec![]; + for obj_ver in object.versions().iter() { + let ver = garage.version_table.get(&obj_ver.uuid, &EmptyKey).await?; + let blocks = ver + .map(|v| { + v.blocks + .items() + .iter() + .map(|(vk, vb)| InspectObjectBlock { + part_number: vk.part_number, + offset: vk.offset, + hash: hex::encode(&vb.hash), + size: vb.size, + }) + .collect::>() + }) + .unwrap_or_default(); + let uuid = hex::encode(&obj_ver.uuid); + let timestamp = DateTime::from_timestamp_millis(obj_ver.timestamp as i64) + .expect("invalid timestamp in db"); + match &obj_ver.state { + ObjectVersionState::Uploading { encryption, .. } => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + encrypted: !matches!(encryption, ObjectVersionEncryption::Plaintext { .. }), + uploading: true, + headers: match encryption { + ObjectVersionEncryption::Plaintext { inner } => inner.headers.clone(), + _ => vec![], + }, + blocks, + ..Default::default() + }); + } + ObjectVersionState::Complete(data) => match data { + ObjectVersionData::DeleteMarker => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + delete_marker: true, + ..Default::default() + }); + } + ObjectVersionData::Inline(meta, _) => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + inline: true, + size: Some(meta.size), + etag: Some(meta.etag.clone()), + encrypted: !matches!( + meta.encryption, + ObjectVersionEncryption::Plaintext { .. } + ), + headers: match &meta.encryption { + ObjectVersionEncryption::Plaintext { inner } => { + inner.headers.clone() + } + _ => vec![], + }, + ..Default::default() + }); + } + ObjectVersionData::FirstBlock(meta, _) => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + size: Some(meta.size), + etag: Some(meta.etag.clone()), + encrypted: !matches!( + meta.encryption, + ObjectVersionEncryption::Plaintext { .. } + ), + headers: match &meta.encryption { + ObjectVersionEncryption::Plaintext { inner } => { + inner.headers.clone() + } + _ => vec![], + }, + blocks, + ..Default::default() + }); + } + }, + ObjectVersionState::Aborted => { + versions.push(InspectObjectVersion { + uuid, + timestamp, + aborted: true, + blocks, + ..Default::default() + }); + } + } + } + + Ok(InspectObjectResponse { + bucket_id: hex::encode(&object.bucket_id), + key: object.key, + versions, + }) + } +} + // ---- BUCKET/KEY PERMISSIONS ---- impl RequestHandler for AllowBucketKeyRequest { diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index b7ffdcf1..6e9cb5e1 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -509,6 +509,20 @@ fn DeleteBucket() -> () {} )] fn CleanupIncompleteUploads() -> () {} +#[utoipa::path(get, + path = "/v2/InspectObject", + tag = "Bucket", + description = " +Returns detailed information about an object in a bucket, including its internal state in Garage. + ", + params(InspectObjectRequest), + responses( + (status = 200, description = "Returns exhaustive information about the object", body = InspectObjectResponse), + (status = 500, description = "Internal server error") + ), +)] +fn InspectObject() -> () {} + // ********************************************** // Operations on permissions for keys on buckets // ********************************************** @@ -872,6 +886,7 @@ impl Modify for SecurityAddon { UpdateBucket, DeleteBucket, CleanupIncompleteUploads, + InspectObject, // Operations on permissions AllowBucketKey, DenyBucketKey, diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 73f98308..3051dae4 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -62,6 +62,7 @@ impl AdminApiRequest { POST DeleteBucket (query::id), POST UpdateBucket (body_field, query::id), POST CleanupIncompleteUploads (body), + GET InspectObject (query::bucket_id, query::key), // Bucket-key permissions POST AllowBucketKey (body), POST DenyBucketKey (body), @@ -267,6 +268,8 @@ generateQueryParameters! { "globalAlias" => global_alias, "alias" => alias, "accessKeyId" => access_key_id, - "showSecretKey" => show_secret_key + "showSecretKey" => show_secret_key, + "bucketId" => bucket_id, + "key" => key ] } diff --git a/src/garage/cli/remote/bucket.rs b/src/garage/cli/remote/bucket.rs index 09e3de64..bc018b33 100644 --- a/src/garage/cli/remote/bucket.rs +++ b/src/garage/cli/remote/bucket.rs @@ -24,6 +24,7 @@ impl Cli { BucketOperation::CleanupIncompleteUploads(query) => { self.cmd_cleanup_incomplete_uploads(query).await } + BucketOperation::InspectObject(query) => self.cmd_inspect_object(query).await, } } @@ -407,6 +408,75 @@ impl Cli { Ok(()) } + + pub async fn cmd_inspect_object(&self, opt: InspectObjectOpt) -> Result<(), Error> { + let bucket = self + .api_request(GetBucketInfoRequest { + id: None, + global_alias: None, + search: Some(opt.bucket), + }) + .await?; + + let info = self + .api_request(InspectObjectRequest { + bucket_id: bucket.id, + key: opt.key, + }) + .await?; + + for ver in info.versions { + println!("==== OBJECT VERSION ===="); + let mut tab = vec![ + format!("Bucket ID:\t{}", info.bucket_id), + format!("Key:\t{}", info.key), + format!("Version ID:\t{}", ver.uuid), + format!("Timestamp:\t{}", ver.timestamp), + ]; + if let Some(size) = ver.size { + let bs = bytesize::ByteSize::b(size); + tab.push(format!( + "Size:\t{} ({})", + bs.to_string_as(true), + bs.to_string_as(false) + )); + tab.push(format!("Size (exact):\t{}", size)); + if !ver.blocks.is_empty() { + tab.push(format!("Number of blocks:\t{:?}", ver.blocks.len())); + } + } + if let Some(etag) = ver.etag { + tab.push(format!("Etag:\t{}", etag)); + } + tab.extend([ + format!("Encrypted:\t{}", ver.encrypted), + format!("Uploading:\t{}", ver.uploading), + format!("Aborted:\t{}", ver.aborted), + format!("Delete marker:\t{}", ver.delete_marker), + format!("Inline data:\t{}", ver.inline), + ]); + if !ver.headers.is_empty() { + tab.push(String::new()); + tab.extend(ver.headers.iter().map(|(k, v)| format!("{}\t{}", k, v))); + } + format_table(tab); + + if !ver.blocks.is_empty() { + let mut tab = vec!["Part#\tOffset\tBlock hash\tSize".to_string()]; + tab.extend(ver.blocks.iter().map(|b| { + format!( + "{:4}\t{:9}\t{}\t{:9}", + b.part_number, b.offset, b.hash, b.size + ) + })); + println!(); + format_table(tab); + } + println!(); + } + + Ok(()) + } } fn print_bucket_info(bucket: &GetBucketInfoResponse) { diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 9a6d912c..20079709 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -265,6 +265,10 @@ pub enum BucketOperation { /// Clean up (abort) old incomplete multipart uploads #[structopt(name = "cleanup-incomplete-uploads", version = garage_version())] CleanupIncompleteUploads(CleanupIncompleteUploadsOpt), + + /// Inspect an object in a bucket + #[structopt(name = "inspect-object", version = garage_version())] + InspectObject(InspectObjectOpt), } #[derive(StructOpt, Debug)] @@ -377,6 +381,14 @@ pub struct CleanupIncompleteUploadsOpt { pub buckets: Vec, } +#[derive(StructOpt, Debug)] +pub struct InspectObjectOpt { + /// Name or ID of bucket + pub bucket: String, + /// Key of object to inspect + pub key: String, +} + // ------------------------ // ---- garage key ... ---- // ------------------------