]> www.infradead.org Git - users/hch/nvmetcli.git/commitdiff
initial public release v0.1
authorChristoph Hellwig <hch@lst.de>
Wed, 16 Mar 2016 16:44:56 +0000 (17:44 +0100)
committerChristoph Hellwig <hch@lst.de>
Wed, 16 Mar 2016 16:57:54 +0000 (17:57 +0100)
.gitignore [new file with mode: 0644]
COPYING [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.md [new file with mode: 0644]
nvmet.json [new file with mode: 0644]
nvmet/__init__.py [new file with mode: 0644]
nvmet/nvme.py [new file with mode: 0644]
nvmetcli [new file with mode: 0755]
setup.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..be778db
--- /dev/null
@@ -0,0 +1,8 @@
+*.swp
+*.swo
+build-stamp
+build/*
+dist/*
+doc/*
+*.pyc
+*.pyc
diff --git a/COPYING b/COPYING
new file mode 100644 (file)
index 0000000..68c771a
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,176 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..e1887a6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,49 @@
+PKGNAME = nvmetcli
+NAME = nvmet
+GIT_BRANCH = $$(git branch | grep \* | tr -d \*)
+VERSION = $$(basename $$(git describe --tags | tr - . | sed 's/^v//'))
+
+all:
+       @echo "Usage:"
+       @echo
+       @echo "  make release     - Generates the release tarball."
+       @echo
+       @echo "  make clean       - Cleanup the local repository build files."
+       @echo "  make cleanall    - Also remove dist/*"
+
+clean:
+       @rm -fv ${NAME}/*.pyc ${NAME}/*.html
+       @rm -frv doc
+       @rm -frv ${NAME}.egg-info MANIFEST build
+       @rm -fv build-stamp
+       @rm -frv results
+       @rm -frv ${PKGNAME}-*
+       @echo "Finished cleanup."
+
+cleanall: clean
+       @rm -frv dist
+
+release: build/release-stamp
+build/release-stamp:
+       @mkdir -p build
+       @echo "Exporting the repository files..."
+       @git archive ${GIT_BRANCH} --prefix ${PKGNAME}-${VERSION}/ \
+               | (cd build; tar xfp -)
+       @echo "Cleaning up the target tree..."
+       @rm -f build/${PKGNAME}-${VERSION}/Makefile
+       @rm -f build/${PKGNAME}-${VERSION}/.gitignore
+       @echo "Fixing version string..."
+       @sed -i "s/__version__ = .*/__version__ = '${VERSION}'/g" \
+               build/${PKGNAME}-${VERSION}/${NAME}/__init__.py
+       @find build/${PKGNAME}-${VERSION}/ -exec \
+               touch -t $$(date -d @$$(git show -s --format="format:%at") \
+                       +"%Y%m%d%H%M.%S") {} \;
+       @mkdir -p dist
+       @cd build; tar -c --owner=0 --group=0 --numeric-owner \
+               --format=gnu -b20 --quoting-style=escape \
+               -f ../dist/${PKGNAME}-${VERSION}.tar \
+               $$(find ${PKGNAME}-${VERSION} -type f | sort)
+       @gzip -6 -n dist/${PKGNAME}-${VERSION}.tar
+       @echo "Generated release tarball:"
+       @echo "    $$(ls dist/${PKGNAME}-${VERSION}.tar.gz)"
+       @touch build/release-stamp
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..7a8a56b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+
+nvmetcli
+========
+
+This contains the NVMe target admin tool "nvmetcli".  It can either be
+used interactively by invoking it without arguments, or it can be used
+to save, restore or clear the current NVMe target configuration.
+
+
+Installation
+------------
+
+Please install the configshell-fb package from
+https://github.com/agrover/configshell-fb first.
+
+Nvmetcli can be run directly from the source directory or installed
+using setup.py.
+
+
+Usage
+-----
+
+Make sure to run nvmetcli as root, the nvmet module is loaded and
+configfs is mounted on /sys/kernel/config, using:
+
+       mount -t configs none /sys/kernel/config
+
+You can load the default config that exports the first NVMe device and
+the first ramdisk by running "nvmetcli restore nvmet.json".  The default
+config is stored in /etc/nvmet.json.  You can also edit the json file
+directly.
+
+To get started with the interactive mode start nvmetcli without
+arguments.  Then in the nvmetcli prompt type:
+
+# 
+# Create a subsystem.  If you do not specify a name a NQN will be generated.
+#
+
+> cd /subsystems
+/subsystems> create testnqn
+
+#
+# Create a new namespace.  If you do not specify a namespace ID the fist
+# unused one will be used.
+#
+
+/subsystems> cd testnqn/
+/subsystems/testnqn> cd namespaces 
+/subsystems/testnqn/namespaces> create 1
+/subsystems/testnqn/namespaces> cd 1
+/subsystems/t.../namespaces/1> set device path=/dev/ram1
diff --git a/nvmet.json b/nvmet.json
new file mode 100644 (file)
index 0000000..8b5309c
--- /dev/null
@@ -0,0 +1,21 @@
+{
+  "subsystems": [
+    {
+      "namespaces": [
+        {
+          "device": {
+            "path": "/dev/ram0"
+          }, 
+          "nsid": 2
+        }, 
+        {
+          "device": {
+            "path": "/dev/nvme0n1"
+          }, 
+          "nsid": 1
+        }
+      ], 
+      "nqn": "nqn.2014-08.org.nvmexpress:NVMf:uuid:77dca664-0d3e-4f67-b8b2-04c70e3f991d"
+    }
+  ]
+}
diff --git a/nvmet/__init__.py b/nvmet/__init__.py
new file mode 100644 (file)
index 0000000..eba0ebb
--- /dev/null
@@ -0,0 +1 @@
+from nvme import Root, Subsystem, Namespace
diff --git a/nvmet/nvme.py b/nvmet/nvme.py
new file mode 100644 (file)
index 0000000..fffdb0e
--- /dev/null
@@ -0,0 +1,474 @@
+'''
+Implements access to the NVMe target configfs hierarchy
+
+Copyright (c) 2011-2013 by Datera, Inc.
+Copyright (c) 2011-2014 by Red Hat, Inc.
+Copyright (c) 2016 by HGST, a Western Digital Company.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may
+not use this file except in compliance with the License. You may obtain
+a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations
+under the License.
+'''
+
+import os
+import stat
+import uuid
+import json
+from glob import iglob as glob
+
+DEFAULT_SAVE_FILE = 'saveconfig.json'
+
+
+class CFSError(Exception):
+    '''
+    Generic slib error.
+    '''
+    pass
+
+
+class CFSNotFound(CFSError):
+    '''
+    The underlying configfs object does not exist. Happens when
+    calling methods of an object that is instantiated but have
+    been deleted from congifs, or when trying to lookup an
+    object that does not exist.
+    '''
+    pass
+
+
+class CFSNode(object):
+
+    configfs_dir = '/sys/kernel/config/nvmet'
+
+    def __init__(self):
+        self._path = self.configfs_dir
+        self._attr_groups = []
+
+    def __eq__(self, other):
+        return self._path == other._path
+
+    def __ne__(self, other):
+        return self._path != other._path
+
+    def _get_path(self):
+        return self._path
+
+    def _create_in_cfs(self, mode):
+        '''
+        Creates the configFS node if it does not already exist, depending on
+        the mode.
+        any -> makes sure it exists, also works if the node already does exist
+        lookup -> make sure it does NOT exist
+        create -> create the node which must not exist beforehand
+        '''
+        if mode not in ['any', 'lookup', 'create']:
+            raise CFSError("Invalid mode: %s" % mode)
+        if self.exists and mode == 'create':
+            raise CFSError("This %s already exists in configFS"
+                              % self.__class__.__name__)
+        elif not self.exists and mode == 'lookup':
+            raise CFSNotFound("No such %s in configfs: %s"
+                                 % (self.__class__.__name__, self.path))
+
+        if not self.exists:
+            try:
+                os.mkdir(self.path)
+            except:
+                raise CFSError("Could not create %s in configFS"
+                                  % self.__class__.__name__)
+
+    def _exists(self):
+        return os.path.isdir(self.path)
+
+    def _check_self(self):
+        if not self.exists:
+            raise CFSNotFound("This %s does not exist in configFS"
+                                 % self.__class__.__name__)
+
+    def list_attrs(self, group, writable=None):
+        '''
+        @param group: The attribute group
+        @param writable: If None (default), returns all attributes, if True,
+        returns read-write attributes, if False, returns just the read-only
+        attributes.
+        @type writable: bool or None
+        @return: A list of existing attribute names as strings.
+        '''
+        self._check_self()
+
+        names = [os.path.basename(name).split('_')[1]
+                 for name in glob("%s/%s_*" % (self._path, group))
+                     if os.path.isfile(name)]
+
+        if writable is True:
+            names = [name for name in names
+                     if self._attr_is_writable(group, name)]
+        elif writable is False:
+            names = [name for name in names
+                     if not self._attr_is_writable(group, name)]
+
+        names.sort()
+        return names
+
+    def _attr_is_writable(self, group, name):
+        s = os.stat("%s/%s_%s" % (self._path, group, name))
+        return s[stat.ST_MODE] & stat.S_IWUSR
+
+    def set_attr(self, group, attribute, value):
+        '''
+        Sets the value of a named attribute.
+        The attribute must exist in configFS.
+        @param group: The attribute group
+        @param attribute: The attribute's name.
+        @param value: The attribute's value.
+        @type value: string
+        '''
+        self._check_self()
+        path = "%s/%s_%s" % (self.path, str(group), str(attribute))
+
+        if not os.path.isfile(path):
+            raise CFSError("Cannot find attribute: %s" % path)
+
+        try:
+            with open(path, 'w') as file_fd:
+                file_fd.write(str(value))
+        except Exception as e:
+            raise CFSError("Cannot set attribute %s: %s" % (path, e))
+
+    def get_attr(self, group, attribute):
+        '''
+        Gets the value of a named attribute.
+        @param group: The attribute group
+        @param attribute: The attribute's name.
+        @return: The named attribute's value, as a string.
+        '''
+        self._check_self()
+        path = "%s/%s_%s" % (self.path, str(group), str(attribute))
+        if not os.path.isfile(path):
+            raise CFSError("Cannot find attribute: %s" % path)
+
+        with open(path, 'r') as file_fd:
+            return file_fd.read().strip()
+
+    def delete(self):
+        '''
+        If the underlying configFS object does not exist, this method does
+        nothing. If the underlying configFS object exists, this method attempts
+        to delete it.
+        '''
+        if self.exists:
+            os.rmdir(self.path)
+
+    path = property(_get_path,
+            doc="Get the configFS object path.")
+    exists = property(_exists,
+            doc="Is True as long as the underlying configFS object exists. "
+                      + "If the underlying configFS objects gets deleted "
+                      + "either by calling the delete() method, or by any "
+                      + "other means, it will be False.")
+
+    def dump(self):
+        d = {}
+        for group in self._attr_groups:
+            a = {}
+            for i in self.list_attrs(group, writable=True):
+                a[str(i)] = self.get_attr(group, i)
+            d[str(group)] = a
+        return d
+
+    def _setup_attrs(self, attr_dict, err_func):
+        for group in self._attr_groups:
+            for name, value in attr_dict.get(group, {}).iteritems():
+                try:
+                    self.set_attr(group, name, value)
+                except CFSError as e:
+                    err_func(str(e))
+
+
+class Root(CFSNode):
+    def __init__(self):
+        super(Root, self).__init__()
+        self._path = self.configfs_dir
+        self._create_in_cfs('lookup')
+
+    def _list_subsystems(self):
+        self._check_self()
+
+        for d in os.listdir("%s/subsystems/" % self._path):
+            yield Subsystem(d, 'lookup')
+
+    subsystems = property(_list_subsystems,
+                doc="Get the list of Subsystems.")
+
+    def save_to_file(self, savefile=None):
+        '''
+        Write the configuration in json format to a file.
+        Save file defaults to '/etc/targets/saveconfig.json'.
+        '''
+        if savefile:
+            savefile = os.path.expanduser(savefile)
+        else:
+            savefile = DEFAULT_SAVE_FILE
+
+        with open(savefile + ".temp", "w+") as f:
+            os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR)
+            f.write(json.dumps(self.dump(), sort_keys=True, indent=2))
+            f.write("\n")
+            os.fsync(f.fileno())
+
+        os.rename(savefile + ".temp", savefile)
+
+    def clear_existing(self):
+        '''
+        Remove entire current configuration.
+        '''
+
+        for s in self.subsystems:
+            s.delete()
+
+    def restore(self, config, clear_existing=False, abort_on_error=False):
+        '''
+        Takes a dict generated by dump() and reconfigures the target to match.
+        Returns list of non-fatal errors that were encountered.
+        Will refuse to restore over an existing configuration unless
+        clear_existing is True.
+        '''
+        if clear_existing:
+            self.clear_existing()
+        else:
+            if any(self.subsystems):
+                raise CFSError("subsystems present, not restoring")
+
+        errors = []
+
+        if abort_on_error:
+            def err_func(err_str):
+                raise CFSError(err_str)
+        else:
+            def err_func(err_str):
+                errors.append(err_str + ", skipped")
+
+        for index, t in enumerate(config.get('subsystems', [])):
+            if 'nqn' not in t:
+                err_func("'nqn' not defined in subsystem %d" % index)
+                continue
+
+            Subsystem.setup(t, err_func)
+
+        return errors
+
+    def restore_from_file(self, savefile=None, clear_existing=True,
+            abort_on_error=False):
+        '''
+        Restore the configuration from a file in json format.
+        Returns a list of non-fatal errors. If abort_on_error is set,
+          it will raise the exception instead of continuing.
+        '''
+        if savefile:
+            savefile = os.path.expanduser(savefile)
+        else:
+            savefile = DEFAULT_SAVE_FILE
+
+        with open(savefile, "r") as f:
+            config = json.loads(f.read())
+            return self.restore(config, clear_existing=clear_existing,
+                                abort_on_error=abort_on_error)
+
+    def dump(self):
+        d = super(Root, self).dump()
+        d['subsystems'] = [s.dump() for s in self.subsystems]
+        return d
+
+
+class Subsystem(CFSNode):
+    '''
+    This is an interface to a NVMe Subsystem in configFS.
+    A Subsystem is identified by its NQN.
+    '''
+
+    def __repr__(self):
+        return "<Namespace %s>" % self.nqn
+
+    def __init__(self, nqn=None, mode='any'):
+        '''
+        @param nqn: The optional Target's NQN.
+            If no NQN is specified, one will be generated.
+        @type nqn: string
+        @param mode:An optionnal string containing the object creation mode:
+            - I{'any'} means the configFS object will be either looked up
+              or created.
+            - I{'lookup'} means the object MUST already exist configFS.
+            - I{'create'} means the object must NOT already exist in configFS.
+        @type mode:string
+        @return: A Subsystem object.
+        '''
+        super(Subsystem, self).__init__()
+
+        if nqn is None:
+            nqn = self._generate_nqn()
+
+        self.nqn = nqn
+        self._path = "%s/subsystems/%s" % (self.configfs_dir, nqn)
+        self._create_in_cfs(mode)
+
+    def _generate_nqn(self):
+        prefix = "nqn.2014-08.org.nvmexpress:NVMf:uuid"
+        name = str(uuid.uuid4())
+        return "%s:%s" % (prefix, name)
+
+    def _list_namespaces(self):
+        self._check_self()
+        for d in os.listdir("%s/namespaces/" % self._path):
+            yield Namespace(self, int(d), 'lookup')
+
+    def delete(self):
+        '''
+        Recursively deletes a Subsystems object.
+        This will delete all attached Namespace objects and then the
+        Subsystem itself.
+        '''
+        self._check_self()
+        for ns in self.namespaces:
+            ns.delete()
+        super(Subsystem, self).delete()
+
+    namespaces = property(_list_namespaces,
+                doc="Get the list of Namespaces for the Subsystem.")
+
+    @classmethod
+    def setup(cls, t, err_func):
+        '''
+        Set up Subsystems objects based upon t dict, from saved config.
+        Guard against missing or bad dict items, but keep going.
+        Call 'err_func' for each error.
+        '''
+
+        if 'nqn' not in t:
+            err_func("'nqn' not defined for Subsystem")
+            return
+
+        try:
+            s = Subsystem(t['nqn'])
+        except CFSError as e:
+            err_func("Could not create Subsystem object: %s" % e)
+            return
+
+        for ns in t.get('namespaces', []):
+            Namespace.setup(s, ns, err_func)
+
+    def dump(self):
+        d = super(Subsystem, self).dump()
+        d['nqn'] = self.nqn
+        d['namespaces'] = [ns.dump() for ns in self.namespaces]
+        return d
+
+
+class Namespace(CFSNode):
+    '''
+    This is an interface to a NVMe Namespace in configFS.
+    A Namespace is identified by its parent Subsystem and Namespace ID.
+    '''
+
+    MAX_NSID = 8192
+
+    def __repr__(self):
+        return "<Namspace %d>" % self.nsid
+
+    def __init__(self, subsystem, nsid=None, mode='any'):
+        '''
+        A LUN object can be instanciated in two ways:
+            - B{Creation mode}: If I{storage_object} is specified, the
+              underlying configFS object will be created with that parameter.
+              No LUN with the same I{lun} index can pre-exist in the parent TPG
+              in that mode, or instanciation will fail.
+            - B{Lookup mode}: If I{storage_object} is not set, then the LUN
+              will be bound to the existing configFS LUN object of the parent
+              TPG having the specified I{lun} index. The underlying configFS
+              object must already exist in that mode.
+
+        @param parent_tpg: The parent TPG object.
+        @type parent_tpg: TPG
+        @param lun: The LUN index.
+        @type lun: 0-255
+        @param storage_object: The storage object to be exported as a LUN.
+        @type storage_object: StorageObject subclass
+        @param alias: An optional parameter to manually specify the LUN alias.
+        You probably do not need this.
+        @type alias: string
+        @return: A LUN object.
+        '''
+        super(Namespace, self).__init__()
+
+        if not isinstance(subsystem, Subsystem):
+            raise CFSError("Invalid parent class")
+
+        if nsid is None:
+            nsids = [n.nsid for n in self.subsystem.namespaces]
+            for index in xrange(1, self.MAX_NSID + 1):
+                if index not in nsids:
+                    nsid = index
+                    break
+            if nsid is None:
+                raise CFSError("All NSIDs 0-%d in use" % self.MAX_NSID)
+        else:
+            nsid = int(nsid)
+            if nsid < 0 or nsid > self.MAX_NSID:
+                raise CFSError("NSID must be 0 to %d" % self.MAX_NSID)
+
+        self._attr_groups = ['device']
+        self._subsystem = subsystem
+        self._nsid = nsid
+        self._path = "%s/namespaces/%d" % (self.subsystem.path, self.nsid)
+        self._create_in_cfs(mode)
+
+    def _get_subsystem(self):
+        return self._subsystem
+
+    def _get_nsid(self):
+        return self._nsid
+
+    subsystem = property(_get_subsystem,
+            doc="Get the parent Subsystem object.")
+    nsid = property(_get_nsid,
+            doc="Get the NSID as an int.")
+
+    @classmethod
+    def setup(cls, subsys, n, err_func):
+        '''
+        Set up a Namespace object based upon n dict, from saved config.
+        Guard against missing or bad dict items, but keep going.
+        Call 'err_func' for each error.
+        '''
+
+        if 'nsid' not in n:
+            err_func("'nsid' not defined for Namespace")
+            return
+
+        try:
+            ns = Namespace(subsys, n['nsid'])
+        except CFSError as e:
+            err_func("Could not create Namespace object: %s" % e)
+            return
+
+        ns._setup_attrs(n, err_func)
+
+    def dump(self):
+        d = super(Namespace, self).dump()
+        d['nsid'] = self.nsid
+        return d
+
+
+def _test():
+    from doctest import testmod
+    testmod()
+
+if __name__ == "__main__":
+    _test()
diff --git a/nvmetcli b/nvmetcli
new file mode 100755 (executable)
index 0000000..0650cab
--- /dev/null
+++ b/nvmetcli
@@ -0,0 +1,246 @@
+#!/usr/bin/python
+
+'''
+Frontend to access to the NVMe target configfs hierarchy
+
+Copyright (c) 2016 by HGST, a Western Digital Company.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may
+not use this file except in compliance with the License. You may obtain
+a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations
+under the License.
+'''
+
+import sys
+import configshell_fb as configshell
+import nvmet.nvme as nvme
+
+DEFAULT_SAVE_FILE = "/etc/nvmet.json"
+
+
+class UIRootNode(configshell.node.ConfigNode):
+    def __init__(self, shell):
+        configshell.node.ConfigNode.__init__(self, '/', shell=shell)
+        self.cfnode = nvme.Root()
+        self.refresh()
+
+    def refresh(self):
+        self._children = set([])
+        UISubsystemsNode(self)
+
+    def ui_command_saveconfig(self, savefile=None):
+        '''
+        Saves the current configuration to a file so that it can be restored
+        on next boot.
+        '''
+        self.cfnode.save_to_file(savefile)
+
+    def ui_command_restoreconfig(self, savefile=None, clear_existing=False):
+        '''
+        Restores configuration from a file.
+        '''
+        errors = self.cfnode.restore_from_file(savefile, clear_existing)
+        self.refresh()
+
+        if errors:
+            raise configshell.ExecutionError(
+                "Configuration restored, %d errors:\n%s" %
+                (len(errors), "\n".join(errors)))
+
+
+class UISubsystemsNode(configshell.node.ConfigNode):
+    def __init__(self, parent):
+        configshell.node.ConfigNode.__init__(self, 'subsystems', parent)
+        self._parent = parent
+        self.refresh()
+
+    def refresh(self):
+        self._children = set([])
+        for subsys in self._parent.cfnode.subsystems:
+            UISubsystemNode(self, subsys)
+
+    def ui_command_create(self, nqn=None):
+        '''
+        Creates a new target. If I{nqn} is ommited, then the new Subsystem
+        will be created using a randomly generated NQN.
+
+        SEE ALSO
+        ========
+        B{delete}
+        '''
+        subsystem = nvme.Subsystem(nqn, mode='create')
+        UISubsystemNode(self, subsystem)
+
+    def ui_command_delete(self, nqn):
+        '''
+        Recursively deletes the subsystem with the specified I{nqn}, and all
+        objects hanging under it.
+
+        SEE ALSO
+        ========
+        B{delete}
+        '''
+        subsystem = nvme.Subsystem(nqn, mode='lookup')
+        subsystem.delete()
+        self.refresh()
+
+
+class UISubsystemNode(configshell.node.ConfigNode):
+    def __init__(self, parent, cfnode):
+        configshell.node.ConfigNode.__init__(self, cfnode.nqn, parent)
+        self.cfnode = cfnode
+        self.refresh()
+
+    def refresh(self):
+        self._children = set([])
+        UINamespacesNode(self)
+
+
+class UINamespacesNode(configshell.node.ConfigNode):
+    def __init__(self, parent):
+        configshell.node.ConfigNode.__init__(self, 'namespaces', parent)
+        self._parent = parent
+        self.refresh()
+
+    def refresh(self):
+        self._children = set([])
+        for ns in self._parent.cfnode.namespaces:
+            UINamespaceNode(self, ns)
+
+    def ui_command_create(self, nsid=None):
+        '''
+        Creates a new namespace. If I{nsid} is ommited, then the next
+        available namespace id will be used.
+
+        SEE ALSO
+        ========
+        B{delete}
+        '''
+        namespace = nvme.Namespace(self._parent.cfnode, nsid, mode='create')
+        UINamespaceNode(self, namespace)
+
+    def ui_command_delete(self, nsid):
+        '''
+        Recursively deletes the namespace with the specified I{nsid}, and all
+        objects hanging under it.
+
+        SEE ALSO
+        ========
+        B{delete}
+        '''
+        namespace = nvme.Namespace(self._parent.cfnode, nsid, mode='lookup')
+        namespace.delete()
+        self.refresh()
+
+
+class UINamespaceNode(configshell.node.ConfigNode):
+    def __init__(self, parent, cfnode):
+        configshell.node.ConfigNode.__init__(self, str(cfnode.nsid), parent)
+        self.cfnode = cfnode
+        self.refresh()
+
+    def refresh(self):
+        self._children = set([])
+        self._init_group('device')
+
+    def _init_group(self, group):
+        attrs = self.cfnode.list_attrs(group)
+        attrs_ro = self.cfnode.list_attrs(group, writable=False)
+        for attr in attrs:
+            writable = attr not in attrs_ro
+            name = "ui_desc_%s" % group
+
+            t, d = getattr(self.__class__, name, {}).get(attr, ('string', ''))
+            self.define_config_group_param(group, attr, t, d, writable)
+
+    def ui_getgroup_device(self, attr):
+        return self.cfnode.get_attr('device', attr)
+
+    def ui_setgroup_device(self, attr, value):
+        return self.cfnode.set_attr('device', attr, value)
+
+
+class UIControllersNode(configshell.node.ConfigNode):
+    def __init__(self, parent):
+        configshell.node.ConfigNode.__init__(self, 'controllers', parent)
+        self._controllers = [0, 1]
+        self.refresh()
+
+    def refresh(self):
+        self._children = set([])
+        for ctrl in self._controllers:
+            UIControllerNode(self, ctrl)
+
+
+class UIControllerNode(configshell.node.ConfigNode):
+    def __init__(self, parent, cntlid):
+        configshell.node.ConfigNode.__init__(self, str(cntlid), parent)
+
+
+def usage():
+    print("syntax: %s save [file_to_save_to]" % sys.argv[0])
+    print("        %s restore [file_to_restore_from]" % sys.argv[0])
+    print("        %s clear" % sys.argv[0])
+    sys.exit(-1)
+
+
+def save(to_file):
+    nvme.Root().save_to_file(to_file)
+
+
+def restore(from_file):
+    try:
+        errors = nvme.Root().restore_from_file(from_file)
+    except IOError:
+        # Not an error if the restore file is not present
+        print("No saved config file at %s, ok, exiting" % from_file)
+    sys.exit(0)
+
+    for error in errors:
+        print(error)
+
+
+def clear(unused):
+    nvme.Root().clear_existing()
+
+
+funcs = dict(save=save, restore=restore, clear=clear)
+
+
+def main():
+    if len(sys.argv) > 3:
+        usage()
+
+    if len(sys.argv) == 2 or len(sys.argv) == 3:
+        if sys.argv[1] == "--help":
+            usage()
+
+        if sys.argv[1] not in funcs.keys():
+            usage()
+
+        if len(sys.argv) == 3:
+            savefile = sys.argv[2]
+        else:
+            savefile = None
+
+        funcs[sys.argv[1]](savefile)
+        return
+
+    shell = configshell.shell.ConfigShell('~/.nvmetcli')
+    UIRootNode(shell)
+
+    while not shell._exit:
+        try:
+            shell.run_interactive()
+        except configshell.ExecutionError as msg:
+            shell.log.error(str(msg))
+
+if __name__ == "__main__":
+    main()
diff --git a/setup.py b/setup.py
new file mode 100755 (executable)
index 0000000..93cdae1
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,29 @@
+#! /usr/bin/env python
+'''
+This file is part of ConfigShell.
+Copyright (c) 2011-2013 by Datera, Inc
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may
+not use this file except in compliance with the License. You may obtain
+a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations
+under the License.
+'''
+
+from setuptools import setup
+
+setup(
+    name = 'nvmetcli',
+    version = '0.1',
+    description = 'NVMe target configuration tool',
+    license = 'Apache 2.0',
+    maintainer = 'Christoph Hellwig',
+    maintainer_email = 'hch@lst.de',
+    packages = ['nvmet'],
+    )