Merge pull request 'admin api: implement InspectObject (fix #892)' (#1005) from inspect-object into next-v2
All checks were successful
ci/woodpecker/push/debug Pipeline was successful
ci/woodpecker/pr/debug Pipeline was successful

Reviewed-on: #1005
This commit is contained in:
Alex 2025-04-06 12:33:11 +00:00
commit 2d1c073d2f
8 changed files with 486 additions and 2 deletions

View file

@ -1054,6 +1054,51 @@
}
}
},
"/v2/InspectObject": {
"get": {
"tags": [
"Bucket"
],
"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": [
{
"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"
}
}
}
},
"404": {
"description": "Object not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/v2/LaunchRepairOperation": {
"post": {
"tags": [
@ -2571,6 +2616,146 @@
"ImportKeyResponse": {
"$ref": "#/components/schemas/GetKeyInfoResponse"
},
"InspectObjectBlock": {
"type": "object",
"required": [
"partNumber",
"offset",
"hash",
"size"
],
"properties": {
"hash": {
"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
}
}
},
"InspectObjectResponse": {
"type": "object",
"required": [
"bucketId",
"key",
"versions"
],
"properties": {
"bucketId": {
"type": "string",
"description": "ID of the bucket containing the inspected object"
},
"key": {
"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"
}
}
},
"InspectObjectVersion": {
"type": "object",
"required": [
"uuid",
"timestamp",
"encrypted",
"uploading",
"aborted",
"deleteMarker",
"inline"
],
"properties": {
"aborted": {
"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",
"description": "Whether this version is a delete marker (a tombstone indicating that a previous version of\nthe object has been deleted)"
},
"encrypted": {
"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",
"items": {
"type": "array",
"items": false,
"prefixItems": [
{
"type": "string"
},
{
"type": "string"
}
]
},
"description": "Metadata (HTTP headers) associated with this object version"
},
"inline": {
"type": "boolean",
"description": "Whether the object's data is stored inline (for small objects)"
},
"size": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Size of the object, in bytes",
"minimum": 0
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "Creation timestamp of this object version"
},
"uploading": {
"type": "boolean",
"description": "Whether this object version is still uploading"
},
"uuid": {
"type": "string",
"description": "Version ID"
}
}
},
"KeyInfoBucketResponse": {
"type": "object",
"required": [

View file

@ -80,6 +80,7 @@ admin_endpoints![
UpdateBucket,
DeleteBucket,
CleanupIncompleteUploads,
InspectObject,
// Operations on permissions for keys on buckets
AllowBucketKey,
@ -907,6 +908,67 @@ 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 {
/// 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<InspectObjectVersion>,
}
#[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<chrono::Utc>,
/// 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<u64>,
/// Etag of this object version
pub etag: Option<String>,
/// 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<InspectObjectBlock>,
}
#[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,
}
// **********************************************
// Operations on permissions for keys on buckets
// **********************************************

View file

@ -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<Garage>,
_admin: &Admin,
) -> Result<InspectObjectResponse, Error> {
let bucket_id = parse_bucket_id(&self.bucket_id)?;
let object = garage
.object_table
.get(&bucket_id, &self.key)
.await?
.ok_or_else(|| Error::NoSuchKey)?;
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::<Vec<_>>()
})
.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 {

View file

@ -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,
}
}

View file

@ -509,6 +509,28 @@ 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.
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")
),
)]
fn InspectObject() -> () {}
// **********************************************
// Operations on permissions for keys on buckets
// **********************************************
@ -872,6 +894,7 @@ impl Modify for SecurityAddon {
UpdateBucket,
DeleteBucket,
CleanupIncompleteUploads,
InspectObject,
// Operations on permissions
AllowBucketKey,
DenyBucketKey,

View file

@ -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
]
}

View file

@ -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) {

View file

@ -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<String>,
}
#[derive(StructOpt, Debug)]
pub struct InspectObjectOpt {
/// Name or ID of bucket
pub bucket: String,
/// Key of object to inspect
pub key: String,
}
// ------------------------
// ---- garage key ... ----
// ------------------------