Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
96f35520a0 | |||
490560e0c6 | |||
52f53d8280 | |||
27b8a3f671 | |||
abf9b6da42 | |||
0c9209b04c | |||
edebd52374 | |||
61205f00fb | |||
a303e00289 | |||
af9f72e9d8 | |||
5176346b30 | |||
731eeef25b | |||
a65e3e4bc0 |
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "proxmox-backup"
|
name = "proxmox-backup"
|
||||||
version = "1.0.3"
|
version = "1.0.5"
|
||||||
authors = [
|
authors = [
|
||||||
"Dietmar Maurer <dietmar@proxmox.com>",
|
"Dietmar Maurer <dietmar@proxmox.com>",
|
||||||
"Dominik Csapak <d.csapak@proxmox.com>",
|
"Dominik Csapak <d.csapak@proxmox.com>",
|
||||||
@ -48,7 +48,7 @@ percent-encoding = "2.1"
|
|||||||
pin-utils = "0.1.0"
|
pin-utils = "0.1.0"
|
||||||
pin-project = "0.4"
|
pin-project = "0.4"
|
||||||
pathpatterns = "0.1.2"
|
pathpatterns = "0.1.2"
|
||||||
proxmox = { version = "0.7.1", features = [ "sortable-macro", "api-macro", "websocket" ] }
|
proxmox = { version = "0.7.2", features = [ "sortable-macro", "api-macro", "websocket" ] }
|
||||||
#proxmox = { git = "git://git.proxmox.com/git/proxmox", version = "0.1.2", features = [ "sortable-macro", "api-macro" ] }
|
#proxmox = { git = "git://git.proxmox.com/git/proxmox", version = "0.1.2", features = [ "sortable-macro", "api-macro" ] }
|
||||||
#proxmox = { path = "../proxmox/proxmox", features = [ "sortable-macro", "api-macro", "websocket" ] }
|
#proxmox = { path = "../proxmox/proxmox", features = [ "sortable-macro", "api-macro", "websocket" ] }
|
||||||
proxmox-fuse = "0.1.0"
|
proxmox-fuse = "0.1.0"
|
||||||
|
16
debian/changelog
vendored
16
debian/changelog
vendored
@ -1,3 +1,19 @@
|
|||||||
|
rust-proxmox-backup (1.0.5-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* client: restore: print meta information exclusively to standard error
|
||||||
|
|
||||||
|
-- Proxmox Support Team <support@proxmox.com> Wed, 25 Nov 2020 15:29:58 +0100
|
||||||
|
|
||||||
|
rust-proxmox-backup (1.0.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* fingerprint: add bytes() accessor
|
||||||
|
|
||||||
|
* ui: fix broken gettext use
|
||||||
|
|
||||||
|
* cli: move more commands into "snapshot" sub-command
|
||||||
|
|
||||||
|
-- Proxmox Support Team <support@proxmox.com> Wed, 25 Nov 2020 06:37:41 +0100
|
||||||
|
|
||||||
rust-proxmox-backup (1.0.3-1) unstable; urgency=medium
|
rust-proxmox-backup (1.0.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
* client: inform user when automatically using the default encryption key
|
* client: inform user when automatically using the default encryption key
|
||||||
|
8
debian/control
vendored
8
debian/control
vendored
@ -35,10 +35,10 @@ Build-Depends: debhelper (>= 11),
|
|||||||
librust-percent-encoding-2+default-dev (>= 2.1-~~),
|
librust-percent-encoding-2+default-dev (>= 2.1-~~),
|
||||||
librust-pin-project-0.4+default-dev,
|
librust-pin-project-0.4+default-dev,
|
||||||
librust-pin-utils-0.1+default-dev,
|
librust-pin-utils-0.1+default-dev,
|
||||||
librust-proxmox-0.7+api-macro-dev (>= 0.7.1-~~),
|
librust-proxmox-0.7+api-macro-dev (>= 0.7.2-~~),
|
||||||
librust-proxmox-0.7+default-dev (>= 0.7.1-~~),
|
librust-proxmox-0.7+default-dev (>= 0.7.2-~~),
|
||||||
librust-proxmox-0.7+sortable-macro-dev (>= 0.7.1-~~),
|
librust-proxmox-0.7+sortable-macro-dev (>= 0.7.2-~~),
|
||||||
librust-proxmox-0.7+websocket-dev (>= 0.7.1-~~),
|
librust-proxmox-0.7+websocket-dev (>= 0.7.2-~~),
|
||||||
librust-proxmox-fuse-0.1+default-dev,
|
librust-proxmox-fuse-0.1+default-dev,
|
||||||
librust-pxar-0.6+default-dev (>= 0.6.1-~~),
|
librust-pxar-0.6+default-dev (>= 0.6.1-~~),
|
||||||
librust-pxar-0.6+futures-io-dev (>= 0.6.1-~~),
|
librust-pxar-0.6+futures-io-dev (>= 0.6.1-~~),
|
||||||
|
@ -392,11 +392,11 @@ periodic recovery tests to ensure that you can access the data in
|
|||||||
case of problems.
|
case of problems.
|
||||||
|
|
||||||
First, you need to find the snapshot which you want to restore. The snapshot
|
First, you need to find the snapshot which you want to restore. The snapshot
|
||||||
command provides a list of all the snapshots on the server:
|
list command provides a list of all the snapshots on the server:
|
||||||
|
|
||||||
.. code-block:: console
|
.. code-block:: console
|
||||||
|
|
||||||
# proxmox-backup-client snapshots
|
# proxmox-backup-client snapshot list
|
||||||
┌────────────────────────────────┬─────────────┬────────────────────────────────────┐
|
┌────────────────────────────────┬─────────────┬────────────────────────────────────┐
|
||||||
│ snapshot │ size │ files │
|
│ snapshot │ size │ files │
|
||||||
╞════════════════════════════════╪═════════════╪════════════════════════════════════╡
|
╞════════════════════════════════╪═════════════╪════════════════════════════════════╡
|
||||||
@ -581,7 +581,7 @@ command:
|
|||||||
|
|
||||||
.. code-block:: console
|
.. code-block:: console
|
||||||
|
|
||||||
# proxmox-backup-client forget <snapshot>
|
# proxmox-backup-client snapshot forget <snapshot>
|
||||||
|
|
||||||
|
|
||||||
.. caution:: This command removes all archives in this backup
|
.. caution:: This command removes all archives in this backup
|
||||||
|
@ -47,6 +47,15 @@ pub struct Fingerprint {
|
|||||||
bytes: [u8; 32],
|
bytes: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Fingerprint {
|
||||||
|
pub fn new(bytes: [u8; 32]) -> Self {
|
||||||
|
Self { bytes }
|
||||||
|
}
|
||||||
|
pub fn bytes(&self) -> &[u8; 32] {
|
||||||
|
&self.bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Display as short key ID
|
/// Display as short key ID
|
||||||
impl Display for Fingerprint {
|
impl Display for Fingerprint {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
@ -126,9 +135,7 @@ impl CryptConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn fingerprint(&self) -> Fingerprint {
|
pub fn fingerprint(&self) -> Fingerprint {
|
||||||
Fingerprint {
|
Fingerprint::new(self.compute_digest(&FINGERPRINT_INPUT))
|
||||||
bytes: self.compute_digest(&FINGERPRINT_INPUT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data_crypter(&self, iv: &[u8; 16], mode: Mode) -> Result<Crypter, Error> {
|
pub fn data_crypter(&self, iv: &[u8; 16], mode: Mode) -> Result<Crypter, Error> {
|
||||||
|
@ -53,7 +53,6 @@ use proxmox_backup::backup::{
|
|||||||
ChunkStream,
|
ChunkStream,
|
||||||
CryptConfig,
|
CryptConfig,
|
||||||
CryptMode,
|
CryptMode,
|
||||||
DataBlob,
|
|
||||||
DynamicIndexReader,
|
DynamicIndexReader,
|
||||||
FixedChunkStream,
|
FixedChunkStream,
|
||||||
FixedIndexReader,
|
FixedIndexReader,
|
||||||
@ -456,112 +455,6 @@ async fn change_backup_owner(group: String, mut param: Value) -> Result<(), Erro
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[api(
|
|
||||||
input: {
|
|
||||||
properties: {
|
|
||||||
repository: {
|
|
||||||
schema: REPO_URL_SCHEMA,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
group: {
|
|
||||||
type: String,
|
|
||||||
description: "Backup group.",
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
"output-format": {
|
|
||||||
schema: OUTPUT_FORMAT,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)]
|
|
||||||
/// List backup snapshots.
|
|
||||||
async fn list_snapshots(param: Value) -> Result<Value, Error> {
|
|
||||||
|
|
||||||
let repo = extract_repository_from_value(¶m)?;
|
|
||||||
|
|
||||||
let output_format = get_output_format(¶m);
|
|
||||||
|
|
||||||
let client = connect(&repo)?;
|
|
||||||
|
|
||||||
let group: Option<BackupGroup> = if let Some(path) = param["group"].as_str() {
|
|
||||||
Some(path.parse()?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut data = api_datastore_list_snapshots(&client, repo.store(), group).await?;
|
|
||||||
|
|
||||||
record_repository(&repo);
|
|
||||||
|
|
||||||
let render_snapshot_path = |_v: &Value, record: &Value| -> Result<String, Error> {
|
|
||||||
let item: SnapshotListItem = serde_json::from_value(record.to_owned())?;
|
|
||||||
let snapshot = BackupDir::new(item.backup_type, item.backup_id, item.backup_time)?;
|
|
||||||
Ok(snapshot.relative_path().to_str().unwrap().to_owned())
|
|
||||||
};
|
|
||||||
|
|
||||||
let render_files = |_v: &Value, record: &Value| -> Result<String, Error> {
|
|
||||||
let item: SnapshotListItem = serde_json::from_value(record.to_owned())?;
|
|
||||||
let mut filenames = Vec::new();
|
|
||||||
for file in &item.files {
|
|
||||||
filenames.push(file.filename.to_string());
|
|
||||||
}
|
|
||||||
Ok(tools::format::render_backup_file_list(&filenames[..]))
|
|
||||||
};
|
|
||||||
|
|
||||||
let options = default_table_format_options()
|
|
||||||
.sortby("backup-type", false)
|
|
||||||
.sortby("backup-id", false)
|
|
||||||
.sortby("backup-time", false)
|
|
||||||
.column(ColumnConfig::new("backup-id").renderer(render_snapshot_path).header("snapshot"))
|
|
||||||
.column(ColumnConfig::new("size").renderer(tools::format::render_bytes_human_readable))
|
|
||||||
.column(ColumnConfig::new("files").renderer(render_files))
|
|
||||||
;
|
|
||||||
|
|
||||||
let info = &proxmox_backup::api2::admin::datastore::API_RETURN_SCHEMA_LIST_SNAPSHOTS;
|
|
||||||
|
|
||||||
format_and_print_result_full(&mut data, info, &output_format, &options);
|
|
||||||
|
|
||||||
Ok(Value::Null)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[api(
|
|
||||||
input: {
|
|
||||||
properties: {
|
|
||||||
repository: {
|
|
||||||
schema: REPO_URL_SCHEMA,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
snapshot: {
|
|
||||||
type: String,
|
|
||||||
description: "Snapshot path.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)]
|
|
||||||
/// Forget (remove) backup snapshots.
|
|
||||||
async fn forget_snapshots(param: Value) -> Result<Value, Error> {
|
|
||||||
|
|
||||||
let repo = extract_repository_from_value(¶m)?;
|
|
||||||
|
|
||||||
let path = tools::required_string_param(¶m, "snapshot")?;
|
|
||||||
let snapshot: BackupDir = path.parse()?;
|
|
||||||
|
|
||||||
let mut client = connect(&repo)?;
|
|
||||||
|
|
||||||
let path = format!("api2/json/admin/datastore/{}/snapshots", repo.store());
|
|
||||||
|
|
||||||
let result = client.delete(&path, Some(json!({
|
|
||||||
"backup-type": snapshot.group().backup_type(),
|
|
||||||
"backup-id": snapshot.group().backup_id(),
|
|
||||||
"backup-time": snapshot.backup_time(),
|
|
||||||
}))).await?;
|
|
||||||
|
|
||||||
record_repository(&repo);
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[api(
|
#[api(
|
||||||
input: {
|
input: {
|
||||||
properties: {
|
properties: {
|
||||||
@ -655,58 +548,6 @@ async fn api_version(param: Value) -> Result<(), Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[api(
|
|
||||||
input: {
|
|
||||||
properties: {
|
|
||||||
repository: {
|
|
||||||
schema: REPO_URL_SCHEMA,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
snapshot: {
|
|
||||||
type: String,
|
|
||||||
description: "Snapshot path.",
|
|
||||||
},
|
|
||||||
"output-format": {
|
|
||||||
schema: OUTPUT_FORMAT,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)]
|
|
||||||
/// List snapshot files.
|
|
||||||
async fn list_snapshot_files(param: Value) -> Result<Value, Error> {
|
|
||||||
|
|
||||||
let repo = extract_repository_from_value(¶m)?;
|
|
||||||
|
|
||||||
let path = tools::required_string_param(¶m, "snapshot")?;
|
|
||||||
let snapshot: BackupDir = path.parse()?;
|
|
||||||
|
|
||||||
let output_format = get_output_format(¶m);
|
|
||||||
|
|
||||||
let client = connect(&repo)?;
|
|
||||||
|
|
||||||
let path = format!("api2/json/admin/datastore/{}/files", repo.store());
|
|
||||||
|
|
||||||
let mut result = client.get(&path, Some(json!({
|
|
||||||
"backup-type": snapshot.group().backup_type(),
|
|
||||||
"backup-id": snapshot.group().backup_id(),
|
|
||||||
"backup-time": snapshot.backup_time(),
|
|
||||||
}))).await?;
|
|
||||||
|
|
||||||
record_repository(&repo);
|
|
||||||
|
|
||||||
let info = &proxmox_backup::api2::admin::datastore::API_RETURN_SCHEMA_LIST_SNAPSHOT_FILES;
|
|
||||||
|
|
||||||
let mut data: Value = result["data"].take();
|
|
||||||
|
|
||||||
let options = default_table_format_options();
|
|
||||||
|
|
||||||
format_and_print_result_full(&mut data, info, &output_format, &options);
|
|
||||||
|
|
||||||
Ok(Value::Null)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[api(
|
#[api(
|
||||||
input: {
|
input: {
|
||||||
properties: {
|
properties: {
|
||||||
@ -803,7 +644,7 @@ fn keyfile_parameters(param: &Value) -> Result<(Option<Vec<u8>>, CryptMode), Err
|
|||||||
(None, None) => None,
|
(None, None) => None,
|
||||||
(Some(_), Some(_)) => bail!("--keyfile and --keyfd are mutually exclusive"),
|
(Some(_), Some(_)) => bail!("--keyfile and --keyfd are mutually exclusive"),
|
||||||
(Some(keyfile), None) => {
|
(Some(keyfile), None) => {
|
||||||
println!("Using encryption key file: {}", keyfile);
|
eprintln!("Using encryption key file: {}", keyfile);
|
||||||
Some(file_get_contents(keyfile)?)
|
Some(file_get_contents(keyfile)?)
|
||||||
},
|
},
|
||||||
(None, Some(fd)) => {
|
(None, Some(fd)) => {
|
||||||
@ -813,7 +654,7 @@ fn keyfile_parameters(param: &Value) -> Result<(Option<Vec<u8>>, CryptMode), Err
|
|||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
format_err!("error reading encryption key from fd {}: {}", fd, err)
|
format_err!("error reading encryption key from fd {}: {}", fd, err)
|
||||||
})?;
|
})?;
|
||||||
println!("Using encryption key from file descriptor");
|
eprintln!("Using encryption key from file descriptor");
|
||||||
Some(data)
|
Some(data)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -822,7 +663,7 @@ fn keyfile_parameters(param: &Value) -> Result<(Option<Vec<u8>>, CryptMode), Err
|
|||||||
// no parameters:
|
// no parameters:
|
||||||
(None, None) => match key::read_optional_default_encryption_key()? {
|
(None, None) => match key::read_optional_default_encryption_key()? {
|
||||||
Some(key) => {
|
Some(key) => {
|
||||||
println!("Encrypting with default encryption key!");
|
eprintln!("Encrypting with default encryption key!");
|
||||||
(Some(key), CryptMode::Encrypt)
|
(Some(key), CryptMode::Encrypt)
|
||||||
},
|
},
|
||||||
None => (None, CryptMode::None),
|
None => (None, CryptMode::None),
|
||||||
@ -835,7 +676,7 @@ fn keyfile_parameters(param: &Value) -> Result<(Option<Vec<u8>>, CryptMode), Err
|
|||||||
(None, Some(crypt_mode)) => match key::read_optional_default_encryption_key()? {
|
(None, Some(crypt_mode)) => match key::read_optional_default_encryption_key()? {
|
||||||
None => bail!("--crypt-mode without --keyfile and no default key file available"),
|
None => bail!("--crypt-mode without --keyfile and no default key file available"),
|
||||||
Some(key) => {
|
Some(key) => {
|
||||||
println!("Encrypting with default encryption key!");
|
eprintln!("Encrypting with default encryption key!");
|
||||||
(Some(key), crypt_mode)
|
(Some(key), crypt_mode)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -1416,7 +1257,7 @@ async fn restore(param: Value) -> Result<Value, Error> {
|
|||||||
None => None,
|
None => None,
|
||||||
Some(key) => {
|
Some(key) => {
|
||||||
let (key, _, fingerprint) = decrypt_key(&key, &key::get_encryption_key_password)?;
|
let (key, _, fingerprint) = decrypt_key(&key, &key::get_encryption_key_password)?;
|
||||||
println!("Encryption key fingerprint: '{}'", fingerprint);
|
eprintln!("Encryption key fingerprint: '{}'", fingerprint);
|
||||||
Some(Arc::new(CryptConfig::new(key)?))
|
Some(Arc::new(CryptConfig::new(key)?))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1529,81 +1370,6 @@ async fn restore(param: Value) -> Result<Value, Error> {
|
|||||||
Ok(Value::Null)
|
Ok(Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[api(
|
|
||||||
input: {
|
|
||||||
properties: {
|
|
||||||
repository: {
|
|
||||||
schema: REPO_URL_SCHEMA,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
snapshot: {
|
|
||||||
type: String,
|
|
||||||
description: "Group/Snapshot path.",
|
|
||||||
},
|
|
||||||
logfile: {
|
|
||||||
type: String,
|
|
||||||
description: "The path to the log file you want to upload.",
|
|
||||||
},
|
|
||||||
keyfile: {
|
|
||||||
schema: KEYFILE_SCHEMA,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
"keyfd": {
|
|
||||||
schema: KEYFD_SCHEMA,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
"crypt-mode": {
|
|
||||||
type: CryptMode,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)]
|
|
||||||
/// Upload backup log file.
|
|
||||||
async fn upload_log(param: Value) -> Result<Value, Error> {
|
|
||||||
|
|
||||||
let logfile = tools::required_string_param(¶m, "logfile")?;
|
|
||||||
let repo = extract_repository_from_value(¶m)?;
|
|
||||||
|
|
||||||
let snapshot = tools::required_string_param(¶m, "snapshot")?;
|
|
||||||
let snapshot: BackupDir = snapshot.parse()?;
|
|
||||||
|
|
||||||
let mut client = connect(&repo)?;
|
|
||||||
|
|
||||||
let (keydata, crypt_mode) = keyfile_parameters(¶m)?;
|
|
||||||
|
|
||||||
let crypt_config = match keydata {
|
|
||||||
None => None,
|
|
||||||
Some(key) => {
|
|
||||||
let (key, _created, _) = decrypt_key(&key, &key::get_encryption_key_password)?;
|
|
||||||
let crypt_config = CryptConfig::new(key)?;
|
|
||||||
Some(Arc::new(crypt_config))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let data = file_get_contents(logfile)?;
|
|
||||||
|
|
||||||
// fixme: howto sign log?
|
|
||||||
let blob = match crypt_mode {
|
|
||||||
CryptMode::None | CryptMode::SignOnly => DataBlob::encode(&data, None, true)?,
|
|
||||||
CryptMode::Encrypt => DataBlob::encode(&data, crypt_config.as_ref().map(Arc::as_ref), true)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let raw_data = blob.into_inner();
|
|
||||||
|
|
||||||
let path = format!("api2/json/admin/datastore/{}/upload-backup-log", repo.store());
|
|
||||||
|
|
||||||
let args = json!({
|
|
||||||
"backup-type": snapshot.group().backup_type(),
|
|
||||||
"backup-id": snapshot.group().backup_id(),
|
|
||||||
"backup-time": snapshot.backup_time(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let body = hyper::Body::from(raw_data);
|
|
||||||
|
|
||||||
client.upload("application/octet-stream", body, &path, Some(args)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_METHOD_PRUNE: ApiMethod = ApiMethod::new(
|
const API_METHOD_PRUNE: ApiMethod = ApiMethod::new(
|
||||||
&ApiHandler::Async(&prune),
|
&ApiHandler::Async(&prune),
|
||||||
&ObjectSchema::new(
|
&ObjectSchema::new(
|
||||||
@ -2041,26 +1807,9 @@ fn main() {
|
|||||||
.completion_cb("repository", complete_repository)
|
.completion_cb("repository", complete_repository)
|
||||||
.completion_cb("keyfile", tools::complete_file_name);
|
.completion_cb("keyfile", tools::complete_file_name);
|
||||||
|
|
||||||
let upload_log_cmd_def = CliCommand::new(&API_METHOD_UPLOAD_LOG)
|
|
||||||
.arg_param(&["snapshot", "logfile"])
|
|
||||||
.completion_cb("snapshot", complete_backup_snapshot)
|
|
||||||
.completion_cb("logfile", tools::complete_file_name)
|
|
||||||
.completion_cb("keyfile", tools::complete_file_name)
|
|
||||||
.completion_cb("repository", complete_repository);
|
|
||||||
|
|
||||||
let list_cmd_def = CliCommand::new(&API_METHOD_LIST_BACKUP_GROUPS)
|
let list_cmd_def = CliCommand::new(&API_METHOD_LIST_BACKUP_GROUPS)
|
||||||
.completion_cb("repository", complete_repository);
|
.completion_cb("repository", complete_repository);
|
||||||
|
|
||||||
let snapshots_cmd_def = CliCommand::new(&API_METHOD_LIST_SNAPSHOTS)
|
|
||||||
.arg_param(&["group"])
|
|
||||||
.completion_cb("group", complete_backup_group)
|
|
||||||
.completion_cb("repository", complete_repository);
|
|
||||||
|
|
||||||
let forget_cmd_def = CliCommand::new(&API_METHOD_FORGET_SNAPSHOTS)
|
|
||||||
.arg_param(&["snapshot"])
|
|
||||||
.completion_cb("repository", complete_repository)
|
|
||||||
.completion_cb("snapshot", complete_backup_snapshot);
|
|
||||||
|
|
||||||
let garbage_collect_cmd_def = CliCommand::new(&API_METHOD_START_GARBAGE_COLLECTION)
|
let garbage_collect_cmd_def = CliCommand::new(&API_METHOD_START_GARBAGE_COLLECTION)
|
||||||
.completion_cb("repository", complete_repository);
|
.completion_cb("repository", complete_repository);
|
||||||
|
|
||||||
@ -2071,11 +1820,6 @@ fn main() {
|
|||||||
.completion_cb("archive-name", complete_archive_name)
|
.completion_cb("archive-name", complete_archive_name)
|
||||||
.completion_cb("target", tools::complete_file_name);
|
.completion_cb("target", tools::complete_file_name);
|
||||||
|
|
||||||
let files_cmd_def = CliCommand::new(&API_METHOD_LIST_SNAPSHOT_FILES)
|
|
||||||
.arg_param(&["snapshot"])
|
|
||||||
.completion_cb("repository", complete_repository)
|
|
||||||
.completion_cb("snapshot", complete_backup_snapshot);
|
|
||||||
|
|
||||||
let prune_cmd_def = CliCommand::new(&API_METHOD_PRUNE)
|
let prune_cmd_def = CliCommand::new(&API_METHOD_PRUNE)
|
||||||
.arg_param(&["group"])
|
.arg_param(&["group"])
|
||||||
.completion_cb("group", complete_backup_group)
|
.completion_cb("group", complete_backup_group)
|
||||||
@ -2101,16 +1845,13 @@ fn main() {
|
|||||||
|
|
||||||
let cmd_def = CliCommandMap::new()
|
let cmd_def = CliCommandMap::new()
|
||||||
.insert("backup", backup_cmd_def)
|
.insert("backup", backup_cmd_def)
|
||||||
.insert("upload-log", upload_log_cmd_def)
|
|
||||||
.insert("forget", forget_cmd_def)
|
|
||||||
.insert("garbage-collect", garbage_collect_cmd_def)
|
.insert("garbage-collect", garbage_collect_cmd_def)
|
||||||
.insert("list", list_cmd_def)
|
.insert("list", list_cmd_def)
|
||||||
.insert("login", login_cmd_def)
|
.insert("login", login_cmd_def)
|
||||||
.insert("logout", logout_cmd_def)
|
.insert("logout", logout_cmd_def)
|
||||||
.insert("prune", prune_cmd_def)
|
.insert("prune", prune_cmd_def)
|
||||||
.insert("restore", restore_cmd_def)
|
.insert("restore", restore_cmd_def)
|
||||||
.insert("snapshots", snapshots_cmd_def)
|
.insert("snapshot", snapshot_mgtm_cli())
|
||||||
.insert("files", files_cmd_def)
|
|
||||||
.insert("status", status_cmd_def)
|
.insert("status", status_cmd_def)
|
||||||
.insert("key", key::cli())
|
.insert("key", key::cli())
|
||||||
.insert("mount", mount_cmd_def())
|
.insert("mount", mount_cmd_def())
|
||||||
@ -2120,7 +1861,13 @@ fn main() {
|
|||||||
.insert("task", task_mgmt_cli())
|
.insert("task", task_mgmt_cli())
|
||||||
.insert("version", version_cmd_def)
|
.insert("version", version_cmd_def)
|
||||||
.insert("benchmark", benchmark_cmd_def)
|
.insert("benchmark", benchmark_cmd_def)
|
||||||
.insert("change-owner", change_owner_cmd_def);
|
.insert("change-owner", change_owner_cmd_def)
|
||||||
|
|
||||||
|
.alias(&["files"], &["snapshot", "files"])
|
||||||
|
.alias(&["forget"], &["snapshot", "forget"])
|
||||||
|
.alias(&["upload-log"], &["snapshot", "upload-log"])
|
||||||
|
.alias(&["snapshots"], &["snapshot", "list"])
|
||||||
|
;
|
||||||
|
|
||||||
let rpcenv = CliEnvironment::new();
|
let rpcenv = CliEnvironment::new();
|
||||||
run_cli_command(cmd_def, rpcenv, Some(|future| {
|
run_cli_command(cmd_def, rpcenv, Some(|future| {
|
||||||
|
@ -8,6 +8,8 @@ mod task;
|
|||||||
pub use task::*;
|
pub use task::*;
|
||||||
mod catalog;
|
mod catalog;
|
||||||
pub use catalog::*;
|
pub use catalog::*;
|
||||||
|
mod snapshot;
|
||||||
|
pub use snapshot::*;
|
||||||
|
|
||||||
pub mod key;
|
pub mod key;
|
||||||
|
|
||||||
|
416
src/bin/proxmox_backup_client/snapshot.rs
Normal file
416
src/bin/proxmox_backup_client/snapshot.rs
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use proxmox::{
|
||||||
|
api::{api, cli::*},
|
||||||
|
tools::fs::file_get_contents,
|
||||||
|
};
|
||||||
|
|
||||||
|
use proxmox_backup::{
|
||||||
|
tools,
|
||||||
|
api2::types::*,
|
||||||
|
backup::{
|
||||||
|
CryptMode,
|
||||||
|
CryptConfig,
|
||||||
|
DataBlob,
|
||||||
|
BackupGroup,
|
||||||
|
decrypt_key,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
REPO_URL_SCHEMA,
|
||||||
|
KEYFILE_SCHEMA,
|
||||||
|
KEYFD_SCHEMA,
|
||||||
|
BackupDir,
|
||||||
|
api_datastore_list_snapshots,
|
||||||
|
complete_backup_snapshot,
|
||||||
|
complete_backup_group,
|
||||||
|
complete_repository,
|
||||||
|
connect,
|
||||||
|
extract_repository_from_value,
|
||||||
|
record_repository,
|
||||||
|
keyfile_parameters,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[api(
|
||||||
|
input: {
|
||||||
|
properties: {
|
||||||
|
repository: {
|
||||||
|
schema: REPO_URL_SCHEMA,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: String,
|
||||||
|
description: "Backup group.",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
"output-format": {
|
||||||
|
schema: OUTPUT_FORMAT,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)]
|
||||||
|
/// List backup snapshots.
|
||||||
|
async fn list_snapshots(param: Value) -> Result<Value, Error> {
|
||||||
|
|
||||||
|
let repo = extract_repository_from_value(¶m)?;
|
||||||
|
|
||||||
|
let output_format = get_output_format(¶m);
|
||||||
|
|
||||||
|
let client = connect(&repo)?;
|
||||||
|
|
||||||
|
let group: Option<BackupGroup> = if let Some(path) = param["group"].as_str() {
|
||||||
|
Some(path.parse()?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut data = api_datastore_list_snapshots(&client, repo.store(), group).await?;
|
||||||
|
|
||||||
|
record_repository(&repo);
|
||||||
|
|
||||||
|
let render_snapshot_path = |_v: &Value, record: &Value| -> Result<String, Error> {
|
||||||
|
let item: SnapshotListItem = serde_json::from_value(record.to_owned())?;
|
||||||
|
let snapshot = BackupDir::new(item.backup_type, item.backup_id, item.backup_time)?;
|
||||||
|
Ok(snapshot.relative_path().to_str().unwrap().to_owned())
|
||||||
|
};
|
||||||
|
|
||||||
|
let render_files = |_v: &Value, record: &Value| -> Result<String, Error> {
|
||||||
|
let item: SnapshotListItem = serde_json::from_value(record.to_owned())?;
|
||||||
|
let mut filenames = Vec::new();
|
||||||
|
for file in &item.files {
|
||||||
|
filenames.push(file.filename.to_string());
|
||||||
|
}
|
||||||
|
Ok(tools::format::render_backup_file_list(&filenames[..]))
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = default_table_format_options()
|
||||||
|
.sortby("backup-type", false)
|
||||||
|
.sortby("backup-id", false)
|
||||||
|
.sortby("backup-time", false)
|
||||||
|
.column(ColumnConfig::new("backup-id").renderer(render_snapshot_path).header("snapshot"))
|
||||||
|
.column(ColumnConfig::new("size").renderer(tools::format::render_bytes_human_readable))
|
||||||
|
.column(ColumnConfig::new("files").renderer(render_files))
|
||||||
|
;
|
||||||
|
|
||||||
|
let info = &proxmox_backup::api2::admin::datastore::API_RETURN_SCHEMA_LIST_SNAPSHOTS;
|
||||||
|
|
||||||
|
format_and_print_result_full(&mut data, info, &output_format, &options);
|
||||||
|
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(
|
||||||
|
input: {
|
||||||
|
properties: {
|
||||||
|
repository: {
|
||||||
|
schema: REPO_URL_SCHEMA,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
type: String,
|
||||||
|
description: "Snapshot path.",
|
||||||
|
},
|
||||||
|
"output-format": {
|
||||||
|
schema: OUTPUT_FORMAT,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)]
|
||||||
|
/// List snapshot files.
|
||||||
|
async fn list_snapshot_files(param: Value) -> Result<Value, Error> {
|
||||||
|
|
||||||
|
let repo = extract_repository_from_value(¶m)?;
|
||||||
|
|
||||||
|
let path = tools::required_string_param(¶m, "snapshot")?;
|
||||||
|
let snapshot: BackupDir = path.parse()?;
|
||||||
|
|
||||||
|
let output_format = get_output_format(¶m);
|
||||||
|
|
||||||
|
let client = connect(&repo)?;
|
||||||
|
|
||||||
|
let path = format!("api2/json/admin/datastore/{}/files", repo.store());
|
||||||
|
|
||||||
|
let mut result = client.get(&path, Some(json!({
|
||||||
|
"backup-type": snapshot.group().backup_type(),
|
||||||
|
"backup-id": snapshot.group().backup_id(),
|
||||||
|
"backup-time": snapshot.backup_time(),
|
||||||
|
}))).await?;
|
||||||
|
|
||||||
|
record_repository(&repo);
|
||||||
|
|
||||||
|
let info = &proxmox_backup::api2::admin::datastore::API_RETURN_SCHEMA_LIST_SNAPSHOT_FILES;
|
||||||
|
|
||||||
|
let mut data: Value = result["data"].take();
|
||||||
|
|
||||||
|
let options = default_table_format_options();
|
||||||
|
|
||||||
|
format_and_print_result_full(&mut data, info, &output_format, &options);
|
||||||
|
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(
|
||||||
|
input: {
|
||||||
|
properties: {
|
||||||
|
repository: {
|
||||||
|
schema: REPO_URL_SCHEMA,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
type: String,
|
||||||
|
description: "Snapshot path.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)]
|
||||||
|
/// Forget (remove) backup snapshots.
|
||||||
|
async fn forget_snapshots(param: Value) -> Result<Value, Error> {
|
||||||
|
|
||||||
|
let repo = extract_repository_from_value(¶m)?;
|
||||||
|
|
||||||
|
let path = tools::required_string_param(¶m, "snapshot")?;
|
||||||
|
let snapshot: BackupDir = path.parse()?;
|
||||||
|
|
||||||
|
let mut client = connect(&repo)?;
|
||||||
|
|
||||||
|
let path = format!("api2/json/admin/datastore/{}/snapshots", repo.store());
|
||||||
|
|
||||||
|
let result = client.delete(&path, Some(json!({
|
||||||
|
"backup-type": snapshot.group().backup_type(),
|
||||||
|
"backup-id": snapshot.group().backup_id(),
|
||||||
|
"backup-time": snapshot.backup_time(),
|
||||||
|
}))).await?;
|
||||||
|
|
||||||
|
record_repository(&repo);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(
|
||||||
|
input: {
|
||||||
|
properties: {
|
||||||
|
repository: {
|
||||||
|
schema: REPO_URL_SCHEMA,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
type: String,
|
||||||
|
description: "Group/Snapshot path.",
|
||||||
|
},
|
||||||
|
logfile: {
|
||||||
|
type: String,
|
||||||
|
description: "The path to the log file you want to upload.",
|
||||||
|
},
|
||||||
|
keyfile: {
|
||||||
|
schema: KEYFILE_SCHEMA,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
"keyfd": {
|
||||||
|
schema: KEYFD_SCHEMA,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
"crypt-mode": {
|
||||||
|
type: CryptMode,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)]
|
||||||
|
/// Upload backup log file.
|
||||||
|
async fn upload_log(param: Value) -> Result<Value, Error> {
|
||||||
|
|
||||||
|
let logfile = tools::required_string_param(¶m, "logfile")?;
|
||||||
|
let repo = extract_repository_from_value(¶m)?;
|
||||||
|
|
||||||
|
let snapshot = tools::required_string_param(¶m, "snapshot")?;
|
||||||
|
let snapshot: BackupDir = snapshot.parse()?;
|
||||||
|
|
||||||
|
let mut client = connect(&repo)?;
|
||||||
|
|
||||||
|
let (keydata, crypt_mode) = keyfile_parameters(¶m)?;
|
||||||
|
|
||||||
|
let crypt_config = match keydata {
|
||||||
|
None => None,
|
||||||
|
Some(key) => {
|
||||||
|
let (key, _created, _) = decrypt_key(&key, &crate::key::get_encryption_key_password)?;
|
||||||
|
let crypt_config = CryptConfig::new(key)?;
|
||||||
|
Some(Arc::new(crypt_config))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = file_get_contents(logfile)?;
|
||||||
|
|
||||||
|
// fixme: howto sign log?
|
||||||
|
let blob = match crypt_mode {
|
||||||
|
CryptMode::None | CryptMode::SignOnly => DataBlob::encode(&data, None, true)?,
|
||||||
|
CryptMode::Encrypt => DataBlob::encode(&data, crypt_config.as_ref().map(Arc::as_ref), true)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw_data = blob.into_inner();
|
||||||
|
|
||||||
|
let path = format!("api2/json/admin/datastore/{}/upload-backup-log", repo.store());
|
||||||
|
|
||||||
|
let args = json!({
|
||||||
|
"backup-type": snapshot.group().backup_type(),
|
||||||
|
"backup-id": snapshot.group().backup_id(),
|
||||||
|
"backup-time": snapshot.backup_time(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let body = hyper::Body::from(raw_data);
|
||||||
|
|
||||||
|
client.upload("application/octet-stream", body, &path, Some(args)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(
|
||||||
|
input: {
|
||||||
|
properties: {
|
||||||
|
repository: {
|
||||||
|
schema: REPO_URL_SCHEMA,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
type: String,
|
||||||
|
description: "Snapshot path.",
|
||||||
|
},
|
||||||
|
"output-format": {
|
||||||
|
schema: OUTPUT_FORMAT,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)]
|
||||||
|
/// Show notes
|
||||||
|
async fn show_notes(param: Value) -> Result<Value, Error> {
|
||||||
|
let repo = extract_repository_from_value(¶m)?;
|
||||||
|
let path = tools::required_string_param(¶m, "snapshot")?;
|
||||||
|
|
||||||
|
let snapshot: BackupDir = path.parse()?;
|
||||||
|
let client = connect(&repo)?;
|
||||||
|
|
||||||
|
let path = format!("api2/json/admin/datastore/{}/notes", repo.store());
|
||||||
|
|
||||||
|
let args = json!({
|
||||||
|
"backup-type": snapshot.group().backup_type(),
|
||||||
|
"backup-id": snapshot.group().backup_id(),
|
||||||
|
"backup-time": snapshot.backup_time(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let output_format = get_output_format(¶m);
|
||||||
|
|
||||||
|
let mut result = client.get(&path, Some(args)).await?;
|
||||||
|
|
||||||
|
let notes = result["data"].take();
|
||||||
|
|
||||||
|
if output_format == "text" {
|
||||||
|
if let Some(notes) = notes.as_str() {
|
||||||
|
println!("{}", notes);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format_and_print_result(
|
||||||
|
&json!({
|
||||||
|
"notes": notes,
|
||||||
|
}),
|
||||||
|
&output_format,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(
|
||||||
|
input: {
|
||||||
|
properties: {
|
||||||
|
repository: {
|
||||||
|
schema: REPO_URL_SCHEMA,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
type: String,
|
||||||
|
description: "Snapshot path.",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: String,
|
||||||
|
description: "The Notes.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)]
|
||||||
|
/// Update Notes
|
||||||
|
async fn update_notes(param: Value) -> Result<Value, Error> {
|
||||||
|
let repo = extract_repository_from_value(¶m)?;
|
||||||
|
let path = tools::required_string_param(¶m, "snapshot")?;
|
||||||
|
let notes = tools::required_string_param(¶m, "notes")?;
|
||||||
|
|
||||||
|
let snapshot: BackupDir = path.parse()?;
|
||||||
|
let mut client = connect(&repo)?;
|
||||||
|
|
||||||
|
let path = format!("api2/json/admin/datastore/{}/notes", repo.store());
|
||||||
|
|
||||||
|
let args = json!({
|
||||||
|
"backup-type": snapshot.group().backup_type(),
|
||||||
|
"backup-id": snapshot.group().backup_id(),
|
||||||
|
"backup-time": snapshot.backup_time(),
|
||||||
|
"notes": notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.put(&path, Some(args)).await?;
|
||||||
|
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notes_cli() -> CliCommandMap {
|
||||||
|
CliCommandMap::new()
|
||||||
|
.insert(
|
||||||
|
"show",
|
||||||
|
CliCommand::new(&API_METHOD_SHOW_NOTES)
|
||||||
|
.arg_param(&["snapshot"])
|
||||||
|
.completion_cb("snapshot", complete_backup_snapshot),
|
||||||
|
)
|
||||||
|
.insert(
|
||||||
|
"update",
|
||||||
|
CliCommand::new(&API_METHOD_UPDATE_NOTES)
|
||||||
|
.arg_param(&["snapshot", "notes"])
|
||||||
|
.completion_cb("snapshot", complete_backup_snapshot),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_mgtm_cli() -> CliCommandMap {
|
||||||
|
CliCommandMap::new()
|
||||||
|
.insert("notes", notes_cli())
|
||||||
|
.insert(
|
||||||
|
"list",
|
||||||
|
CliCommand::new(&API_METHOD_LIST_SNAPSHOTS)
|
||||||
|
.arg_param(&["group"])
|
||||||
|
.completion_cb("group", complete_backup_group)
|
||||||
|
.completion_cb("repository", complete_repository)
|
||||||
|
)
|
||||||
|
.insert(
|
||||||
|
"files",
|
||||||
|
CliCommand::new(&API_METHOD_LIST_SNAPSHOT_FILES)
|
||||||
|
.arg_param(&["snapshot"])
|
||||||
|
.completion_cb("repository", complete_repository)
|
||||||
|
.completion_cb("snapshot", complete_backup_snapshot)
|
||||||
|
)
|
||||||
|
.insert(
|
||||||
|
"forget",
|
||||||
|
CliCommand::new(&API_METHOD_FORGET_SNAPSHOTS)
|
||||||
|
.arg_param(&["snapshot"])
|
||||||
|
.completion_cb("repository", complete_repository)
|
||||||
|
.completion_cb("snapshot", complete_backup_snapshot)
|
||||||
|
)
|
||||||
|
.insert(
|
||||||
|
"upload-log",
|
||||||
|
CliCommand::new(&API_METHOD_UPLOAD_LOG)
|
||||||
|
.arg_param(&["snapshot", "logfile"])
|
||||||
|
.completion_cb("snapshot", complete_backup_snapshot)
|
||||||
|
.completion_cb("logfile", tools::complete_file_name)
|
||||||
|
.completion_cb("keyfile", tools::complete_file_name)
|
||||||
|
.completion_cb("repository", complete_repository)
|
||||||
|
)
|
||||||
|
}
|
@ -395,7 +395,7 @@ Ext.define('PBS.dashboard.SubscriptionInfo', {
|
|||||||
break;
|
break;
|
||||||
case 0:
|
case 0:
|
||||||
icon = 'times-circle critical';
|
icon = 'times-circle critical';
|
||||||
message = gettext('<h1>No valid subscription</h1>' + PBS.Utils.noSubKeyHtml);
|
message = `<h1>${gettext('No valid subscription')}</h1>${PBS.Utils.noSubKeyHtml}`;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw 'invalid subscription status';
|
throw 'invalid subscription status';
|
||||||
|
Reference in New Issue
Block a user