[PATCH] Experimental NFS export support V2
by Tony Asleson
V2 - use variable instead of positional
Signed-off-by: Tony Asleson <tasleson(a)redhat.com>
---
targetd/fs.py | 75 ++++++++++++++++--
targetd/main.py | 18 +++--
targetd/nfs.py | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 320 insertions(+), 13 deletions(-)
create mode 100644 targetd/nfs.py
diff --git a/targetd/fs.py b/targetd/fs.py
index a6fd9b9..63080d3 100644
--- a/targetd/fs.py
+++ b/targetd/fs.py
@@ -20,6 +20,7 @@ import os
import time
from subprocess import Popen, PIPE
from main import TargetdError
+from nfs import Nfs, Export
# Notes:
@@ -46,6 +47,7 @@ fs_cmd = 'btrfs'
pools = []
+
def initialize(config_dict):
global pools
@@ -67,8 +69,11 @@ def initialize(config_dict):
fs_clone=fs_clone,
ss_list=ss,
fs_snapshot=fs_snapshot,
- fs_snapshot_delete=fs_snapshot_delete
- )
+ fs_snapshot_delete=fs_snapshot_delete,
+ nfs_export_auth_list=nfs_export_auth_list,
+ nfs_export_list=nfs_export_list,
+ nfs_export_add=nfs_export_add,
+ nfs_export_remove=nfs_export_remove)
def invoke(cmd, raise_exception=True):
@@ -194,8 +199,8 @@ def fs_pools(req):
return results
-def fs(req):
- fs_list = []
+def _fs_hash():
+ fs_list = {}
for pool in pools:
full_path = os.path.join(pool, fs_path)
@@ -210,9 +215,11 @@ def fs(req):
if len(data):
(total, free) = fs_space_values(full_path)
for e in data:
- fs_list.append(dict(name=e[10], uuid=e[8],
- total_space=total, free_space=free,
- pool=pool))
+ sub_vol = e[10]
+ key = full_path + '/' + sub_vol
+ fs_list[key] = dict(name=sub_vol, uuid=e[8],
+ total_space=total, free_space=free,
+ pool=pool, full_path=key)
break
elif result == 19:
time.sleep(1)
@@ -223,6 +230,11 @@ def fs(req):
return fs_list
+def fs(req):
+ fs_hash = _fs_hash()
+ return fs_hash.values()
+
+
def ss(req, fs_uuid, fs_cache=None):
snapshots = []
@@ -293,3 +305,52 @@ def fs_clone(req, fs_uuid, dest_fs_name, snapshot_id):
raise TargetdError(-51, "Filesystem with that name exists")
invoke([fs_cmd, 'subvolume', 'snapshot', source, dest])
+
+
+def nfs_export_auth_list(req):
+ return Nfs.security_options()
+
+
+def nfs_export_list(req):
+ rc = []
+ fs_hash = _fs_hash()
+
+ exports = Nfs.exports()
+ for e in exports:
+ #Only report those exports which match our filesystem layout
+ if e.path in fs_hash:
+ rc.append(dict(host=e.host, path=e.path, options=e.options_list(),
+ fs_uuid=fs_hash[e.path]['uuid']))
+ return rc
+
+
+def nfs_export_add(req, host, path, export_path, options):
+
+ if export_path is not None:
+ raise TargetdError(-401, "separate export path not supported at "
+ "this time")
+ bit_opt = 0
+ key_opt = {}
+
+ for o in options:
+ if '=' in o:
+ k, v = o.split('=')
+ key_opt[k] = v
+ else:
+ bit_opt |= Export.bool_option[o]
+
+ print 'Calling: %s - %s - %x %s' % (host, path, bit_opt, str(key_opt))
+
+ Nfs.export_add(host, path, bit_opt, key_opt)
+
+
+def nfs_export_remove(req, host, path):
+ exports = Nfs.exports()
+
+ for e in exports:
+ if e.host == host and e.path == path:
+ Nfs.export_remove(e)
+ return None
+
+ raise TargetdError(-400, "NFS export to remove not found %s:%s",
+ (host, path))
\ No newline at end of file
diff --git a/targetd/main.py b/targetd/main.py
index 1082f5f..16173df 100644
--- a/targetd/main.py
+++ b/targetd/main.py
@@ -27,12 +27,14 @@ import yaml
import itertools
import socket
import ssl
+import traceback
+import sys
default_config_path = "/etc/target/targetd.yaml"
default_config = dict(
- block_pools = ['vg-targetd'],
- fs_pools = [],
+ block_pools=['vg-targetd'],
+ fs_pools=[],
user="admin",
# security: no default password
target_name="iqn.2003-01.org.linux-iscsi.%s:targetd" % socket.gethostname(),
@@ -43,6 +45,7 @@ default_config = dict(
config = {}
+
class TargetdError(Exception):
def __init__(self, error_code, message, *args, **kwargs):
Exception.__init__(self, *args, **kwargs)
@@ -80,17 +83,18 @@ async_id = 100
class TargetHandler(BaseHTTPRequestHandler):
def _new_async_id(self):
+ global async_id
with async_id_lock:
new_id = async_id
async_id += 1
return new_id
def mark_async(self):
- '''
+ """
Mark a request as finishing after the given HTTP request returns.
Handlers calling this must
- '''
+ """
if not self.async_id:
self.async_id = self._new_async_id()
rpcdata = json.dumps(
@@ -122,7 +126,6 @@ class TargetHandler(BaseHTTPRequestHandler):
if not code:
del long_op_status[self.async_id]
-
def log_request(self, code='-', size='-'):
# override base class - don't log good requests
pass
@@ -182,9 +185,11 @@ class TargetHandler(BaseHTTPRequestHandler):
result = mapping[method](self)
except KeyError:
error = (-32601, "method %s not found" % method)
+ traceback.print_exc(file=sys.stdout)
raise
except TypeError:
- error = (-32602, "invalid method parameter(s)")
+ error = (-32602, "invalid method parameter(s) %s" % str(params))
+ traceback.print_exc(file=sys.stdout)
raise
except TargetdError, td:
error = (td.error, td.msg)
@@ -207,6 +212,7 @@ class TargetHandler(BaseHTTPRequestHandler):
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer, object):
"""Handle requests in a separate thread."""
+
class TLSThreadedHTTPServer(ThreadedHTTPServer):
"""Also use TLS to encrypt the connection"""
diff --git a/targetd/nfs.py b/targetd/nfs.py
new file mode 100644
index 0000000..25ee8f0
--- /dev/null
+++ b/targetd/nfs.py
@@ -0,0 +1,240 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from subprocess import Popen, PIPE
+import re
+
+
+def invoke(cmd, raise_exception=True):
+ """
+ Exec a command returning a tuple (exit code, stdout, stderr) and optionally
+ throwing an exception on non-zero exit code.
+ """
+ c = Popen(cmd, stdout=PIPE, stderr=PIPE)
+ out = c.communicate()
+
+ if raise_exception:
+ if c.returncode != 0:
+ cmd_str = str(cmd)
+ raise RuntimeError('Unexpected exit code "%s" %s, out= %s' %
+ (cmd_str, str(c.returncode),
+ str(out[0] + out[1])))
+
+ return c.returncode, out[0], out[1]
+
+
+def make_line_array(out):
+ """
+ Split the text out as an array of text strings
+ """
+ rc = []
+ for line in out.split('\n'):
+ if len(line) > 1:
+ rc.append(line)
+ return rc
+
+
+class Export(object):
+
+ SECURE = 0x00000001
+ RW = 0x00000002
+ RO = 0x00000004
+ SYNC = 0x00000008
+ ASYNC = 0x00000010
+ NO_WDELAY = 0x00000020
+ NOHIDE = 0x00000040
+ CROSS_MNT = 0x00000080
+ NO_SUBTREE_CHECK = 0x00000100
+ INSECURE_LOCKS = 0x00000200
+ ROOT_SQUASH = 0x00000400
+ NO_ROOT_SQUASH = 0x00000800
+ ALL_SQUASH = 0x00001000
+ WDELAY = 0x00002000
+ HIDE = 0x00004000
+ INSECURE = 0x00008000
+
+ bool_option = dict(secure=SECURE, rw=RW, ro=RO, sync=SYNC, async=ASYNC,
+ no_wdelay=NO_WDELAY, nohide=NOHIDE,
+ cross_mnt=CROSS_MNT, no_subtree_check=NO_SUBTREE_CHECK,
+ insecure_locks=INSECURE_LOCKS, root_squash=ROOT_SQUASH,
+ all_squash=ALL_SQUASH, wdelay=WDELAY, hide=HIDE,
+ insecure=INSECURE, no_root_squash=NO_ROOT_SQUASH)
+
+ key_pair = dict(mountpoint=str, mp=str, fsid=None, refer=str, replicas=str,
+ anonuid=int, anongid=int)
+
+ export_regex = '([\/a-zA-Z0-9\.-_]+)[\s]+(.+)\((.+)\)'
+
+ @staticmethod
+ def _bitCount(int_type):
+ count = 0
+ while int_type:
+ int_type &= int_type - 1
+ count += 1
+ return count
+
+ @staticmethod
+ def _validate_options(options):
+
+ if Export._bitCount(((Export.RW | Export.RO) & options)) == 2:
+ raise ValueError("Both RO & RW set")
+
+ if Export._bitCount(((Export.INSECURE | Export.SECURE) & options)) == 2:
+ raise ValueError("Both INSECURE & SECURE set")
+
+ if Export._bitCount(((Export.SYNC | Export.ASYNC) & options)) == 2:
+ raise ValueError("Both SYNC & ASYNC set")
+
+ if Export._bitCount(((Export.HIDE | Export.NOHIDE) & options)) == 2:
+ raise ValueError("Both HIDE & NOHIDE set")
+
+ if Export._bitCount(((Export.WDELAY | Export.NO_WDELAY) & options)) \
+ == 2:
+ raise ValueError("Both WDELAY & NO_WDELAY set")
+
+ if Export._bitCount(((Export.ROOT_SQUASH | Export.NO_ROOT_SQUASH)
+ & options)) > 1:
+ raise ValueError("Only one option of ROOT_SQUASH, NO_ROOT_SQUASH, "
+ "can be specified")
+
+ return options
+
+ @staticmethod
+ def _validate_key_pairs(kp):
+ if kp:
+ if isinstance(kp, dict):
+ for k, v in kp.items():
+ if k not in Export.key_pair:
+ raise ValueError('option %s not valid' % k)
+
+ return kp
+ else:
+ raise ValueError('key_value_options domain is None or dict')
+ else:
+ return {}
+
+ def __init__(self, host, path, bit_wise_options=0, key_value_options=None):
+
+ if host == '<world>':
+ self.host = '*'
+ else:
+ self.host = host
+ self.path = path
+ self.options = Export._validate_options(bit_wise_options)
+ self.key_value_options = Export._validate_key_pairs(key_value_options)
+
+ @staticmethod
+ def parse_opt(options_string):
+ bits = 0
+ pairs = {}
+
+ options = options_string.split(',')
+ for o in options:
+ if '=' in o:
+ #We have a key=value
+ key, value = o.split('=')
+ pairs[key] = value
+ else:
+ bits |= Export.bool_option[o]
+
+ return bits, pairs
+
+ @staticmethod
+ def parse(export_text):
+ rc = []
+ pattern = re.compile(Export.export_regex)
+
+ for m in re.finditer(pattern, export_text):
+ rc.append(Export(m.group(2), m.group(1),
+ *Export.parse_opt(m.group(3))))
+ return rc
+
+ @staticmethod
+ def _append(s, a):
+ if len(s):
+ s = s + "," + a
+ else:
+ s = a
+ return s
+
+ def options_list(self):
+ rc = []
+ for k, v in self.bool_option.items():
+ if self.options & v:
+ rc.append(k)
+
+ for k, v in self.key_value_options.items():
+ rc.append('%s=%s' % (k, v))
+
+ return rc
+
+ def options_string(self):
+ return ','.join(self.options_list())
+
+ def __repr__(self):
+ return "%s%s(%s)" % (self.path.ljust(50), self.host,
+ self.options_string())
+
+
+class Nfs(object):
+ """
+ Python module for configuring NFS exports
+ """
+ cmd = 'exportfs'
+
+ def __init__(self):
+ pass
+
+ @staticmethod
+ def security_options():
+ return "sys", "krb5", "krb5i", "krb5p"
+
+ @staticmethod
+ def exports():
+ """
+ Return list of exports
+ """
+ rc = []
+ ec, out, error = invoke([Nfs.cmd, '-v'])
+ rc = Export.parse(out)
+ return rc
+
+ @staticmethod
+ def export_add(host, path, bit_wise_options, key_value_options):
+ """
+ Adds a path as an NFS export
+ """
+ export = Export(host, path, bit_wise_options, key_value_options)
+ options = export.options_string()
+
+ cmd = [Nfs.cmd]
+
+ if len(options):
+ cmd.extend(['-o', options])
+
+ cmd.extend(['%s:%s' % (host, path)])
+
+ ec, out, err = invoke(cmd, False)
+ if ec == 0:
+ return None
+ elif ec == 22:
+ raise ValueError("Invalid option: %s" % err)
+ else:
+ raise RuntimeError('Unexpected exit code "%s" %s, out= %s' %
+ (str(cmd), str(ec),
+ str(out + ":" + err)))
+
+ @staticmethod
+ def export_remove(export):
+ ec, out, err = invoke([Nfs.cmd, '-u', '%s:%s' %
+ (export.host, export.path)])
\ No newline at end of file
--
1.8.2.1
10 years, 11 months
[PATCH 1/4] Experimental NFS export support V3
by Tony Asleson
V3 - Code review updates from agrover.
Signed-off-by: Tony Asleson <tasleson(a)redhat.com>
---
targetd/fs.py | 71 +++++++++++++++--
targetd/main.py | 2 +
targetd/nfs.py | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 307 insertions(+), 6 deletions(-)
create mode 100644 targetd/nfs.py
diff --git a/targetd/fs.py b/targetd/fs.py
index a6fd9b9..55ef38e 100644
--- a/targetd/fs.py
+++ b/targetd/fs.py
@@ -20,6 +20,7 @@ import os
import time
from subprocess import Popen, PIPE
from main import TargetdError
+from nfs import Nfs, Export
# Notes:
@@ -67,7 +68,11 @@ def initialize(config_dict):
fs_clone=fs_clone,
ss_list=ss,
fs_snapshot=fs_snapshot,
- fs_snapshot_delete=fs_snapshot_delete
+ fs_snapshot_delete=fs_snapshot_delete,
+ nfs_export_auth_list=nfs_export_auth_list,
+ nfs_export_list=nfs_export_list,
+ nfs_export_add=nfs_export_add,
+ nfs_export_remove=nfs_export_remove,
)
@@ -194,8 +199,8 @@ def fs_pools(req):
return results
-def fs(req):
- fs_list = []
+def _fs_hash():
+ fs_list = {}
for pool in pools:
full_path = os.path.join(pool, fs_path)
@@ -210,9 +215,11 @@ def fs(req):
if len(data):
(total, free) = fs_space_values(full_path)
for e in data:
- fs_list.append(dict(name=e[10], uuid=e[8],
- total_space=total, free_space=free,
- pool=pool))
+ sub_vol = e[10]
+ key = full_path + '/' + sub_vol
+ fs_list[key] = dict(name=sub_vol, uuid=e[8],
+ total_space=total, free_space=free,
+ pool=pool, full_path=key)
break
elif result == 19:
time.sleep(1)
@@ -223,6 +230,10 @@ def fs(req):
return fs_list
+def fs(req):
+ return _fs_hash().values()
+
+
def ss(req, fs_uuid, fs_cache=None):
snapshots = []
@@ -293,3 +304,51 @@ def fs_clone(req, fs_uuid, dest_fs_name, snapshot_id):
raise TargetdError(-51, "Filesystem with that name exists")
invoke([fs_cmd, 'subvolume', 'snapshot', source, dest])
+
+
+def nfs_export_auth_list(req):
+ return Nfs.security_options()
+
+
+def nfs_export_list(req):
+ rc = []
+ fs_hash = _fs_hash()
+
+ exports = Nfs.exports()
+ for e in exports:
+ #Only report those exports which match our filesystem layout
+ if e.path in fs_hash:
+ rc.append(dict(host=e.host, path=e.path, options=e.options_list(),
+ fs_uuid=fs_hash[e.path]['uuid']))
+ return rc
+
+
+def nfs_export_add(req, host, path, export_path, options):
+
+ if export_path is not None:
+ raise TargetdError(-401, "separate export path not supported at "
+ "this time")
+ bit_opt = 0
+ key_opt = {}
+
+ for o in options:
+ if '=' in o:
+ k, v = o.split('=')
+ key_opt[k] = v
+ else:
+ bit_opt |= Export.bool_option[o]
+
+ Nfs.export_add(host, path, bit_opt, key_opt)
+
+
+def nfs_export_remove(req, host, path):
+ found = False
+
+ for e in Nfs.exports():
+ if e.host == host and e.path == path:
+ Nfs.export_remove(e)
+ found = True
+
+ if not found:
+ raise TargetdError(-400, "NFS export to remove not found %s:%s",
+ (host, path))
\ No newline at end of file
diff --git a/targetd/main.py b/targetd/main.py
index 1082f5f..b108a00 100644
--- a/targetd/main.py
+++ b/targetd/main.py
@@ -27,6 +27,8 @@ import yaml
import itertools
import socket
import ssl
+import traceback
+import sys
default_config_path = "/etc/target/targetd.yaml"
diff --git a/targetd/nfs.py b/targetd/nfs.py
new file mode 100644
index 0000000..083760e
--- /dev/null
+++ b/targetd/nfs.py
@@ -0,0 +1,240 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from subprocess import Popen, PIPE
+import re
+
+
+def invoke(cmd, raise_exception=True):
+ """
+ Exec a command returning a tuple (exit code, stdout, stderr) and optionally
+ throwing an exception on non-zero exit code.
+ """
+ c = Popen(cmd, stdout=PIPE, stderr=PIPE)
+ out = c.communicate()
+
+ if raise_exception:
+ if c.returncode != 0:
+ cmd_str = str(cmd)
+ raise RuntimeError('Unexpected exit code "%s" %s, out= %s' %
+ (cmd_str, str(c.returncode),
+ str(out[0] + out[1])))
+
+ return c.returncode, out[0], out[1]
+
+
+def make_line_array(out):
+ """
+ Split the text out as an array of text strings
+ """
+ rc = []
+ for line in out.split('\n'):
+ if len(line) > 1:
+ rc.append(line)
+ return rc
+
+
+class Export(object):
+
+ SECURE = 0x00000001
+ RW = 0x00000002
+ RO = 0x00000004
+ SYNC = 0x00000008
+ ASYNC = 0x00000010
+ NO_WDELAY = 0x00000020
+ NOHIDE = 0x00000040
+ CROSS_MNT = 0x00000080
+ NO_SUBTREE_CHECK = 0x00000100
+ INSECURE_LOCKS = 0x00000200
+ ROOT_SQUASH = 0x00000400
+ NO_ROOT_SQUASH = 0x00000800
+ ALL_SQUASH = 0x00001000
+ WDELAY = 0x00002000
+ HIDE = 0x00004000
+ INSECURE = 0x00008000
+
+ bool_option = dict(secure=SECURE, rw=RW, ro=RO, sync=SYNC, async=ASYNC,
+ no_wdelay=NO_WDELAY, nohide=NOHIDE,
+ cross_mnt=CROSS_MNT, no_subtree_check=NO_SUBTREE_CHECK,
+ insecure_locks=INSECURE_LOCKS, root_squash=ROOT_SQUASH,
+ all_squash=ALL_SQUASH, wdelay=WDELAY, hide=HIDE,
+ insecure=INSECURE, no_root_squash=NO_ROOT_SQUASH)
+
+ key_pair = dict(mountpoint=str, mp=str, fsid=None, refer=str, replicas=str,
+ anonuid=int, anongid=int)
+
+ export_regex = '([\/a-zA-Z0-9\.-_]+)[\s]+(.+)\((.+)\)'
+
+ @staticmethod
+ def _bitCount(int_type):
+ count = 0
+ while int_type:
+ int_type &= int_type - 1
+ count += 1
+ return count
+
+ @staticmethod
+ def _validate_options(options):
+
+ if Export._bitCount(((Export.RW | Export.RO) & options)) == 2:
+ raise ValueError("Both RO & RW set")
+
+ if Export._bitCount(((Export.INSECURE | Export.SECURE) & options)) == 2:
+ raise ValueError("Both INSECURE & SECURE set")
+
+ if Export._bitCount(((Export.SYNC | Export.ASYNC) & options)) == 2:
+ raise ValueError("Both SYNC & ASYNC set")
+
+ if Export._bitCount(((Export.HIDE | Export.NOHIDE) & options)) == 2:
+ raise ValueError("Both HIDE & NOHIDE set")
+
+ if Export._bitCount(((Export.WDELAY | Export.NO_WDELAY) & options)) \
+ == 2:
+ raise ValueError("Both WDELAY & NO_WDELAY set")
+
+ if Export._bitCount(((Export.ROOT_SQUASH | Export.NO_ROOT_SQUASH)
+ & options)) > 1:
+ raise ValueError("Only one option of ROOT_SQUASH, NO_ROOT_SQUASH, "
+ "can be specified")
+
+ return options
+
+ @staticmethod
+ def _validate_key_pairs(kp):
+ if kp:
+ if isinstance(kp, dict):
+ for k, v in kp.items():
+ if k not in Export.key_pair:
+ raise ValueError('option %s not valid' % k)
+
+ return kp
+ else:
+ raise ValueError('key_value_options domain is None or dict')
+ else:
+ return {}
+
+ def __init__(self, host, path, bit_wise_options=0, key_value_options=None):
+
+ if host == '<world>':
+ self.host = '*'
+ else:
+ self.host = host
+ self.path = path
+ self.options = Export._validate_options(bit_wise_options)
+ self.key_value_options = Export._validate_key_pairs(key_value_options)
+
+ @staticmethod
+ def parse_opt(options_string):
+ bits = 0
+ pairs = {}
+
+ options = options_string.split(',')
+ for o in options:
+ if '=' in o:
+ #We have a key=value
+ key, value = o.split('=')
+ pairs[key] = value
+ else:
+ bits |= Export.bool_option[o]
+
+ return bits, pairs
+
+ @staticmethod
+ def parse(export_text):
+ rc = []
+ pattern = re.compile(Export.export_regex)
+
+ for m in re.finditer(pattern, export_text):
+ rc.append(Export(m.group(2), m.group(1),
+ *Export.parse_opt(m.group(3))))
+ return rc
+
+ @staticmethod
+ def _append(s, a):
+ if len(s):
+ s = s + "," + a
+ else:
+ s = a
+ return s
+
+ def options_list(self):
+ rc = []
+ for k, v in self.bool_option.items():
+ if self.options & v:
+ rc.append(k)
+
+ for k, v in self.key_value_options.items():
+ rc.append('%s=%s' % (k, v))
+
+ return rc
+
+ def options_string(self):
+ return ','.join(self.options_list())
+
+ def __repr__(self):
+ return "%s%s(%s)" % (self.path.ljust(50), self.host,
+ self.options_string())
+
+
+class Nfs(object):
+ """
+ Python module for configuring NFS exports
+ """
+ cmd = 'exportfs'
+
+ def __init__(self):
+ pass
+
+ @staticmethod
+ def security_options():
+ return "sys", "krb5", "krb5i", "krb5p"
+
+ @staticmethod
+ def exports():
+ """
+ Return list of exports
+ """
+ rc = []
+ ec, out, error = invoke([Nfs.cmd, '-v'])
+ rc = Export.parse(out)
+ return rc
+
+ @staticmethod
+ def export_add(host, path, bit_wise_options, key_value_options):
+ """
+ Adds a path as an NFS export
+ """
+ export = Export(host, path, bit_wise_options, key_value_options)
+ options = export.options_string()
+
+ cmd = [Nfs.cmd]
+
+ if len(options):
+ cmd.extend(['-o', options])
+
+ cmd.extend(['%s:%s' % (host, path)])
+
+ ec, out, err = invoke(cmd, False)
+ if ec == 0:
+ return None
+ elif ec == 22:
+ raise ValueError("Invalid option: %s" % err)
+ else:
+ raise RuntimeError('Unexpected exit code "%s" %s, out= %s' %
+ (str(cmd), str(ec),
+ str(out + ":" + err)))
+
+ @staticmethod
+ def export_remove(export):
+ ec, out, err = invoke([Nfs.cmd, '-u', '%s:%s' %
+ (export.host, export.path)])
--
1.8.2.1
10 years, 11 months
[PATCH] Experimental NFS export support
by Tony Asleson
Signed-off-by: Tony Asleson <tasleson(a)redhat.com>
---
targetd/fs.py | 75 ++++++++++++++++--
targetd/main.py | 18 +++--
targetd/nfs.py | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 320 insertions(+), 13 deletions(-)
create mode 100644 targetd/nfs.py
diff --git a/targetd/fs.py b/targetd/fs.py
index a6fd9b9..32c1e06 100644
--- a/targetd/fs.py
+++ b/targetd/fs.py
@@ -20,6 +20,7 @@ import os
import time
from subprocess import Popen, PIPE
from main import TargetdError
+from nfs import Nfs, Export
# Notes:
@@ -46,6 +47,7 @@ fs_cmd = 'btrfs'
pools = []
+
def initialize(config_dict):
global pools
@@ -67,8 +69,11 @@ def initialize(config_dict):
fs_clone=fs_clone,
ss_list=ss,
fs_snapshot=fs_snapshot,
- fs_snapshot_delete=fs_snapshot_delete
- )
+ fs_snapshot_delete=fs_snapshot_delete,
+ nfs_export_auth_list=nfs_export_auth_list,
+ nfs_export_list=nfs_export_list,
+ nfs_export_add=nfs_export_add,
+ nfs_export_remove=nfs_export_remove)
def invoke(cmd, raise_exception=True):
@@ -194,8 +199,8 @@ def fs_pools(req):
return results
-def fs(req):
- fs_list = []
+def _fs_hash():
+ fs_list = {}
for pool in pools:
full_path = os.path.join(pool, fs_path)
@@ -210,9 +215,11 @@ def fs(req):
if len(data):
(total, free) = fs_space_values(full_path)
for e in data:
- fs_list.append(dict(name=e[10], uuid=e[8],
- total_space=total, free_space=free,
- pool=pool))
+ sub_vol = e[10]
+ key = full_path + '/' + sub_vol
+ fs_list[key] = dict(name=e[10], uuid=e[8],
+ total_space=total, free_space=free,
+ pool=pool, full_path=key)
break
elif result == 19:
time.sleep(1)
@@ -223,6 +230,11 @@ def fs(req):
return fs_list
+def fs(req):
+ fs_hash = _fs_hash()
+ return fs_hash.values()
+
+
def ss(req, fs_uuid, fs_cache=None):
snapshots = []
@@ -293,3 +305,52 @@ def fs_clone(req, fs_uuid, dest_fs_name, snapshot_id):
raise TargetdError(-51, "Filesystem with that name exists")
invoke([fs_cmd, 'subvolume', 'snapshot', source, dest])
+
+
+def nfs_export_auth_list(req):
+ return Nfs.security_options()
+
+
+def nfs_export_list(req):
+ rc = []
+ fs_hash = _fs_hash()
+
+ exports = Nfs.exports()
+ for e in exports:
+ #Only report those exports which match our filesystem layout
+ if e.path in fs_hash:
+ rc.append(dict(host=e.host, path=e.path, options=e.options_list(),
+ fs_uuid=fs_hash[e.path]['uuid']))
+ return rc
+
+
+def nfs_export_add(req, host, path, export_path, options):
+
+ if export_path is not None:
+ raise TargetdError(-401, "separate export path not supported at "
+ "this time")
+ bit_opt = 0
+ key_opt = {}
+
+ for o in options:
+ if '=' in o:
+ k, v = o.split('=')
+ key_opt[k] = v
+ else:
+ bit_opt |= Export.bool_option[o]
+
+ print 'Calling: %s - %s - %x %s' % (host, path, bit_opt, str(key_opt))
+
+ Nfs.export_add(host, path, bit_opt, key_opt)
+
+
+def nfs_export_remove(req, host, path):
+ exports = Nfs.exports()
+
+ for e in exports:
+ if e.host == host and e.path == path:
+ Nfs.export_remove(e)
+ return None
+
+ raise TargetdError(-400, "NFS export to remove not found %s:%s",
+ (host, path))
\ No newline at end of file
diff --git a/targetd/main.py b/targetd/main.py
index 1082f5f..16173df 100644
--- a/targetd/main.py
+++ b/targetd/main.py
@@ -27,12 +27,14 @@ import yaml
import itertools
import socket
import ssl
+import traceback
+import sys
default_config_path = "/etc/target/targetd.yaml"
default_config = dict(
- block_pools = ['vg-targetd'],
- fs_pools = [],
+ block_pools=['vg-targetd'],
+ fs_pools=[],
user="admin",
# security: no default password
target_name="iqn.2003-01.org.linux-iscsi.%s:targetd" % socket.gethostname(),
@@ -43,6 +45,7 @@ default_config = dict(
config = {}
+
class TargetdError(Exception):
def __init__(self, error_code, message, *args, **kwargs):
Exception.__init__(self, *args, **kwargs)
@@ -80,17 +83,18 @@ async_id = 100
class TargetHandler(BaseHTTPRequestHandler):
def _new_async_id(self):
+ global async_id
with async_id_lock:
new_id = async_id
async_id += 1
return new_id
def mark_async(self):
- '''
+ """
Mark a request as finishing after the given HTTP request returns.
Handlers calling this must
- '''
+ """
if not self.async_id:
self.async_id = self._new_async_id()
rpcdata = json.dumps(
@@ -122,7 +126,6 @@ class TargetHandler(BaseHTTPRequestHandler):
if not code:
del long_op_status[self.async_id]
-
def log_request(self, code='-', size='-'):
# override base class - don't log good requests
pass
@@ -182,9 +185,11 @@ class TargetHandler(BaseHTTPRequestHandler):
result = mapping[method](self)
except KeyError:
error = (-32601, "method %s not found" % method)
+ traceback.print_exc(file=sys.stdout)
raise
except TypeError:
- error = (-32602, "invalid method parameter(s)")
+ error = (-32602, "invalid method parameter(s) %s" % str(params))
+ traceback.print_exc(file=sys.stdout)
raise
except TargetdError, td:
error = (td.error, td.msg)
@@ -207,6 +212,7 @@ class TargetHandler(BaseHTTPRequestHandler):
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer, object):
"""Handle requests in a separate thread."""
+
class TLSThreadedHTTPServer(ThreadedHTTPServer):
"""Also use TLS to encrypt the connection"""
diff --git a/targetd/nfs.py b/targetd/nfs.py
new file mode 100644
index 0000000..25ee8f0
--- /dev/null
+++ b/targetd/nfs.py
@@ -0,0 +1,240 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from subprocess import Popen, PIPE
+import re
+
+
+def invoke(cmd, raise_exception=True):
+ """
+ Exec a command returning a tuple (exit code, stdout, stderr) and optionally
+ throwing an exception on non-zero exit code.
+ """
+ c = Popen(cmd, stdout=PIPE, stderr=PIPE)
+ out = c.communicate()
+
+ if raise_exception:
+ if c.returncode != 0:
+ cmd_str = str(cmd)
+ raise RuntimeError('Unexpected exit code "%s" %s, out= %s' %
+ (cmd_str, str(c.returncode),
+ str(out[0] + out[1])))
+
+ return c.returncode, out[0], out[1]
+
+
+def make_line_array(out):
+ """
+ Split the text out as an array of text strings
+ """
+ rc = []
+ for line in out.split('\n'):
+ if len(line) > 1:
+ rc.append(line)
+ return rc
+
+
+class Export(object):
+
+ SECURE = 0x00000001
+ RW = 0x00000002
+ RO = 0x00000004
+ SYNC = 0x00000008
+ ASYNC = 0x00000010
+ NO_WDELAY = 0x00000020
+ NOHIDE = 0x00000040
+ CROSS_MNT = 0x00000080
+ NO_SUBTREE_CHECK = 0x00000100
+ INSECURE_LOCKS = 0x00000200
+ ROOT_SQUASH = 0x00000400
+ NO_ROOT_SQUASH = 0x00000800
+ ALL_SQUASH = 0x00001000
+ WDELAY = 0x00002000
+ HIDE = 0x00004000
+ INSECURE = 0x00008000
+
+ bool_option = dict(secure=SECURE, rw=RW, ro=RO, sync=SYNC, async=ASYNC,
+ no_wdelay=NO_WDELAY, nohide=NOHIDE,
+ cross_mnt=CROSS_MNT, no_subtree_check=NO_SUBTREE_CHECK,
+ insecure_locks=INSECURE_LOCKS, root_squash=ROOT_SQUASH,
+ all_squash=ALL_SQUASH, wdelay=WDELAY, hide=HIDE,
+ insecure=INSECURE, no_root_squash=NO_ROOT_SQUASH)
+
+ key_pair = dict(mountpoint=str, mp=str, fsid=None, refer=str, replicas=str,
+ anonuid=int, anongid=int)
+
+ export_regex = '([\/a-zA-Z0-9\.-_]+)[\s]+(.+)\((.+)\)'
+
+ @staticmethod
+ def _bitCount(int_type):
+ count = 0
+ while int_type:
+ int_type &= int_type - 1
+ count += 1
+ return count
+
+ @staticmethod
+ def _validate_options(options):
+
+ if Export._bitCount(((Export.RW | Export.RO) & options)) == 2:
+ raise ValueError("Both RO & RW set")
+
+ if Export._bitCount(((Export.INSECURE | Export.SECURE) & options)) == 2:
+ raise ValueError("Both INSECURE & SECURE set")
+
+ if Export._bitCount(((Export.SYNC | Export.ASYNC) & options)) == 2:
+ raise ValueError("Both SYNC & ASYNC set")
+
+ if Export._bitCount(((Export.HIDE | Export.NOHIDE) & options)) == 2:
+ raise ValueError("Both HIDE & NOHIDE set")
+
+ if Export._bitCount(((Export.WDELAY | Export.NO_WDELAY) & options)) \
+ == 2:
+ raise ValueError("Both WDELAY & NO_WDELAY set")
+
+ if Export._bitCount(((Export.ROOT_SQUASH | Export.NO_ROOT_SQUASH)
+ & options)) > 1:
+ raise ValueError("Only one option of ROOT_SQUASH, NO_ROOT_SQUASH, "
+ "can be specified")
+
+ return options
+
+ @staticmethod
+ def _validate_key_pairs(kp):
+ if kp:
+ if isinstance(kp, dict):
+ for k, v in kp.items():
+ if k not in Export.key_pair:
+ raise ValueError('option %s not valid' % k)
+
+ return kp
+ else:
+ raise ValueError('key_value_options domain is None or dict')
+ else:
+ return {}
+
+ def __init__(self, host, path, bit_wise_options=0, key_value_options=None):
+
+ if host == '<world>':
+ self.host = '*'
+ else:
+ self.host = host
+ self.path = path
+ self.options = Export._validate_options(bit_wise_options)
+ self.key_value_options = Export._validate_key_pairs(key_value_options)
+
+ @staticmethod
+ def parse_opt(options_string):
+ bits = 0
+ pairs = {}
+
+ options = options_string.split(',')
+ for o in options:
+ if '=' in o:
+ #We have a key=value
+ key, value = o.split('=')
+ pairs[key] = value
+ else:
+ bits |= Export.bool_option[o]
+
+ return bits, pairs
+
+ @staticmethod
+ def parse(export_text):
+ rc = []
+ pattern = re.compile(Export.export_regex)
+
+ for m in re.finditer(pattern, export_text):
+ rc.append(Export(m.group(2), m.group(1),
+ *Export.parse_opt(m.group(3))))
+ return rc
+
+ @staticmethod
+ def _append(s, a):
+ if len(s):
+ s = s + "," + a
+ else:
+ s = a
+ return s
+
+ def options_list(self):
+ rc = []
+ for k, v in self.bool_option.items():
+ if self.options & v:
+ rc.append(k)
+
+ for k, v in self.key_value_options.items():
+ rc.append('%s=%s' % (k, v))
+
+ return rc
+
+ def options_string(self):
+ return ','.join(self.options_list())
+
+ def __repr__(self):
+ return "%s%s(%s)" % (self.path.ljust(50), self.host,
+ self.options_string())
+
+
+class Nfs(object):
+ """
+ Python module for configuring NFS exports
+ """
+ cmd = 'exportfs'
+
+ def __init__(self):
+ pass
+
+ @staticmethod
+ def security_options():
+ return "sys", "krb5", "krb5i", "krb5p"
+
+ @staticmethod
+ def exports():
+ """
+ Return list of exports
+ """
+ rc = []
+ ec, out, error = invoke([Nfs.cmd, '-v'])
+ rc = Export.parse(out)
+ return rc
+
+ @staticmethod
+ def export_add(host, path, bit_wise_options, key_value_options):
+ """
+ Adds a path as an NFS export
+ """
+ export = Export(host, path, bit_wise_options, key_value_options)
+ options = export.options_string()
+
+ cmd = [Nfs.cmd]
+
+ if len(options):
+ cmd.extend(['-o', options])
+
+ cmd.extend(['%s:%s' % (host, path)])
+
+ ec, out, err = invoke(cmd, False)
+ if ec == 0:
+ return None
+ elif ec == 22:
+ raise ValueError("Invalid option: %s" % err)
+ else:
+ raise RuntimeError('Unexpected exit code "%s" %s, out= %s' %
+ (str(cmd), str(ec),
+ str(out + ":" + err)))
+
+ @staticmethod
+ def export_remove(export):
+ ec, out, err = invoke([Nfs.cmd, '-u', '%s:%s' %
+ (export.host, export.path)])
\ No newline at end of file
--
1.8.2.1
10 years, 11 months
revised API for initiator_set_auth
by Andy Grover
It sounds like we want to have a little more configurability w.r.t.
mutual auth parameters. Comments?
Initiator operations
--------------------
### initiator_set_auth(initiator_wwn, in_user, in_pass, out_user, out_pass)
Sets the inbound and outbound login credentials for the given
initiator. 'in_user' and 'in_pass' are credentials that the
initiator will use to login to the target and access luns exported by
'export_create'. 'out_user' and 'out_pass' are the credentials the
target will use to authenticate itself back to the initiator.
'initiator_wwn' must be set, but 'in_user', 'in_pass', 'out_user' and
'out_pass' may be 'null'. If either or both of each directions'
parameters are 'null', then authentication is disabled for that
direction.
Calling this method is optional. If it is not called, exports
configured via 'export_create' require no authentication.
Regards -- Andy
10 years, 11 months
[PATCH] initiator_set_auth: Fix a couple of issues.
by Tony Asleson
Signed-off-by: Tony Asleson <tasleson(a)redhat.com>
---
targetd/block.py | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/targetd/block.py b/targetd/block.py
index 4138f57..260d847 100644
--- a/targetd/block.py
+++ b/targetd/block.py
@@ -266,6 +266,9 @@ def export_destroy(req, pool, vol, initiator_wwn):
def initiator_set_auth(req, initiator_wwn, username, password, mutual):
+ global mutual_auth_user
+ global mutual_auth_password
+
fm = FabricModule('iscsi')
t = Target(fm, target_name)
tpg = TPG(t, 1)
@@ -275,12 +278,12 @@ def initiator_set_auth(req, initiator_wwn, username, password, mutual):
# rtslib treats '' as its NULL value for these
username = password = ''
- na.chap_userid = userid
+ na.chap_userid = username
na.chap_password = password
if mutual:
- na.chap_mutual_userid = target_auth_userid
- na.chap_mutual_password = target_auth_password
+ na.chap_mutual_userid = mutual_auth_user
+ na.chap_mutual_password = mutual_auth_password
else:
na.chap_mutual_userid = ''
na.chap_mutual_password = ''
--
1.8.2.1
10 years, 11 months
revised API text
by Andy Grover
Here's the revised addition to API.md I'll be checking in as 0.5. It
clarifies the function is optional, and the behavior if it is not
called. -- Andy
Initiator operations
--------------------
### initiator_set_auth(initiator_wwn, username, password, mutual)
Sets the login credentials that the initiator will use to login and
access luns exported by 'export_create'. 'initiator_wwn', 'username',
and 'password' are all strings. 'username' and 'password' may be
'null'. If either (or both) are 'null', initiator-to-target
authentication is disabled.
The 'mutual' parameter is a boolean indicating if the target should
attempt to authenticate to the initiator, a.k.a. "mutual
authentication". This may be set even if initiator-to-target
authentication is not enabled. The target's authentication credentials
are not configured via this API.
Calling this method is optional. If it is not called, exports configured
via 'export_create' will require no authentication.
10 years, 12 months