admin api: implement InspectObject (fix #892)
All checks were successful
ci/woodpecker/pr/debug Pipeline was successful
ci/woodpecker/push/debug Pipeline was successful

This commit is contained in:
Alex 2025-04-06 13:23:25 +02:00
parent d7506b282c
commit fd0e23e984
7 changed files with 431 additions and 1 deletions

View file

@ -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": [

View file

@ -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<InspectObjectVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
#[serde(rename_all = "camelCase")]
pub struct InspectObjectVersion {
pub uuid: String,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub encrypted: bool,
pub uploading: bool,
pub aborted: bool,
pub delete_marker: bool,
pub inline: bool,
pub size: Option<u64>,
pub etag: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<(String, String)>,
#[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 {
pub part_number: u64,
pub offset: u64,
pub hash: String,
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::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::<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

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

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 ... ----
// ------------------------