From fd0e23e984a2a9fcc728e09111cbd2491c7ec751 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 6 Apr 2025 13:23:25 +0200 Subject: [PATCH 1/2] 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 ... ---- // ------------------------ From 5e7307cbf36215e4071978dbf6815b97acd3c8bc Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sun, 6 Apr 2025 14:19:48 +0200 Subject: [PATCH 2/2] admin api: add comments for InspectObject --- doc/api/garage-admin-v2.json | 51 +++++++++++++++++++++++++----------- src/api/admin/api.rs | 19 ++++++++++++++ src/api/admin/bucket.rs | 2 +- src/api/admin/error.rs | 8 +++++- src/api/admin/openapi.rs | 8 ++++++ 5 files changed, 71 insertions(+), 17 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index a7bea179..7819a0a6 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -1059,7 +1059,7 @@ "tags": [ "Bucket" ], - "description": "\nReturns detailed information about an object in a bucket, including its internal state in Garage.\n ", + "description": "\nReturns detailed information about an object in a bucket, including its internal state in Garage.\n\nThis API call can be used to list the data blocks referenced by an object,\nas well as to view metadata associated to the object.\n\nThis call may return a list of more than one version for the object, for instance in the\ncase where there is a currently stored version of the object, and a newer version whose\nupload is in progress and not yet finished.\n ", "operationId": "InspectObject", "parameters": [ { @@ -1090,6 +1090,9 @@ } } }, + "404": { + "description": "Object not found" + }, "500": { "description": "Internal server error" } @@ -2623,21 +2626,25 @@ ], "properties": { "hash": { - "type": "string" + "type": "string", + "description": "Hash (blake2 sum) of the block's data" }, "offset": { "type": "integer", "format": "int64", + "description": "Offset of this block within the part", "minimum": 0 }, "partNumber": { "type": "integer", "format": "int64", + "description": "Part number of the part containing this block, for multipart uploads", "minimum": 0 }, "size": { "type": "integer", "format": "int64", + "description": "Length of the blocks's data", "minimum": 0 } } @@ -2651,16 +2658,19 @@ ], "properties": { "bucketId": { - "type": "string" + "type": "string", + "description": "ID of the bucket containing the inspected object" }, "key": { - "type": "string" + "type": "string", + "description": "Key of the inspected object" }, "versions": { "type": "array", "items": { "$ref": "#/components/schemas/InspectObjectVersion" - } + }, + "description": "List of versions currently stored for this object" } } }, @@ -2677,25 +2687,30 @@ ], "properties": { "aborted": { - "type": "boolean" + "type": "boolean", + "description": "Whether this is an aborted upload" }, "blocks": { "type": "array", "items": { "$ref": "#/components/schemas/InspectObjectBlock" - } + }, + "description": "List of data blocks for this object version" }, "deleteMarker": { - "type": "boolean" + "type": "boolean", + "description": "Whether this version is a delete marker (a tombstone indicating that a previous version of\nthe object has been deleted)" }, "encrypted": { - "type": "boolean" + "type": "boolean", + "description": "Whether this object version was created with SSE-C encryption" }, "etag": { "type": [ "string", "null" - ] + ], + "description": "Etag of this object version" }, "headers": { "type": "array", @@ -2710,10 +2725,12 @@ "type": "string" } ] - } + }, + "description": "Metadata (HTTP headers) associated with this object version" }, "inline": { - "type": "boolean" + "type": "boolean", + "description": "Whether the object's data is stored inline (for small objects)" }, "size": { "type": [ @@ -2721,17 +2738,21 @@ "null" ], "format": "int64", + "description": "Size of the object, in bytes", "minimum": 0 }, "timestamp": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Creation timestamp of this object version" }, "uploading": { - "type": "boolean" + "type": "boolean", + "description": "Whether this object version is still uploading" }, "uuid": { - "type": "string" + "type": "string", + "description": "Version ID" } } }, diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 97f4583b..ffb9456b 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -918,25 +918,40 @@ pub struct InspectObjectRequest { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct InspectObjectResponse { + /// ID of the bucket containing the inspected object pub bucket_id: String, + /// Key of the inspected object pub key: String, + /// List of versions currently stored for this object pub versions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] #[serde(rename_all = "camelCase")] pub struct InspectObjectVersion { + /// Version ID pub uuid: String, + /// Creation timestamp of this object version pub timestamp: chrono::DateTime, + /// Whether this object version was created with SSE-C encryption pub encrypted: bool, + /// Whether this object version is still uploading pub uploading: bool, + /// Whether this is an aborted upload pub aborted: bool, + /// Whether this version is a delete marker (a tombstone indicating that a previous version of + /// the object has been deleted) pub delete_marker: bool, + /// Whether the object's data is stored inline (for small objects) pub inline: bool, + /// Size of the object, in bytes pub size: Option, + /// Etag of this object version pub etag: Option, + /// Metadata (HTTP headers) associated with this object version #[serde(default, skip_serializing_if = "Vec::is_empty")] pub headers: Vec<(String, String)>, + /// List of data blocks for this object version #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocks: Vec, } @@ -944,9 +959,13 @@ pub struct InspectObjectVersion { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct InspectObjectBlock { + /// Part number of the part containing this block, for multipart uploads pub part_number: u64, + /// Offset of this block within the part pub offset: u64, + /// Hash (blake2 sum) of the block's data pub hash: String, + /// Length of the blocks's data pub size: u64, } diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index d825dfb4..af26200b 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -365,7 +365,7 @@ impl RequestHandler for InspectObjectRequest { .object_table .get(&bucket_id, &self.key) .await? - .ok_or_else(|| Error::bad_request("object not found"))?; + .ok_or_else(|| Error::NoSuchKey)?; let mut versions = vec![]; for obj_ver in object.versions().iter() { diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index f12a936e..8fbbc895 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -37,6 +37,10 @@ pub enum Error { #[error(display = "Worker not found: {}", _0)] NoSuchWorker(u64), + /// The object requested don't exists + #[error(display = "Key not found")] + NoSuchKey, + /// In Import key, the key already exists #[error( display = "Key {} already exists in data store. Even if it is deleted, we can't let you create a new key with the same ID. Sorry.", @@ -69,6 +73,7 @@ impl Error { Error::NoSuchWorker(_) => "NoSuchWorker", Error::NoSuchBlock(_) => "NoSuchBlock", Error::KeyAlreadyExists(_) => "KeyAlreadyExists", + Error::NoSuchKey => "NoSuchKey", } } } @@ -81,7 +86,8 @@ impl ApiError for Error { Error::NoSuchAdminToken(_) | Error::NoSuchAccessKey(_) | Error::NoSuchWorker(_) - | Error::NoSuchBlock(_) => StatusCode::NOT_FOUND, + | Error::NoSuchBlock(_) + | Error::NoSuchKey => StatusCode::NOT_FOUND, Error::KeyAlreadyExists(_) => StatusCode::CONFLICT, } } diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 6e9cb5e1..f1b90676 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -514,10 +514,18 @@ fn CleanupIncompleteUploads() -> () {} tag = "Bucket", description = " Returns detailed information about an object in a bucket, including its internal state in Garage. + +This API call can be used to list the data blocks referenced by an object, +as well as to view metadata associated to the object. + +This call may return a list of more than one version for the object, for instance in the +case where there is a currently stored version of the object, and a newer version whose +upload is in progress and not yet finished. ", params(InspectObjectRequest), responses( (status = 200, description = "Returns exhaustive information about the object", body = InspectObjectResponse), + (status = 404, description = "Object not found"), (status = 500, description = "Internal server error") ), )]