Nir Soffer has uploaded a new change for review. Change subject: vdsm-imaged: Support random io to oVirt disks ......................................................................
vdsm-imaged: Support random io to oVirt disks vdsm-imaged provides direct access to oVirt disks using HTTPS protocol. Together with oVirt transfer proxy, it allows uploading a disk image directly into an oVirt disk, or downloading an oVirt disk. Dependencies - python-webob - small library simplifying writing WSGI HTTP servers - py.test - more convenient unit testing framework This is work in progress; still missing: - Accessing storage using dd - Handling of partial content (Content-Range, Rage) - Python 3 support (some modules were renamed in Python 3) Change-Id: If3339fa94ef8464228cd036f4fe8eea61887e337 Signed-off-by: Nir Soffer <[email protected]> --- A vdsm-imaged/README A vdsm-imaged/imaged.py A vdsm-imaged/imaged_test.py A vdsm-imaged/pki/certs/vdsmcert.pem A vdsm-imaged/pki/keys/vdsmkey.pem 5 files changed, 671 insertions(+), 0 deletions(-) git pull ssh://gerrit.ovirt.org:29418/vdsm refs/changes/24/41824/4 diff --git a/vdsm-imaged/README b/vdsm-imaged/README new file mode 100644 index 0000000..7206245 --- /dev/null +++ b/vdsm-imaged/README @@ -0,0 +1,41 @@ +vdsm-imaged daemon +------------------ + +vdsm-imaged provides direct access to oVirt disks using HTTPS protocol. +Together with oVirt transfer proxy, it allows uploading a disk image +directly into an oVirt disk, or downloading an oVirt disk. + +The goal is to keep vdsm-imaged simple as possible. We use a single +protocol (HTTPS) for everything, and avoid dependencies on Vdsm. + +This daemon provides two services: + +- image service read and write data to/from images. This service is + available via HTTP on port 54322. + +- ticket service manage session tickets authorizing image service + operations. This service is available via HTTP over + unix domain socket. + +Transfer session flow + +- Client starts an engine transfer session using oVirt REST API. +- Engine creates session tickets +- Engine ask Vdsm to start a transfer session with a ticket. +- Vdsm prepares an image, and add the session ticket to vdsm-imaged. +- Engine returns signed session tickets to client. +- Client perform io operations with oVirt transfer proxy using the + signed session ticket. +- oVirt transfer proxy performs random io operations with vdsm-imaged + using the session ticket uuid. +- When client is done, it ends the engine transfer session. +- Engine ends the vdsm transfer session. +- Vdsm deletes session ticket from vdsm-imaged. + +Session tickets are ephemeral; A client needs to request Engine to renew +the ticket from time to time, otherwise a ticket will expire and the +transfer session will be aborted. + +Session tickets are not persisted. In case of vdsm-imaged crash or +reboot, Engine will provide a new session ticket and possibly point +client to another host to continue the transfer session. diff --git a/vdsm-imaged/imaged.py b/vdsm-imaged/imaged.py new file mode 100644 index 0000000..ba96fb6 --- /dev/null +++ b/vdsm-imaged/imaged.py @@ -0,0 +1,344 @@ +# vdsm-imaged - vdsm image daemon +# Copyright (C) 2015 Red Hat, Inc. +# +# 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 2 of the License, or +# (at your option) any later version. + +from contextlib import contextmanager +from wsgiref import simple_server +import SocketServer +import json +import os +import re +import signal +import ssl +import sys +import threading +import time + +import webob + +from webob.exc import ( + HTTPException, + HTTPBadRequest, + HTTPMethodNotAllowed, + HTTPNotFound, + HTTPForbidden +) + +from vdsm import uhttp + +image_server = None +ticket_server = None +tickets = {} +running = True + + +def main(args): + config = Config() + signal.signal(signal.SIGINT, terminate) + signal.signal(signal.SIGTERM, terminate) + start(config) + try: + while running: + time.sleep(30) + finally: + stop() + + +def terminate(signo, frame): + global running + running = False + + +def start(config): + global image_server, ticket_server + assert not (image_server or ticket_server) + + image_server = ThreadedWSGIServer((config.host, config.port), + WSGIRequestHandler) + secure_server(config, image_server) + image_app = Application(config, [(r"/images/(.*)", Images)]) + image_server.set_app(image_app) + + ticket_server = uhttp.UnixWSGIServer(config.socket, UnixWSGIRequestHandler) + secure_server(config, ticket_server) + ticket_app = Application(config, [(r"/tickets/(.*)", Tickets)]) + ticket_server.set_app(ticket_app) + + start_server(config, image_server, "image.server") + start_server(config, ticket_server, "ticket.server") + + +def stop(): + global image_server, ticket_server + image_server.shutdown() + ticket_server.shutdown() + image_server = None + ticket_server = None + + +def secure_server(config, server): + server.socket = ssl.wrap_socket(server.socket, certfile=config.cert_file, + keyfile=config.key_file, server_side=True) + + +def start_server(config, server, name): + def run(): + server.serve_forever(poll_interval=config.poll_interval) + + t = threading.Thread(target=run, name=name) + t.daemon = True + t.start() + + +class Config(object): + + pki_dir = "/etc/pki/vdsm" + host = "" + port = 54322 + repo_dir = "/var/run/vdsm/storage" + poll_interval = 1.0 + buffer_size = 64 * 1024 + socket = "/var/run/vdsm/imaged.socket" + + @property + def key_file(self): + return os.path.join(self.pki_dir, "keys", "vdsmkey.pem") + + @property + def cert_file(self): + return os.path.join(self.pki_dir, "certs", "vdsmcert.pem") + + +def error_response(e): + """ + Return WSGI application for sending error response using JSON format. + """ + payload = { + "code": e.code, + "title": e.title, + "explanation": e.explanation + } + if e.detail: + payload["detail"] = e.detail + return response(status=e.code, payload=payload) + + +def response(status=200, payload=None): + """ + Return WSGI application for sending response in JSON format. + """ + body = json.dumps(payload) if payload else "" + return webob.Response(status=status, body=body, + content_type="application/json") + + +class Application(object): + """ + WSGI application dispatching requests based on path and method to request + handlers. + """ + + def __init__(self, config, routes): + self.config = config + self.routes = [(re.compile(pattern), cls) for pattern, cls in routes] + + def __call__(self, env, start_response): + request = webob.Request(env) + try: + resp = self.dispatch(request) + except HTTPException as e: + resp = error_response(e) + return resp(env, start_response) + + def dispatch(self, request): + method_name = request.method.lower() + if method_name.startswith("_"): + raise HTTPMethodNotAllowed("Invalid method %r" % + request.method) + path = request.path_info + for route, handler_class in self.routes: + match = route.match(path) + if match: + handler = handler_class(self.config, request) + try: + method = getattr(handler, method_name) + except AttributeError: + raise HTTPMethodNotAllowed( + "Method %r not defined for %r" % + (request.method, path)) + else: + request.path_info_pop() + return method(*match.groups()) + raise HTTPNotFound("No handler for %r" % path) + + +def get_ticket(ticket_id, op, size): + """ + Return a ticket for the requested operation, authorizing the operation. + """ + try: + ticket = tickets[ticket_id] + except KeyError: + raise HTTPForbidden("No such ticket %r" % ticket_id) + if ticket["expires"] >= monotonic_time(): + raise HTTPForbidden("Ticket %r expired" % ticket_id) + if op not in ticket["ops"]: + raise HTTPForbidden("Ticket %r forbids %r" % (ticket_id, op)) + if size > ticket["size"]: + raise HTTPForbidden("Content-Length out of allowed range") + return ticket + + +@contextmanager +def register_request(ticket, request_id, request): + """ + Context manager registring a request with a ticket, so requests can be + canceled when a ticket is revoked or expired. + """ + requests = ticket.setdefault("requests", {}) + if request_id in requests: + raise HTTPForbidden("Request id %r exists" % request_id) + requests[request_id] = request + try: + yield + finally: + del requests[request_id] + + +class Images(object): + """ + Request handler for the /images/ resource. + """ + + def __init__(self, config, request): + self.config = config + self.request = request + self.canceled = False + + def put(self, ticket_id): + if not ticket_id: + raise HTTPBadRequest("Ticket id is required") + request_id = self.request.params.get("id") + if not request_id: + raise HTTPBadRequest("Request id is required") + size = self.request.content_length + ticket = get_ticket(ticket_id, "put", size) + with register_request(ticket, request_id, self): + self._copy_to_image(ticket["path"], size) + return response() + + def _copy_to_image(self, path, size): + # TODO: Use dd for writing + body_file = self.request.body_file + with open(path, "r+b") as out: + bufsize = self.config.buffer_size + bytes_written = 0 + while bytes_written < size: + bytes_to_read = min(size - bytes_written, bufsize) + block = body_file.read(bytes_to_read) + if self.canceled: + raise HTTPForbidden("Ticket was revoked during copy") + out.write(block) + if self.canceled: + raise HTTPForbidden("Ticket was revoked during copy") + bytes_written += len(block) + + +class Tickets(object): + """ + Request handler for the /tickets/ resource. + """ + + def __init__(self, config, request): + self.config = config + self.request = request + + def get(self, ticket_id): + if not ticket_id: + raise HTTPBadRequest("Ticket id is required") + try: + ticket = tickets[ticket_id] + except KeyError: + raise HTTPNotFound("No such ticket %r" % ticket_id) + return response(payload=ticket) + + def put(self, ticket_id): + # TODO + # - reject invalid or expired ticket + # - start expire timer + if not ticket_id: + raise HTTPBadRequest("Ticket id is required") + try: + ticket = self.request.json + except ValueError as e: + raise HTTPBadRequest("Invalid ticket: %s" % e) + tickets[ticket_id] = ticket + return response() + + def patch(self, ticket_id): + # TODO: restart expire timer + if not ticket_id: + raise HTTPBadRequest("Ticket id is required") + try: + patch = self.request.json + except ValueError as e: + raise HTTPBadRequest("Invalid patch: %s" % e) + try: + new_expires = patch["expires"] + except KeyError: + raise HTTPBadRequest("Missing expires key") + tickets[ticket_id]["expires"] = new_expires + return response() + + def delete(self, ticket_id): + # TODO: cancel requests using deleted tickets + ticket_id = self.request.path_info_peek() + if ticket_id: + try: + del tickets[ticket_id] + except KeyError: + raise HTTPNotFound("No such ticket %r" % ticket_id) + else: + tickets.clear() + return response(status=204) + + +class ThreadedWSGIServer(SocketServer.ThreadingMixIn, + simple_server.WSGIServer): + """ + Threaded WSGI HTTP server. + """ + daemon_threads = True + + +class WSGIRequestHandler(simple_server.WSGIRequestHandler): + """ + WSGI request handler using HTTP/1.1. + """ + + protocol_version = "HTTP/1.1" + + def address_string(self): + """ + Override to avoid slow and unneeded name lookup. + """ + return self.client_address[0] + + +class UnixWSGIRequestHandler(uhttp.UnixWSGIRequestHandler): + """ + WSGI over unix domain socket request handler using HTTP/1.1. + """ + protocol_version = "HTTP/1.1" + + +def monotonic_time(): + return os.times()[4] + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/vdsm-imaged/imaged_test.py b/vdsm-imaged/imaged_test.py new file mode 100644 index 0000000..afa5e5e --- /dev/null +++ b/vdsm-imaged/imaged_test.py @@ -0,0 +1,240 @@ +# vdsm-imaged - vdsm image daemon +# Copyright (C) 2015 Red Hat, Inc. +# +# 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 2 of the License, or +# (at your option) any later version. + +from __future__ import print_function + +import os +import sys +if os.path.isdir("../lib/vdsm"): + sys.path.insert(0, "../lib") + +from contextlib import contextmanager, closing +from pprint import pprint +import httplib +import json +import uuid + +from vdsm import uhttp + +import imaged + + +def setup_function(f): + imaged.tickets.clear() + + +def test_ticket_no_resource(): + config = Config() + with server(config): + res = unix_request(config, "GET", "/no/such/resource") + assert res.status == 404 + + +def test_ticket_no_method(): + config = Config() + with server(config): + res = unix_request(config, "POST", "/tickets/") + assert res.status == 405 + + +def test_ticket_get(): + config = Config() + ticket = create_ticket() + imaged.tickets[ticket["uuid"]] = ticket + with server(config): + res = unix_request(config, "GET", "/tickets/%(uuid)s" % ticket) + assert res.status == 200 + assert json.loads(res.read()) == ticket + + +def test_ticket_get_not_found(): + config = Config() + with server(config): + res = unix_request(config, "GET", "/tickets/%s" % uuid.uuid4()) + assert res.status == 404 + + +def test_ticket_put(): + config = Config() + ticket = create_ticket() + body = json.dumps(ticket) + with server(config): + res = unix_request(config, "PUT", "/tickets/%(uuid)s" % ticket, body) + assert res.status == 200 + assert imaged.tickets[ticket["uuid"]] == ticket + + +def test_ticket_put_invalid_json(): + config = Config() + with server(config): + res = unix_request(config, "PUT", "/tickets/", "invalid json") + assert res.status == 400 + + +def test_ticket_extend(): + config = Config() + ticket = create_ticket() + imaged.tickets[ticket["uuid"]] = ticket + patch = {"expires": ticket["expires"] + 300} + body = json.dumps(patch) + with server(config): + res = unix_request(config, "PATCH", "/tickets/%(uuid)s" % ticket, body) + assert res.status == 200 + assert ticket["expires"] == patch["expires"] + + +def test_ticket_delete_one(): + config = Config() + ticket = create_ticket() + imaged.tickets[ticket["uuid"]] = ticket + with server(config): + res = unix_request(config, "DELETE", "/tickets/%(uuid)s" % ticket) + assert res.status == 204 + assert ticket["uuid"] not in imaged.tickets + + +def test_ticket_delete_one_not_found(): + config = Config() + with server(config): + res = unix_request(config, "DELETE", "/tickets/%s" % uuid.uuid4()) + assert res.status == 404 + + +def test_ticket_delete_all(): + # Example usage: move host to maintenance + config = Config() + for i in range(5): + ticket = create_ticket(path="/var/run/vdsm/storage/foo%s" % i) + imaged.tickets[ticket["uuid"]] = ticket + with server(config): + res = unix_request(config, "DELETE", "/tickets/") + assert res.status == 204 + assert imaged.tickets == {} + + +def test_images_no_resource(): + config = Config() + with server(config): + res = http_request(config, "PUT", "/no/such/resource") + assert res.status == 404 + + +def test_images_no_method(): + config = Config() + with server(config): + res = http_request(config, "POST", "/images/") + assert res.status == 405 + + +def test_images_upload_no_request_id(tmpdir): + payload = create_tempfile(tmpdir, "payload", "content") + repo = create_repo(tmpdir) + config = Config(repo) + ticket = create_ticket() + imaged.tickets[ticket["uuid"]] = ticket + with server(config): + res = upload(config, ticket["uuid"], "", str(payload)) + assert res.status == 400 + + +def test_images_upload_no_ticket_id(tmpdir): + payload = create_tempfile(tmpdir, "payload", "content") + repo = create_repo(tmpdir) + config = Config(repo) + request_id = str(uuid.uuid4()) + with server(config): + res = upload(config, "", request_id, str(payload)) + assert res.status == 400 + + +def test_images_upload_no_ticket(tmpdir): + payload = create_tempfile(tmpdir, "payload", "content") + repo = create_repo(tmpdir) + config = Config(repo) + ticket_id = str(uuid.uuid4()) + request_id = str(uuid.uuid4()) + with server(config): + res = upload(config, ticket_id, request_id, str(payload)) + assert res.status == 403 + + +class Config(imaged.Config): + host = "127.0.0.1" + socket = "/tmp/vdsm-imaged.sock" + pki_dir = os.path.join(os.path.dirname(__file__), "pki") + poll_interval = 0.1 + + def __init__(self, repo_dir="/var/tmp"): + self.repo_dir = str(repo_dir) + + +def create_ticket(ops=("get", "put"), timeout=300, size=2**64, + path="/var/run/vdsm/storage/foo"): + return { + "uuid": str(uuid.uuid4()), + "expires": int(imaged.monotonic_time()) + timeout, + "ops": list(ops), + "size": size, + "path": path, + } + + +def upload(config, ticket_uuid, request_uuid, filename): + uri = "/images/%s?id=%s" % (ticket_uuid, request_uuid) + with open(filename) as f: + return http_request(config, "PUT", uri, f) + + +def http_request(config, method, uri, body=None, headers=None): + con = httplib.HTTPSConnection("127.0.0.1", config.port, config.key_file, + config.cert_file) + with closing(con): + con.request(method, uri, body=body, headers=headers or {}) + return response(con) + + +def unix_request(config, method, uri, body=None, headers=None): + con = uhttp.UnixHTTPSConnection(config.socket, config.key_file, + config.cert_file) + with closing(con): + con.request(method, uri, body=body, headers=headers or {}) + return response(con) + + +def response(con): + res = con.getresponse() + pprint((res.status, res.reason)) + pprint(res.getheaders()) + if res.status >= 400: + print(res.read()) + return res + + +def create_tempfile(tmpdir, name, data=''): + file = tmpdir.join(name) + file.write(data) + return file + + +def create_repo(tmpdir): + return tmpdir.mkdir("storage") + + +def create_volume(repo, domain, image, volume, data=''): + volume = repo.mkdir(domain).mkdir("images").mkdir(image).join(volume) + volume.write(data) + return volume + + +@contextmanager +def server(config): + imaged.start(config) + try: + yield + finally: + imaged.stop() diff --git a/vdsm-imaged/pki/certs/vdsmcert.pem b/vdsm-imaged/pki/certs/vdsmcert.pem new file mode 100644 index 0000000..5bc9352 --- /dev/null +++ b/vdsm-imaged/pki/certs/vdsmcert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfoCCQCNg3LPIL2ZWTANBgkqhkiG9w0BAQUFADBLMQswCQYDVQQGEwJV +UzEMMAoGA1UECAwDRm9vMQwwCgYDVQQHDANCYXIxDDAKBgNVBAoMA0RpczESMBAG +A1UEAwwJMTI3LjAuMC4xMB4XDTE1MDUxOTE4MTEzM1oXDTE2MDUxODE4MTEzM1ow +SzELMAkGA1UEBhMCVVMxDDAKBgNVBAgMA0ZvbzEMMAoGA1UEBwwDQmFyMQwwCgYD +VQQKDANEaXMxEjAQBgNVBAMMCTEyNy4wLjAuMTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAMw+K2KcmwVOV0d6XCGWC915+wBFGYNx/vwiDk9CR4jSmGNA +ufWVxB62qOl++WEenfVMSgM9ZcG7j6b7s1HvPnzpj4iNrfiugAC8tDCukdOZa60D +xRcrMttdXFAq7McwO2z7klS+oFkjMZ5cDIoGSsf2hweGPDfrfKWrGPBJO4nSnA6c +UPU43XXntFVWlXOpADVO8R0rRtAlRu4ec8ibiQZ97ZM5xF72YR3RQac18miaerqq +nyH1QInJQGob3T66uV+Sg36atwqi/TszqTvyGiwLOSQBpMYAL+XbRcrG7+lSZR4J +y95qVqajT6CaU6YTKNrE8Ne+LKJ8ur7S4JVzoGkCAwEAATANBgkqhkiG9w0BAQUF +AAOCAQEAZf+UXtg7hrMIv9+libnGMqK/BH4gRUpkFiIhPI2/7lDRM3m7zqaalCH9 +EkrSFSTfV1NNxs12ZQS3OWeZRDXMcUQ0iKXOFC+0vzjejWGiy5P4h1yUFuxOoigO +5irj6BF6ZvZES5UVSqYGNhHTltyY/oGCdD8KutXZoh2MHCURRR9NVlhmgQGmS9TW +TsH/ZwYNCFvpgPrX5MsSWEUyggsEk66/TNrCOk9RJ0qZ+Kmm7tPQwvI3ug7Izb5c +OcGunj0VgM0CIQbf9tnzSFR1ZO5eYka6kRVohwCdecl449K+45IX5pmBT5CXqtxN +X4JBlJYQGjtZe6McB0AWFjgmpFP5qg== +-----END CERTIFICATE----- diff --git a/vdsm-imaged/pki/keys/vdsmkey.pem b/vdsm-imaged/pki/keys/vdsmkey.pem new file mode 100644 index 0000000..103117c --- /dev/null +++ b/vdsm-imaged/pki/keys/vdsmkey.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAzD4rYpybBU5XR3pcIZYL3Xn7AEUZg3H+/CIOT0JHiNKYY0C5 +9ZXEHrao6X75YR6d9UxKAz1lwbuPpvuzUe8+fOmPiI2t+K6AALy0MK6R05lrrQPF +Fysy211cUCrsxzA7bPuSVL6gWSMxnlwMigZKx/aHB4Y8N+t8pasY8Ek7idKcDpxQ +9Tjddee0VVaVc6kANU7xHStG0CVG7h5zyJuJBn3tkznEXvZhHdFBpzXyaJp6uqqf +IfVAiclAahvdPrq5X5KDfpq3CqL9OzOpO/IaLAs5JAGkxgAv5dtFysbv6VJlHgnL +3mpWpqNPoJpTphMo2sTw174sony6vtLglXOgaQIDAQABAoIBAD+8Csfb/NgcCUpp +2YQ1kYBMh1IfPgXxtdMyQWrkUPRWW21ljmIfmTLIZ09t6x4ucrZQVyxJpY5eHEbM +drnTwZkzPTIsnCRlN9aDDGvAngr87kfwTDmdpmIj8SGnM3o5B+JLYu+FCP6n59z1 +9oe/zOg3ew2TCwmcN6pvCPo6sBSij+k4irOkYbCsTKoQzYcaBsKT/RoCVNnrArhX +e6nt9c0JvSci7iCl5hODDxz30Kwyu9msUTEi0J0AGlhrPRrJQebjKFk+KwDYhJ7O +OAsqiXWtENnokmsNuxnJXX3U28tD/uwbbt+RH/8h4E4+FCz9mPvmbGSFAIqhD0qe +hLlPGAECgYEA+5kg66Uk6tPyyPHTqg9Azhhu1CrItF7kfujzlJZPBMS+aoSFqKx8 +dg4MhfTZdweT9iHajNXMr6R4E+Vx2xDM3MLmiUojRVLtkhWZpf1AgwAFqQ4QBEDI +RpgAXpGfxFHPbFK/1et48EzXXCaRafJDQn860F8S5mXGFVhBkajoRQECgYEAz9Dx +gF+y8ZsCbzVT4grsYnlFnF1hkobiqkLaLrqb7rUkajlM4jvLnMIRvAJjUjVCJtvu +RBMnFFv2sQMF8dS5KoILvCl+nSEAUZtOy6lP+2LeKbdRK7WsMvbmsQGriZLIVIkD +/9m7d2WNa2hUdvOXvuIJlCLHrSMx3x+4O+/QU2kCgYEAxyJQ8xG3oYF1tOPqUrxV +34lpFtZyGojMs0HvoJhDHJQX3jjbjUBQFiRSdyfvgw1lZ7ctwrBpnE9BlwXQUsH+ +U3CThPA/8FuNm0UrYjyK9eRrln2B4cvgeDdQe4ko+fqCgMsR+N+xzggToGUKTN+p +qpUG2OuXCvJckIJVC1Oz2wECgYEAsZMYyVj8zvZlXiFzS/OJiZuWn8YrWcloZZQ6 +WzOZip++PgY4bBgsJAawoLZpqBVaMVo8fm7fNcZfRWIP8lSS5H+7B01to1ZPr2vG +KMDEV0pkC8FY2sCiI+pVtWp36VZDV/i8MiMazSs5bE353qrHP5RmGu6dMJSiSMYR +5yVEHgECgYBj05Bq5fQl2nM7E6yLbTnAeVQIJzvF+2m/ttdGx0hcLSVHQBjs3+tH +v9G5V5OIHXgFloT+tbmIIf+D/VuGZmluH8FoEFR7evK/64wu0qQkueTcTrfsjBh3 +0lhpHCfPafk6EYN0yU6Xb4MxNDmCat7NeUFPgnX2epM8nvD8he5lng== +-----END RSA PRIVATE KEY----- -- To view, visit https://gerrit.ovirt.org/41824 To unsubscribe, visit https://gerrit.ovirt.org/settings Gerrit-MessageType: newchange Gerrit-Change-Id: If3339fa94ef8464228cd036f4fe8eea61887e337 Gerrit-PatchSet: 4 Gerrit-Project: vdsm Gerrit-Branch: master Gerrit-Owner: Nir Soffer <[email protected]> Gerrit-Reviewer: Adam Litke <[email protected]> Gerrit-Reviewer: Allon Mureinik <[email protected]> Gerrit-Reviewer: Alon Bar-Lev <[email protected]> Gerrit-Reviewer: Amit Aviram <[email protected]> Gerrit-Reviewer: Dan Kenigsberg <[email protected]> Gerrit-Reviewer: Daniel Erez <[email protected]> Gerrit-Reviewer: Federico Simoncelli <[email protected]> Gerrit-Reviewer: Francesco Romani <[email protected]> Gerrit-Reviewer: Greg Padgett <[email protected]> Gerrit-Reviewer: Jenkins CI Gerrit-Reviewer: Nir Soffer <[email protected]> Gerrit-Reviewer: Yaniv Bronhaim <[email protected]> Gerrit-Reviewer: [email protected] _______________________________________________ vdsm-patches mailing list [email protected] https://lists.fedorahosted.org/mailman/listinfo/vdsm-patches
