admin api: implement InspectObject (fix #892)
This commit is contained in:
parent
d7506b282c
commit
fd0e23e984
7 changed files with 431 additions and 1 deletions
|
@ -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": {
|
"/v2/LaunchRepairOperation": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
@ -2571,6 +2613,128 @@
|
||||||
"ImportKeyResponse": {
|
"ImportKeyResponse": {
|
||||||
"$ref": "#/components/schemas/GetKeyInfoResponse"
|
"$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": {
|
"KeyInfoBucketResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|
|
@ -80,6 +80,7 @@ admin_endpoints![
|
||||||
UpdateBucket,
|
UpdateBucket,
|
||||||
DeleteBucket,
|
DeleteBucket,
|
||||||
CleanupIncompleteUploads,
|
CleanupIncompleteUploads,
|
||||||
|
InspectObject,
|
||||||
|
|
||||||
// Operations on permissions for keys on buckets
|
// Operations on permissions for keys on buckets
|
||||||
AllowBucketKey,
|
AllowBucketKey,
|
||||||
|
@ -907,6 +908,48 @@ pub struct CleanupIncompleteUploadsResponse {
|
||||||
pub uploads_deleted: u64,
|
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
|
// Operations on permissions for keys on buckets
|
||||||
// **********************************************
|
// **********************************************
|
||||||
|
|
|
@ -2,6 +2,8 @@ use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::DateTime;
|
||||||
|
|
||||||
use garage_util::crdt::*;
|
use garage_util::crdt::*;
|
||||||
use garage_util::data::*;
|
use garage_util::data::*;
|
||||||
use garage_util::time::*;
|
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 ----
|
// ---- BUCKET/KEY PERMISSIONS ----
|
||||||
|
|
||||||
impl RequestHandler for AllowBucketKeyRequest {
|
impl RequestHandler for AllowBucketKeyRequest {
|
||||||
|
|
|
@ -509,6 +509,20 @@ fn DeleteBucket() -> () {}
|
||||||
)]
|
)]
|
||||||
fn CleanupIncompleteUploads() -> () {}
|
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
|
// Operations on permissions for keys on buckets
|
||||||
// **********************************************
|
// **********************************************
|
||||||
|
@ -872,6 +886,7 @@ impl Modify for SecurityAddon {
|
||||||
UpdateBucket,
|
UpdateBucket,
|
||||||
DeleteBucket,
|
DeleteBucket,
|
||||||
CleanupIncompleteUploads,
|
CleanupIncompleteUploads,
|
||||||
|
InspectObject,
|
||||||
// Operations on permissions
|
// Operations on permissions
|
||||||
AllowBucketKey,
|
AllowBucketKey,
|
||||||
DenyBucketKey,
|
DenyBucketKey,
|
||||||
|
|
|
@ -62,6 +62,7 @@ impl AdminApiRequest {
|
||||||
POST DeleteBucket (query::id),
|
POST DeleteBucket (query::id),
|
||||||
POST UpdateBucket (body_field, query::id),
|
POST UpdateBucket (body_field, query::id),
|
||||||
POST CleanupIncompleteUploads (body),
|
POST CleanupIncompleteUploads (body),
|
||||||
|
GET InspectObject (query::bucket_id, query::key),
|
||||||
// Bucket-key permissions
|
// Bucket-key permissions
|
||||||
POST AllowBucketKey (body),
|
POST AllowBucketKey (body),
|
||||||
POST DenyBucketKey (body),
|
POST DenyBucketKey (body),
|
||||||
|
@ -267,6 +268,8 @@ generateQueryParameters! {
|
||||||
"globalAlias" => global_alias,
|
"globalAlias" => global_alias,
|
||||||
"alias" => alias,
|
"alias" => alias,
|
||||||
"accessKeyId" => access_key_id,
|
"accessKeyId" => access_key_id,
|
||||||
"showSecretKey" => show_secret_key
|
"showSecretKey" => show_secret_key,
|
||||||
|
"bucketId" => bucket_id,
|
||||||
|
"key" => key
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ impl Cli {
|
||||||
BucketOperation::CleanupIncompleteUploads(query) => {
|
BucketOperation::CleanupIncompleteUploads(query) => {
|
||||||
self.cmd_cleanup_incomplete_uploads(query).await
|
self.cmd_cleanup_incomplete_uploads(query).await
|
||||||
}
|
}
|
||||||
|
BucketOperation::InspectObject(query) => self.cmd_inspect_object(query).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -407,6 +408,75 @@ impl Cli {
|
||||||
|
|
||||||
Ok(())
|
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) {
|
fn print_bucket_info(bucket: &GetBucketInfoResponse) {
|
||||||
|
|
|
@ -265,6 +265,10 @@ pub enum BucketOperation {
|
||||||
/// Clean up (abort) old incomplete multipart uploads
|
/// Clean up (abort) old incomplete multipart uploads
|
||||||
#[structopt(name = "cleanup-incomplete-uploads", version = garage_version())]
|
#[structopt(name = "cleanup-incomplete-uploads", version = garage_version())]
|
||||||
CleanupIncompleteUploads(CleanupIncompleteUploadsOpt),
|
CleanupIncompleteUploads(CleanupIncompleteUploadsOpt),
|
||||||
|
|
||||||
|
/// Inspect an object in a bucket
|
||||||
|
#[structopt(name = "inspect-object", version = garage_version())]
|
||||||
|
InspectObject(InspectObjectOpt),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
|
@ -377,6 +381,14 @@ pub struct CleanupIncompleteUploadsOpt {
|
||||||
pub buckets: Vec<String>,
|
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 ... ----
|
// ---- garage key ... ----
|
||||||
// ------------------------
|
// ------------------------
|
||||||
|
|
Loading…
Add table
Reference in a new issue