This is an automated email from the ASF dual-hosted git repository.
sshenoy pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/master by this push:
new d1615b4d4d HDDS-8547. Support Trash for FSO bucket using ozone sh
command. (#4675)
d1615b4d4d is described below
commit d1615b4d4d83a3f6a05d38f32d080c50a80225fe
Author: ashishkumar50 <[email protected]>
AuthorDate: Thu May 18 12:10:50 2023 +0530
HDDS-8547. Support Trash for FSO bucket using ozone sh command. (#4675)
---
.../dist/src/main/smoketest/basic/links.robot | 2 +-
.../src/main/smoketest/basic/ozone-shell-lib.robot | 25 ++-
.../src/main/smoketest/basic/ozone-shell.robot | 4 +
.../main/smoketest/security/ozone-secure-fs.robot | 2 +-
.../hadoop/ozone/shell/TestOzoneShellHA.java | 179 ++++++++++++++++++++-
.../hadoop/ozone/shell/keys/DeleteKeyHandler.java | 115 ++++++++++++-
6 files changed, 313 insertions(+), 14 deletions(-)
diff --git a/hadoop-ozone/dist/src/main/smoketest/basic/links.robot
b/hadoop-ozone/dist/src/main/smoketest/basic/links.robot
index 5278920a10..f89ea2d755 100644
--- a/hadoop-ozone/dist/src/main/smoketest/basic/links.robot
+++ b/hadoop-ozone/dist/src/main/smoketest/basic/links.robot
@@ -105,7 +105,7 @@ Key list passthrough
Should Contain ${source_list} key2
Key delete passthrough
- Execute ozone sh key delete
${target}/link1/key2
+ Execute ozone sh key delete
--skipTrash ${target}/link1/key2
${source_list} = Execute ozone sh key list
${source}/bucket1 | jq -r '.[].name'
Should Not Contain ${source_list} key2
diff --git a/hadoop-ozone/dist/src/main/smoketest/basic/ozone-shell-lib.robot
b/hadoop-ozone/dist/src/main/smoketest/basic/ozone-shell-lib.robot
index ff612aa397..5ab2938464 100644
--- a/hadoop-ozone/dist/src/main/smoketest/basic/ozone-shell-lib.robot
+++ b/hadoop-ozone/dist/src/main/smoketest/basic/ozone-shell-lib.robot
@@ -151,13 +151,13 @@ Test key handling
Execute diff -q /opt/hadoop/NOTICE.txt
/tmp/key1_RATIS
${result} = Execute ozone sh key info
${protocol}${server}/${volume}/bb1/key1_RATIS | jq -r '. |
select(.name=="key1_RATIS")'
Should contain ${result} RATIS
- Execute ozone sh key delete
${protocol}${server}/${volume}/bb1/key1_RATIS
+ Execute ozone sh key delete --skipTrash
${protocol}${server}/${volume}/bb1/key1_RATIS
Execute ozone sh key cp
${protocol}${server}/${volume}/bb1 key1 key1-copy
Execute rm -f /tmp/key1-copy
Execute ozone sh key get
${protocol}${server}/${volume}/bb1/key1-copy /tmp/key1-copy
Execute diff -q /opt/hadoop/NOTICE.txt
/tmp/key1-copy
- Execute ozone sh key delete
${protocol}${server}/${volume}/bb1/key1-copy
+ Execute ozone sh key delete --skipTrash
${protocol}${server}/${volume}/bb1/key1-copy
${result} = Execute And Ignore Error ozone sh key get
${protocol}${server}/${volume}/bb1/key1 /tmp/NOTICE.txt.1
Should Contain ${result} NOTICE.txt.1 exists
@@ -170,7 +170,7 @@ Test key handling
Execute ozone sh key rename
${protocol}${server}/${volume}/bb1 key1 key2
${result} = Execute ozone sh key list
${protocol}${server}/${volume}/bb1 | jq -r '.[].name'
Should Be Equal ${result} key2
- Execute ozone sh key delete
${protocol}${server}/${volume}/bb1/key2
+ Execute ozone sh key delete --skipTrash
${protocol}${server}/${volume}/bb1/key2
Test key Acls
[arguments] ${protocol} ${server} ${volume}
@@ -204,3 +204,22 @@ Test prefix Acls
${result} = Execute ozone sh key getacl
${protocol}${server}/${volume}/bb1/prefix1/key1
Should Match Regexp ${result} \"type\" :
\"USER\",\n.*\"name\" : \"superuser1\",\n.*\"aclScope\" :
\"ACCESS\",\n.*\"aclList\" : . \"READ\", \"WRITE\", \"READ_ACL\", \"WRITE_ACL\"
Should Match Regexp ${result} \"type\" :
\"GROUP\",\n.*\"name\" : \"superuser1\",\n.*\"aclScope\" :
\"ACCESS\",\n.*\"aclList\" : . \"ALL\" .
+
+Test Delete key with and without Trash
+ [arguments] ${protocol} ${server} ${volume}
+ Execute ozone sh volume create
${protocol}${server}/${volume}
+ Execute ozone sh bucket create
${protocol}${server}/${volume}/bfso --layout FILE_SYSTEM_OPTIMIZED
+ Execute ozone sh key put -t RATIS
${protocol}${server}/${volume}/bfso/key1 /opt/hadoop/NOTICE.txt
+ Execute ozone sh key delete --skipTrash
${protocol}${server}/${volume}/bfso/key1
+ ${result} = Execute ozone sh key list
${protocol}${server}/${volume}/bfso
+ Should not contain ${result} key1
+ Execute ozone sh bucket create
${protocol}${server}/${volume}/obsbkt --layout OBJECT_STORE
+ Execute ozone sh key put -t RATIS
${protocol}${server}/${volume}/obsbkt/key2 /opt/hadoop/NOTICE.txt
+ Execute ozone sh key delete
${protocol}${server}/${volume}/obsbkt/key2
+ ${result} = Execute ozone sh key list
${protocol}${server}/${volume}/obsbkt
+ Should not contain ${result} key2
+ Execute ozone sh key put -t RATIS
${protocol}${server}/${volume}/bfso/key3 /opt/hadoop/NOTICE.txt
+ Execute ozone sh key delete
${protocol}${server}/${volume}/bfso/key3
+ ${result} = Execute ozone sh key list
${protocol}${server}/${volume}/bfso
+ Should Contain Any ${result} .Trash/hadoop
.Trash/testuser .Trash/root
+ Should contain ${result} key3
diff --git a/hadoop-ozone/dist/src/main/smoketest/basic/ozone-shell.robot
b/hadoop-ozone/dist/src/main/smoketest/basic/ozone-shell.robot
index e00cfef06c..e0ed64e0e1 100644
--- a/hadoop-ozone/dist/src/main/smoketest/basic/ozone-shell.robot
+++ b/hadoop-ozone/dist/src/main/smoketest/basic/ozone-shell.robot
@@ -43,3 +43,7 @@ RpcClient prefix acls
RpcClient without host
Test ozone shell o3:// ${EMPTY} ${prefix}-without-host
+
+RpcClient Delete key
+ Test Delete key with and without Trash o3:// om:9862
${prefix}-with-del
+
diff --git
a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-fs.robot
b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-fs.robot
index a47526533a..67158b35ed 100644
--- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-fs.robot
+++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-fs.robot
@@ -70,7 +70,7 @@ Create bucket with non-admin owner(testuser2)
Should not contain ${result} PERMISSION_DENIED
${result} = Execute ozone sh key list ${volume4}/bucket1
Should not contain ${result} PERMISSION_DENIED
- ${result} = Execute ozone sh key delete ${volume4}/bucket1/key1
+ ${result} = Execute ozone sh key delete --skipTrash
${volume4}/bucket1/key1
Should not contain ${result} PERMISSION_DENIED
${result} = Execute ozone sh bucket delete ${volume4}/bucket1
Should not contain ${result} PERMISSION_DENIED
diff --git
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/shell/TestOzoneShellHA.java
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/shell/TestOzoneShellHA.java
index 2401f365a6..fee69a9847 100644
---
a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/shell/TestOzoneShellHA.java
+++
b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/shell/TestOzoneShellHA.java
@@ -20,6 +20,7 @@ package org.apache.hadoop.ozone.shell;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
+import java.io.IOException;
import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
@@ -54,7 +55,9 @@ import org.apache.hadoop.ozone.client.io.OzoneOutputStream;
import org.apache.hadoop.ozone.ha.ConfUtils;
import org.apache.hadoop.ozone.om.OMConfigKeys;
import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
import org.apache.hadoop.ozone.shell.s3.S3Shell;
+import org.apache.hadoop.security.UserGroupInformation;
import org.apache.ozone.test.GenericTestUtils;
import org.apache.ozone.test.LambdaTestUtils;
import org.apache.hadoop.util.ToolRunner;
@@ -68,6 +71,7 @@ import com.google.gson.internal.LinkedTreeMap;
import static java.nio.charset.StandardCharsets.UTF_8;
import static
org.apache.hadoop.fs.CommonConfigurationKeysPublic.FS_TRASH_INTERVAL_KEY;
import static org.apache.hadoop.fs.FileSystem.FS_DEFAULT_NAME_KEY;
+import static org.apache.hadoop.fs.FileSystem.TRASH_PREFIX;
import static org.apache.hadoop.ozone.OzoneConsts.OZONE_OFS_URI_SCHEME;
import org.junit.After;
import org.junit.AfterClass;
@@ -332,13 +336,17 @@ public class TestOzoneShellHA {
/**
* Helper function to generate keys for testing shell command of keys.
*/
- private void generateKeys(String volumeName, String bucketName) {
+ private void generateKeys(String volumeName, String bucketName,
+ String bucketLayout) {
String[] args = new String[] {
"volume", "create", "o3://" + omServiceId + volumeName};
execute(ozoneShell, args);
- args = new String[] {
- "bucket", "create", "o3://" + omServiceId + volumeName + bucketName};
+ args = (Strings.isNullOrEmpty(bucketLayout)) ?
+ new String[] {"bucket", "create", "o3://" + omServiceId +
+ volumeName + bucketName } :
+ new String[] {"bucket", "create", "o3://" + omServiceId +
+ volumeName + bucketName, "--layout", bucketLayout};
execute(ozoneShell, args);
String keyName = volumeName + bucketName + OZONE_URI_DELIMITER + "key";
@@ -464,7 +472,7 @@ public class TestOzoneShellHA {
@Test
public void testOzoneShCmdList() throws UnsupportedEncodingException {
// Part of listing keys test.
- generateKeys("/volume4", "/bucket");
+ generateKeys("/volume4", "/bucket", "");
final String destinationBucket = "o3://" + omServiceId + "/volume4/bucket";
// Test case 1: test listing keys
@@ -516,7 +524,7 @@ public class TestOzoneShellHA {
@Test
public void testOzoneAdminCmdList() throws UnsupportedEncodingException {
// Part of listing keys test.
- generateKeys("/volume6", "/bucket");
+ generateKeys("/volume6", "/bucket", "");
// Test case 1: list OPEN container
String state = "--state=OPEN";
String[] args = new String[] {"container", "list", "--scm",
@@ -1124,6 +1132,167 @@ public class TestOzoneShellHA {
}
}
+
+ @Test
+ public void testKeyDeleteOrSkipTrashWhenTrashEnableFSO()
+ throws IOException {
+ // Create 100 keys
+ generateKeys("/volumefso1", "/bucket1",
+ BucketLayout.FILE_SYSTEM_OPTIMIZED.toString());
+
+ // Enable trash
+ String trashConfKey = generateSetConfString(
+ OMConfigKeys.OZONE_FS_TRASH_INTERVAL_KEY, "1");
+ String[] args =
+ new String[] {trashConfKey, "key", "delete",
+ "/volumefso1/bucket1/key4"};
+
+ // Delete one key from FSO bucket
+ execute(ozoneShell, args);
+
+ // Get key list in .Trash path
+ String prefixKey = "--prefix=.Trash";
+ args = new String[] {"key", "list", prefixKey, "o3://" +
+ omServiceId + "/volumefso1/bucket1/"};
+ out.reset();
+ execute(ozoneShell, args);
+
+ // One key should be present in .Trash
+ Assert.assertEquals(1, getNumOfKeys());
+
+ args = new String[] {"key", "list", "o3://" + omServiceId +
+ "/volumefso1/bucket1/", "-l ", "110"};
+ out.reset();
+ execute(ozoneShell, args);
+
+ // Total number of keys still 100.
+ Assert.assertEquals(100, getNumOfKeys());
+
+ // Skip Trash
+ args = new String[] {trashConfKey, "key", "delete",
+ "/volumefso1/bucket1/key5", "--skipTrash"};
+ execute(ozoneShell, args);
+
+ // .Trash should still contain 1 key
+ prefixKey = "--prefix=.Trash";
+ args = new String[] {"key", "list", prefixKey, "o3://" +
+ omServiceId + "/volumefso1/bucket1/"};
+ out.reset();
+ execute(ozoneShell, args);
+ Assert.assertEquals(1, getNumOfKeys());
+
+ args = new String[] {"key", "list", "o3://" + omServiceId +
+ "/volumefso1/bucket1/", "-l ", "110"};
+ out.reset();
+ execute(ozoneShell, args);
+ // Total number of keys now will be 99 as
+ // 1 key deleted without trash
+ Assert.assertEquals(99, getNumOfKeys());
+
+ final String username =
+ UserGroupInformation.getCurrentUser().getShortUserName();
+ Path trashRoot = new Path(OZONE_URI_DELIMITER, TRASH_PREFIX);
+ Path userTrash = new Path(trashRoot, username);
+ Path current = new Path("Current");
+ Path userTrashCurrent = new Path(userTrash, current);
+
+ // Try to delete from trash path
+ args = new String[] {trashConfKey, "key", "delete",
+ "/volumefso1/bucket1/" + userTrashCurrent.toUri().getPath()
+ + "/key4"};
+
+ out.reset();
+ execute(ozoneShell, args);
+
+ args = new String[] {"key", "list", "o3://" + omServiceId +
+ "/volumefso1/bucket1/", "-l ", "110"};
+ out.reset();
+ execute(ozoneShell, args);
+
+ // Total number of keys still remain 99 as
+ // delete from trash not allowed without --skipTrash
+ Assert.assertEquals(99, getNumOfKeys());
+
+ // Now try to delete from trash path with --skipTrash option
+ args = new String[] {trashConfKey, "key", "delete",
+ "/volumefso1/bucket1/" + userTrashCurrent.toUri().getPath()
+ + "/key4", "--skipTrash"};
+ out.reset();
+ execute(ozoneShell, args);
+
+ args = new String[] {"key", "list", "o3://" + omServiceId +
+ "/volumefso1/bucket1/", "-l ", "110"};
+ out.reset();
+ execute(ozoneShell, args);
+
+ // Total number of keys now will be 98 as
+ // 1 key deleted without trash and 1 from the trash path
+ Assert.assertEquals(98, getNumOfKeys());
+ }
+
+ @Test
+ public void testKeyDeleteWhenTrashDisableFSO()
+ throws UnsupportedEncodingException {
+ // Create 100 keys
+ generateKeys("/volumefso2", "/bucket2",
+ BucketLayout.FILE_SYSTEM_OPTIMIZED.toString());
+ // Disable trash
+ String trashConfKey = generateSetConfString(
+ OMConfigKeys.OZONE_FS_TRASH_INTERVAL_KEY, "0");
+ String[] args =
+ new String[] {trashConfKey, "key",
+ "delete", "/volumefso2/bucket2/key4"};
+
+ execute(ozoneShell, args);
+
+ // Check in .Trash path number of keys
+ final String prefixKey = "--prefix=.Trash";
+ args = new String[] {"key", "list", prefixKey,
+ "o3://" + omServiceId + "/volumefso2/bucket2/"};
+ out.reset();
+ execute(ozoneShell, args);
+
+ // No key should be present in .Trash
+ Assert.assertEquals(0, getNumOfKeys());
+
+ args = new String[] {"key", "list", "o3://" +
+ omServiceId + "/volumefso2/bucket2/"};
+ out.reset();
+ execute(ozoneShell, args);
+
+ // Number of keys remain as 99
+ Assert.assertEquals(99, getNumOfKeys());
+ }
+
+ @Test
+ public void testKeyDeleteWhenTrashEnableOBS()
+ throws UnsupportedEncodingException {
+ generateKeys("/volumeobs1", "/bucket1",
+ BucketLayout.OBJECT_STORE.toString());
+
+ String trashConfKey = generateSetConfString(
+ OMConfigKeys.OZONE_FS_TRASH_INTERVAL_KEY, "1");
+ String[] args =
+ new String[] {trashConfKey, "key",
+ "delete", "/volumeobs1/bucket1/key4"};
+ execute(ozoneShell, args);
+
+ final String prefixKey = "--prefix=.Trash";
+ args = new String[] {"key", "list", prefixKey, "o3://" +
+ omServiceId + "/volumeobs1/bucket1/"};
+ out.reset();
+ execute(ozoneShell, args);
+ Assert.assertEquals(0, getNumOfKeys());
+
+ args = new String[] {"key", "list", "o3://" +
+ omServiceId + "/volumeobs1/bucket1/"};
+ out.reset();
+ execute(ozoneShell, args);
+
+ Assert.assertEquals(99, getNumOfKeys());
+ }
+
+
private void getVolume(String volumeName) {
String[] args = new String[] {"volume", "create",
"o3://" + omServiceId + "/" + volumeName};
diff --git
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/shell/keys/DeleteKeyHandler.java
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/shell/keys/DeleteKeyHandler.java
index a62ad836e0..1d48fdeaa3 100644
---
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/shell/keys/DeleteKeyHandler.java
+++
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/shell/keys/DeleteKeyHandler.java
@@ -18,15 +18,28 @@
package org.apache.hadoop.ozone.shell.keys;
+import org.apache.hadoop.fs.Path;
import org.apache.hadoop.ozone.client.OzoneBucket;
import org.apache.hadoop.ozone.client.OzoneClient;
import org.apache.hadoop.ozone.client.OzoneClientException;
+import org.apache.hadoop.ozone.client.OzoneKeyDetails;
import org.apache.hadoop.ozone.client.OzoneVolume;
+import org.apache.hadoop.ozone.om.helpers.OzoneFileStatus;
import org.apache.hadoop.ozone.shell.OzoneAddress;
-
+import org.apache.hadoop.ozone.om.OMConfigKeys;
+import org.apache.hadoop.ozone.om.helpers.OzoneFSUtils;
+import org.apache.hadoop.security.UserGroupInformation;
+import org.apache.hadoop.util.Time;
+import picocli.CommandLine;
import picocli.CommandLine.Command;
import java.io.IOException;
+import java.util.List;
+
+import static
org.apache.hadoop.fs.CommonConfigurationKeysPublic.FS_TRASH_INTERVAL_DEFAULT;
+import static
org.apache.hadoop.fs.CommonConfigurationKeysPublic.FS_TRASH_INTERVAL_KEY;
+import static org.apache.hadoop.fs.FileSystem.TRASH_PREFIX;
+import static org.apache.hadoop.ozone.OzoneConsts.OZONE_URI_DELIMITER;
/**
* Executes Delete Key.
@@ -35,16 +48,110 @@ import java.io.IOException;
description = "deletes an existing key")
public class DeleteKeyHandler extends KeyHandler {
+ @CommandLine.Option(names = "--skipTrash",
+ description = "Specify whether to skip Trash ")
+ private boolean skipTrash = false;
+
+ private static final Path CURRENT = new Path("Current");
+
@Override
protected void execute(OzoneClient client, OzoneAddress address)
throws IOException, OzoneClientException {
String volumeName = address.getVolumeName();
String bucketName = address.getBucketName();
- String keyName = address.getKeyName();
-
OzoneVolume vol = client.getObjectStore().getVolume(volumeName);
OzoneBucket bucket = vol.getBucket(bucketName);
- bucket.deleteKey(keyName);
+ String keyName = address.getKeyName();
+
+ if (bucket.getBucketLayout().isFileSystemOptimized()) {
+ // Handle FSO delete key which supports trash also
+ deleteFSOKey(bucket, keyName);
+ } else {
+ bucket.deleteKey(keyName);
+ }
+ }
+
+ private void deleteFSOKey(OzoneBucket bucket, String keyName)
+ throws IOException {
+ float hadoopTrashInterval = getConf().getFloat(
+ FS_TRASH_INTERVAL_KEY, FS_TRASH_INTERVAL_DEFAULT);
+ long trashInterval =
+ (long) (getConf().getFloat(
+ OMConfigKeys.OZONE_FS_TRASH_INTERVAL_KEY,
+ hadoopTrashInterval) * 10000);
+
+ // If Bucket layout is FSO and Trash is enabled
+ // In this case during delete operation move key to trash
+ if (trashInterval > 0 && !skipTrash &&
+ !keyName.contains(TRASH_PREFIX)) {
+ keyName = OzoneFSUtils.removeTrailingSlashIfNeeded(keyName);
+ // Check if key exists in Ozone
+ if (!isKeyExist(bucket, keyName)) {
+ out().printf("Key not found %s %n", keyName);
+ return;
+ }
+
+ if (bucket.getFileStatus(keyName).isDirectory()) {
+ List<OzoneFileStatus> ozoneFileStatusList =
+ bucket.listStatus(keyName, false, "", 1);
+ if (ozoneFileStatusList != null && !ozoneFileStatusList.isEmpty()) {
+ out().printf("Directory is not empty %n");
+ return;
+ }
+ }
+
+ final String username =
+ UserGroupInformation.getCurrentUser().getShortUserName();
+ Path trashRoot = new Path(OZONE_URI_DELIMITER, TRASH_PREFIX);
+ Path userTrash = new Path(trashRoot, username);
+ Path userTrashCurrent = new Path(userTrash, CURRENT);
+
+ String trashDirectory = (keyName.contains("/")
+ ? new Path(userTrashCurrent, keyName.substring(0,
+ keyName.lastIndexOf("/")))
+ : userTrashCurrent).toUri().getPath();
+
+ String toKeyName = new Path(userTrashCurrent, keyName).toUri().getPath();
+ if (isKeyExist(bucket, toKeyName)) {
+ if (bucket.getFileStatus(toKeyName).isDirectory()) {
+ // if directory already exist in trash, just delete the directory
+ bucket.deleteKey(keyName);
+ return;
+ }
+ // Key already exists in trash, Append timestamp with keyName
+ // And move into trash
+ // Same behaviour as filesystem trash
+ toKeyName += Time.now();
+ }
+
+ // Check whether trash directory already exist inside bucket
+ if (!isKeyExist(bucket, trashDirectory)) {
+ // Trash directory doesn't exist
+ // Create directory inside trash
+ bucket.createDirectory(trashDirectory);
+ }
+
+ // Rename key to move inside trash folder
+ bucket.renameKey(keyName, toKeyName);
+ out().printf("Key moved inside Trash: %s %n", toKeyName);
+ } else if (trashInterval > 0 && !skipTrash &&
+ keyName.contains(TRASH_PREFIX)) {
+ // Delete from trash not possible when user didn't do skipTrash
+ out().printf("Use --skipTrash to delete key from Trash %n");
+ } else {
+ bucket.deleteKey(keyName);
+ }
+ }
+
+ private boolean isKeyExist(OzoneBucket bucket, String keyName) {
+ OzoneKeyDetails keyDetails;
+ try {
+ // check whether key exist
+ keyDetails = bucket.getKey(keyName);
+ } catch (IOException e) {
+ return false;
+ }
+ return (keyDetails != null);
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]