├── .gitignore ├── CHANGELOG.rst ├── Makefile ├── README.rst ├── common ├── LICENSE.txt ├── MANIFEST.in ├── docs ├── negotiator_common │ ├── __init__.py │ ├── config.py │ ├── scripts │ │ ├── find-disk-usage │ │ ├── find-distribution-codename │ │ ├── find-distribution-release │ │ ├── find-distributor-id │ │ └── find-ip-addresses │ └── utils.py └── setup.py ├── docs ├── api.rst ├── changelog.rst ├── conf.py ├── index.rst ├── readme.rst └── requirements.txt ├── guest ├── LICENSE.txt ├── MANIFEST.in ├── docs ├── negotiator_guest │ ├── __init__.py │ └── cli.py └── setup.py ├── host ├── LICENSE.txt ├── MANIFEST.in ├── docs ├── negotiator_host │ ├── __init__.py │ └── cli.py └── setup.py ├── requirements-checks.txt └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | */dist/*.tar.gz 2 | */dist/*.whl 3 | */dist/*.zip 4 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | The purpose of this document is to list all of the notable changes to this 5 | project. The format was inspired by `Keep a Changelog`_. This project adheres 6 | to `semantic versioning`_. 7 | 8 | .. contents:: 9 | :local: 10 | 11 | .. _Keep a Changelog: http://keepachangelog.com/ 12 | .. _semantic versioning: http://semver.org/ 13 | 14 | `Release 0.12.2`_ (2019-12-11) 15 | ------------------------------ 16 | 17 | Bug fix for 2 most recent releases: ``s/OSError/EnvironmentError/g`` 18 | 19 | This is a follow up to / bug fix for `release 0.12`_ and `release 0.12.1`_ 20 | where I accidentally used ``OSError`` when I should have used ``IOError`` or 21 | the generic parent class ``EnvironmentError``. I've verified that 22 | ``EnvironmentError`` is compatible with both Python 2.7 and 3. 23 | 24 | The last few releases clearly show the value of having an automated test suite 25 | to guard against regressions, unfortunately due to the nature of the Negotiator 26 | project I'm not quite sure how I would get a functional test suite up and 27 | running on Travis CI. 28 | 29 | It's definitely possible, and on my wish list, but it is one of the higher 30 | hanging fruits on that -almost infinite- wish list, so don't hold your breath 31 | 😛. 32 | 33 | .. _Release 0.12.2: https://github.com/xolox/python-negotiator/compare/0.12.1...0.12.2 34 | 35 | `Release 0.12.1`_ (2019-12-09) 36 | ------------------------------ 37 | 38 | **Enable retry in guest CLI.** 39 | 40 | This is a follow up to `release 0.12`_ because I neglected to add 41 | ``retry=True`` to the ``negotiator_guest.cli`` module in that 42 | release (the new behavior is opt-in in the Python API so as to 43 | improve backwards compatibility and make the API more foolproof). 44 | 45 | .. _Release 0.12.1: https://github.com/xolox/python-negotiator/compare/0.12...0.12.1 46 | 47 | `Release 0.12`_ (2019-12-05) 48 | ---------------------------- 49 | 50 | Retry character device access in guest agent when ``EBUSY`` error is reported. 51 | 52 | At my employer we operate 200+ virtual servers that have Negotiator installed 53 | and in recent months we've started building more and more monitoring on top of 54 | Negotiator, resulting in hundreds of invocations per day. This is when I 55 | started seeing intermittent errors like the following:: 56 | 57 | IOError: [Errno 16] Device or resource busy: '/dev/vport2p2' 58 | 59 | The new retry on ``EBUSY`` behavior is intended to minimize occurrences of such 60 | race conditions. 61 | 62 | .. _Release 0.12: https://github.com/xolox/python-negotiator/compare/0.11...0.12 63 | 64 | `Release 0.11`_ (2019-10-11) 65 | ---------------------------- 66 | 67 | Fix error in error handling in ``negotiator-host`` 68 | This resolves the traceback below when ``negotiator-host`` is running but 69 | 'crashes' because ``virsh list`` fails because it can't connect to 70 | ``libvirtd``. This situation is still an unrecoverable error, but the 71 | intention was for it to be an unrecoverable error that did not produce a 72 | traceback... 73 | 74 | .. code-block:: none 75 | 76 | Traceback (most recent call last): 77 | File ".../negotiator_host/cli.py", line 117, in main 78 | action() 79 | File ".../negotiator_host/__init__.py", line 46, in __init__ 80 | self.enter_main_loop() 81 | File ".../negotiator_host/__init__.py", line 53, in enter_main_loop 82 | self.update_workers() 83 | File ".../negotiator_host/__init__.py", line 62, in update_workers 84 | running_guests = set(find_running_guests()) 85 | File ".../negotiator_host/__init__.py", line 270, in find_running_guests 86 | except ExternalCommandFailed: 87 | NameError: global name 'ExternalCommandFailed' is not defined 88 | 89 | Cleaned up flake8 F401 (imported but unused) warning 90 | As a result of moving to ``humanfriendly.compact()``. 91 | 92 | Minor changes relating to Python versions 93 | Two minor changes relating to Python version compatibility: 94 | 95 | - Unbreak ``make docs`` (Sphinx insists on Python 3, and rightfully so). 96 | 97 | - Replace Python 2.6 reference in README with Python 2.7. 98 | 99 | Compatibility with Python 3 hasn't been verified but is more or less expected 100 | given a pure Python code base. Maybe a Unicode error slipped in here or there, 101 | as I said "to be verified". 102 | 103 | .. _Release 0.11: https://github.com/xolox/python-negotiator/compare/0.10...0.11 104 | 105 | `Release 0.10`_ (2019-03-03) 106 | ---------------------------- 107 | 108 | Clarify verbosity control 109 | Update the command line interface usage messages to clarify that the options 110 | ``--verbose`` and ``--quiet`` can be repeated (in response to `#1`_). 111 | 112 | No traceback when guest discovery fails 113 | Don't log a traceback when guest discovery using the ``virsh list`` command 114 | fails, to avoid spamming the logs about a known problem. This change was made 115 | to counteract the following interaction: 116 | 117 | - The negotiator documentation specifically suggests to use a process 118 | supervision solution like supervisord_ to automatically restart the 119 | negotiator daemon when it dies. 120 | 121 | - When the libvirt daemon is down ``virsh list`` will fail and the negotiator 122 | daemon dies with a rather verbose traceback (before `release 0.10`_). 123 | 124 | - Because supervisord_ automatically restarts the negotiator daemon but 125 | doesn't know about the libvirt dependency, several restarts may be required 126 | to get the negotiator daemon up and running again. 127 | 128 | - This "restart until it stays up" interaction would result in quite a few 129 | useless tracebacks being logged which "polluted" the logs and might raise 130 | the impression that something is really broken (that can't be fixed by an 131 | automatic restart). 132 | 133 | .. _Release 0.10: https://github.com/xolox/python-negotiator/compare/0.9...0.10 134 | 135 | `Release 0.9`_ (2019-03-03) 136 | --------------------------- 137 | 138 | Refactored channel discovery to use ``virsh list`` and ``virsh dumpxml``: 139 | 140 | - The recent addition of Ubuntu 18.04 support proved once again that the 141 | old channel discovery strategy was error prone and hard to maintain. 142 | 143 | - Since then it had come to my attention that on Ubuntu 18.04 guest names 144 | embedded in pathnames of UNIX sockets may be truncated in which case the 145 | domain id provides the only way to match a UNIX socket to its guest. 146 | 147 | - Despite the previous point, I also wanted to maintain compatibility with 148 | libvirt releases that don't embed the domain id in the pathnames. Doing so 149 | based on the old channel discovery strategy would have become messy. 150 | 151 | So I decided to take a big step back and opted for a new strategy that will 152 | hopefully prove to be more robust and future proof. Thanks to `@tarmack`_ for 153 | initially suggesting this approach. 154 | 155 | .. _Release 0.9: https://github.com/xolox/python-negotiator/compare/0.8.6...0.9 156 | .. _@tarmack: https://github.com/tarmack 157 | 158 | `Release 0.8.6`_ (2019-02-25) 159 | ----------------------------- 160 | 161 | Follow-up to making channel discovery compatible with Ubuntu 18.04: 162 | 163 | - `Release 0.8.5`_ updated ``negotiator-host --daemon``. 164 | - `Release 0.8.6`_ updates ``negotiator-host --list-commands`` and similar commands. 165 | 166 | .. _Release 0.8.6: https://github.com/xolox/python-negotiator/compare/0.8.5...0.8.6 167 | 168 | `Release 0.8.5`_ (2019-02-23) 169 | ----------------------------- 170 | 171 | - Made channel discovery compatible with Ubuntu 18.04 (related to `#1`_). 172 | - Added this changelog, restructured the documentation. 173 | - Embedded CLI usage messages in readme and documentation. 174 | - Updated supervisord_ configuration examples to use 175 | ``stderr_logfile`` instead of ``redirect_stderr``. 176 | - Other minor changes not touching the code base. 177 | 178 | .. _Release 0.8.5: https://github.com/xolox/python-negotiator/compare/0.8.4...0.8.5 179 | .. _#1: https://github.com/xolox/python-negotiator/pull/1 180 | .. _supervisord: http://supervisord.org/ 181 | 182 | `Release 0.8.4`_ (2016-04-08) 183 | ----------------------------- 184 | 185 | Follow-up to previous commit (Ubuntu 16.04 support). 186 | 187 | .. _Release 0.8.4: https://github.com/xolox/python-negotiator/compare/0.8.3...0.8.4 188 | 189 | `Release 0.8.3`_ (2016-04-08) 190 | ----------------------------- 191 | 192 | Make channel discovery compatible with Ubuntu 16.04. 193 | 194 | .. _Release 0.8.3: https://github.com/xolox/python-negotiator/compare/0.8.2...0.8.3 195 | 196 | `Release 0.8.2`_ (2015-10-29) 197 | ----------------------------- 198 | 199 | Make platform support more explicit in the documentation (Linux only, basically :-P). 200 | 201 | .. _Release 0.8.2: https://github.com/xolox/python-negotiator/compare/0.8.1...0.8.2 202 | 203 | `Release 0.8.1`_ (2014-12-30) 204 | ----------------------------- 205 | 206 | Improve guest channel (re)spawning on hosts (improves robustness). 207 | 208 | .. _Release 0.8.1: https://github.com/xolox/python-negotiator/compare/0.8...0.8.1 209 | 210 | `Release 0.8`_ (2014-11-01) 211 | --------------------------- 212 | 213 | Proper sub process cleanup, more robust blocking read emulation. 214 | 215 | .. _Release 0.8: https://github.com/xolox/python-negotiator/compare/0.7...0.8 216 | 217 | `Release 0.7`_ (2014-10-24) 218 | --------------------------- 219 | 220 | Support for (custom) remote call timeouts with a default of 10s. 221 | 222 | .. _Release 0.7: https://github.com/xolox/python-negotiator/compare/0.6.1...0.7 223 | 224 | `Release 0.6.1`_ (2014-09-28) 225 | ----------------------------- 226 | 227 | Bug fix for Python 2.6 compatibility (``count()`` does not take keyword arguments). 228 | 229 | .. _Release 0.6.1: https://github.com/xolox/python-negotiator/compare/0.6...0.6.1 230 | 231 | `Release 0.6`_ (2014-09-26) 232 | --------------------------- 233 | 234 | - Implemented blocking reads inside guests (don't ask me how, please ...). 235 | - Improved getting started instructions on adding virtual devices. 236 | - Rebranded ``s/generic/scriptable/g`` and improved the readme a bit. 237 | 238 | .. _Release 0.6: https://github.com/xolox/python-negotiator/compare/0.5.2...0.6 239 | 240 | `Release 0.5.2`_ (2014-09-24) 241 | ----------------------------- 242 | 243 | Add syntax highlighting to the code and configuration samples in the readme 244 | and explicitly link to the online documentation available on Read the Docs. 245 | 246 | .. _Release 0.5.2: https://github.com/xolox/python-negotiator/compare/0.5.1...0.5.2 247 | 248 | `Release 0.5.1`_ (2014-09-24) 249 | ----------------------------- 250 | 251 | - Minor improvements and fixes to the documentation. 252 | - Properly documented the environment variables exposed to host commands. 253 | - Added trove classifiers to the ``setup.py`` scripts. 254 | - Bumped the version to release updated documentation to PyPI. 255 | 256 | .. _Release 0.5.1: https://github.com/xolox/python-negotiator/compare/0.5...0.5.1 257 | 258 | `Release 0.5`_ (2014-09-24) 259 | --------------------------- 260 | 261 | - Support for proper bidirectional user defined command execution on both sides. 262 | - Improved the ``negotiator-guest`` usage message (by mentioning character device detection). 263 | 264 | .. _Release 0.5: https://github.com/xolox/python-negotiator/compare/0.2.1...0.5 265 | 266 | `Release 0.2.1`_ (2014-09-22) 267 | ----------------------------- 268 | 269 | Fixed a typo in the readme, fixed a bug in the makefile and bumped the version 270 | so I could push a new release to PyPI because the readme was missing there (due 271 | to the makefile bug). 272 | 273 | .. _Release 0.2.1: https://github.com/xolox/python-negotiator/compare/0.2...0.2.1 274 | 275 | `Release 0.2`_ (2014-09-22) 276 | --------------------------- 277 | 278 | - Added automatic character device selection. 279 | - Created online documentation on Read the Docs. 280 | 281 | .. _Release 0.2: https://github.com/xolox/python-negotiator/compare/0.1...0.2 282 | 283 | `Release 0.1`_ (2014-09-22) 284 | --------------------------- 285 | 286 | The initial commit and release. 287 | 288 | .. _Release 0.1: https://github.com/xolox/python-negotiator/tree/0.1 289 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for negotiator. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: October 11, 2019 5 | # URL: https://github.com/xolox/negotiator 6 | 7 | PROJECT_NAME = negotiator 8 | WORKON_HOME ?= $(HOME)/.virtualenvs 9 | VIRTUAL_ENV ?= $(WORKON_HOME)/$(PROJECT_NAME) 10 | PATH := $(VIRTUAL_ENV)/bin:$(PATH) 11 | MAKE := $(MAKE) --no-print-directory 12 | SHELL = bash 13 | 14 | default: 15 | @echo "Makefile for $(PROJECT_NAME)" 16 | @echo 17 | @echo 'Usage:' 18 | @echo 19 | @echo ' make install install the package in a virtual environment' 20 | @echo ' make reset recreate the virtual environment' 21 | @echo ' make check check coding style (PEP-8, PEP-257)' 22 | @echo ' make readme update usage in readme' 23 | @echo ' make docs update documentation using Sphinx' 24 | @echo ' make publish publish changes to GitHub/PyPI' 25 | @echo ' make clean cleanup all temporary files' 26 | @echo 27 | 28 | install: 29 | @test -d "$(VIRTUAL_ENV)" || mkdir -p "$(VIRTUAL_ENV)" 30 | @test -x "$(VIRTUAL_ENV)/bin/python" || virtualenv --python=python3 --quiet "$(VIRTUAL_ENV)" 31 | @test -x "$(VIRTUAL_ENV)/bin/pip" || easy_install pip 32 | @pip uninstall --yes negotiator-host &>/dev/null || true 33 | @pip uninstall --yes negotiator-guest &>/dev/null || true 34 | @pip uninstall --yes negotiator-common &>/dev/null || true 35 | @pip install --quiet --editable ./common 36 | @pip install --quiet --editable ./host 37 | @pip install --quiet --editable ./guest 38 | 39 | reset: 40 | $(MAKE) clean 41 | rm -Rf "$(VIRTUAL_ENV)" 42 | $(MAKE) install 43 | 44 | check: install 45 | @pip install -r requirements-checks.txt && flake8 46 | 47 | readme: install 48 | @pip install --quiet cogapp && cog.py -r README.rst 49 | 50 | docs: readme 51 | @pip install --quiet sphinx 52 | @cd docs && sphinx-build -nb html -d build/doctrees . build/html 53 | 54 | publish: install 55 | git push origin && git push --tags origin 56 | $(MAKE) clean 57 | pip install --quiet twine wheel 58 | set -e; for package in common host guest; do \ 59 | cp $(CURDIR)/README.rst $(CURDIR)/$$package; \ 60 | cd $(CURDIR)/$$package; \ 61 | python setup.py sdist bdist_wheel; \ 62 | twine upload dist/*; \ 63 | rm README.rst; \ 64 | done 65 | 66 | clean: 67 | rm -Rf {host,guest,common}/{build,dist} 68 | rm -Rf docs/{_{build,static,templates},build} 69 | find -type f -name '*.pyc' -delete 70 | 71 | .PHONY: default install reset check readme docs publish clean 72 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Scriptable KVM/QEMU guest agent implemented in Python 2 | ===================================================== 3 | 4 | The Python packages negotiator-host_, negotiator-guest_ and negotiator-common_ 5 | together implement a scriptable KVM_/QEMU_ guest agent infrastructure in 6 | Python. This infrastructure supports realtime bidirectional communication 7 | between Linux_ hosts and guests which allows the hosts and guests to invoke 8 | user defined commands on 'the other side'. 9 | 10 | Because the user defines the commands that hosts and guests can execute, the 11 | user controls the amount of influence that hosts and guests have over each 12 | other (there are several built-in commands, these are all read only). 13 | 14 | .. contents:: 15 | 16 | Status 17 | ------ 18 | 19 | Some points to consider: 20 | 21 | - The Negotiator project does what I expect from it: realtime bidirectional 22 | communication between Linux based KVM/QEMU hosts and guests. 23 | 24 | - The project doesn't have an automated test suite, although its functionality 25 | has been extensively tested during development and is being used in a 26 | production environment on more than 100 virtual machines (for non-critical 27 | tasks). 28 | 29 | - The project has not been peer reviewed with regards to security. My primary 30 | use case is KVM/QEMU hosts and guests that trust each other to some extent 31 | (think private clouds, not shared hosting :-). 32 | 33 | Installation 34 | ------------ 35 | 36 | The `negotiator` packages and their dependencies are compatible with Python 2.7 37 | and newer and are all pure Python. This means you don't need a compiler 38 | toolchain to install the `negotiator` packages. This is a design decision and 39 | so won't be changed. 40 | 41 | .. contents:: 42 | :local: 43 | 44 | On KVM/QEMU hosts 45 | ~~~~~~~~~~~~~~~~~ 46 | 47 | Here's how to install the negotiator-host_ package on your host(s): 48 | 49 | .. code-block:: bash 50 | 51 | $ sudo pip install negotiator-host 52 | 53 | If you prefer you can install the Python package in a virtual environment: 54 | 55 | .. code-block:: bash 56 | 57 | $ sudo apt-get install --yes python-virtualenv 58 | $ virtualenv /tmp/negotiator-host 59 | $ source /tmp/negotiator-host/bin/activate 60 | $ pip install negotiator-host 61 | 62 | After installation the ``negotiator-host`` program is available. The usage 63 | message will help you get started, try the ``--help`` option. Now you need to 64 | find a way to run the ``negotiator-host`` command as a daemon. I have good 65 | experiences with supervisord_, here's how to set that up: 66 | 67 | .. code-block:: bash 68 | 69 | $ sudo apt-get install --yes supervisor 70 | $ sudo tee /etc/supervisor/conf.d/negotiator-host.conf >/dev/null << EOF 71 | [program:negotiator-host] 72 | command = /usr/local/bin/negotiator-host --daemon 73 | autostart = True 74 | stdout_logfile = /var/log/negotiator-host.log 75 | stderr_logfile = /var/log/negotiator-host.log 76 | EOF 77 | $ sudo supervisorctl update negotiator-host 78 | 79 | On KVM/QEMU guests 80 | ~~~~~~~~~~~~~~~~~~ 81 | 82 | Install the negotiator-guest_ package on your guest(s): 83 | 84 | .. code-block:: bash 85 | 86 | $ sudo pip install negotiator-guest 87 | 88 | If you prefer you can install the Python package in a virtual environment: 89 | 90 | .. code-block:: bash 91 | 92 | $ sudo apt-get install --yes python-virtualenv 93 | $ virtualenv /tmp/negotiator-guest 94 | $ source /tmp/negotiator-guest/bin/activate 95 | $ pip install negotiator-guest 96 | 97 | After installation you need to find a way to run the ``negotiator-guest`` 98 | command as a daemon. I have good experiences with supervisord_, here's how 99 | to set that up: 100 | 101 | .. code-block:: bash 102 | 103 | $ sudo apt-get install --yes supervisor 104 | $ sudo tee /etc/supervisor/conf.d/negotiator-guest.conf >/dev/null << EOF 105 | [program:negotiator-guest] 106 | command = /usr/local/bin/negotiator-guest --daemon 107 | autostart = True 108 | stdout_logfile = /var/log/negotiator-guest.log 109 | stderr_logfile = /var/log/negotiator-guest.log 110 | EOF 111 | $ sudo supervisorctl update negotiator-guest 112 | 113 | Getting started 114 | --------------- 115 | 116 | If the instructions below are not enough to get you started, take a look at the 117 | *Debugging* section below for hints about what to do when things don't work as 118 | expected. 119 | 120 | 1. First you have to add two virtual devices to your QEMU guest. You can do so 121 | by editing the guest's XML definition file. On Ubuntu Linux KVM/QEMU hosts 122 | these files are found in the directory ``/etc/libvirt/qemu``. Open the file 123 | in your favorite text editor (Vim? :-) and add the the following XML snippet 124 | inside the ```` section: 125 | 126 | .. code-block:: xml 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | Replace ``GUEST_NAME`` with the name of your guest in both places. If you 139 | use libvirt 1.0.6 or newer (you can check with ``virsh --version``) you can 140 | omit the ``path='...'`` attribute because libvirt will fill it in 141 | automatically when it reloads the guest's XML definition file (in step 2). 142 | 143 | 2. After adding the configuration snippet you have to activate it: 144 | 145 | .. code-block:: bash 146 | 147 | $ sudo virsh define /etc/libvirt/qemu/GUEST_NAME.xml 148 | 149 | 3. Now you need to shut down the guest and then start it again: 150 | 151 | .. code-block:: bash 152 | 153 | $ sudo virsh shutdown --mode acpi GUEST_NAME 154 | $ sudo virsh start GUEST_NAME 155 | 156 | Note that just rebooting the guest will not add the new virtual devices, you 157 | have to actually stop the guest and then start it again! 158 | 159 | 4. Now go and create some scripts in ``/usr/lib/negotiator/commands`` and try 160 | to execute them from the other side! Once you start writing your own 161 | commands it's useful to know that commands on the KVM/QEMU host side have 162 | access to some `environment variables`_. 163 | 164 | Usage 165 | ----- 166 | 167 | This section documents the command line interfaces of the programs running on 168 | hosts and guests. For information on the Python API please refer to the online 169 | documentation on `Read the Docs`_. 170 | 171 | .. contents:: 172 | :local: 173 | 174 | The negotiator-host program 175 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 176 | 177 | .. A DRY solution to avoid duplication of the `negotiator-host --help' text: 178 | .. 179 | .. [[[cog 180 | .. from humanfriendly.usage import inject_usage 181 | .. inject_usage('negotiator_host.cli') 182 | .. ]]] 183 | 184 | **Usage:** `negotiator-host [OPTIONS] GUEST_NAME` 185 | 186 | Communicate from a KVM/QEMU host system with running guest systems using a 187 | guest agent daemon running inside the guests. 188 | 189 | **Supported options:** 190 | 191 | .. csv-table:: 192 | :header: Option, Description 193 | :widths: 30, 70 194 | 195 | 196 | "``-g``, ``--list-guests``",List the names of the guests that have the appropriate channel. 197 | "``-c``, ``--list-commands``",List the commands that the guest exposes to its host. 198 | "``-e``, ``--execute=COMMAND``","Execute the given command inside GUEST_NAME. The standard output stream of 199 | the command inside the guest is intercepted and copied to the standard 200 | output stream on the host. If the command exits with a nonzero status code 201 | the negotiator-host program will also exit with a nonzero status code." 202 | "``-t``, ``--timeout=SECONDS``","Set the number of seconds before a remote call without a response times 203 | out. A value of zero disables the timeout (in this case the command can 204 | hang indefinitely). The default is 10 seconds." 205 | "``-d``, ``--daemon``",Start the host daemon that answers real time requests from guests. 206 | "``-v``, ``--verbose``",Increase logging verbosity (can be repeated). 207 | "``-q``, ``--quiet``",Decrease logging verbosity (can be repeated). 208 | "``-h``, ``--help``",Show this message and exit. 209 | 210 | .. [[[end]]] 211 | 212 | The negotiator-guest program 213 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 214 | 215 | .. A DRY solution to avoid duplication of the `negotiator-host --help' text: 216 | .. 217 | .. [[[cog 218 | .. from humanfriendly.usage import inject_usage 219 | .. inject_usage('negotiator_guest.cli') 220 | .. ]]] 221 | 222 | **Usage:** `negotiator-guest [OPTIONS]` 223 | 224 | Communicate from a KVM/QEMU guest system to its host or start the 225 | guest daemon to allow the host to execute commands on its guests. 226 | 227 | **Supported options:** 228 | 229 | .. csv-table:: 230 | :header: Option, Description 231 | :widths: 30, 70 232 | 233 | 234 | "``-l``, ``--list-commands``",List the commands that the host exposes to its guests. 235 | "``-e``, ``--execute=COMMAND``","Execute the given command on the KVM/QEMU host. The standard output stream 236 | of the command on the host is intercepted and copied to the standard output 237 | stream on the guest. If the command exits with a nonzero status code the 238 | negotiator-guest program will also exit with a nonzero status code." 239 | "``-d``, ``--daemon``","Start the guest daemon. When using this command line option the 240 | ""negotiator-guest"" program never returns (unless an unexpected error 241 | condition occurs)." 242 | "``-t``, ``--timeout=SECONDS``","Set the number of seconds before a remote call without a response times 243 | out. A value of zero disables the timeout (in this case the command can 244 | hang indefinitely). The default is 10 seconds." 245 | "``-c``, ``--character-device=PATH``","By default the appropriate character device is automatically selected based 246 | on /sys/class/virtio-ports/\*/name. If the automatic selection doesn't work, 247 | you can set the absolute pathname of the character device that's used to 248 | communicate with the negotiator-host daemon running on the KVM/QEMU host." 249 | "``-v``, ``--verbose``",Increase logging verbosity (can be repeated). 250 | "``-q``, ``--quiet``",Decrease logging verbosity (can be repeated). 251 | "``-h``, ``--help``",Show this message and exit. 252 | 253 | .. [[[end]]] 254 | 255 | Debugging 256 | --------- 257 | 258 | This section contains hints about what to do when things don't work as 259 | expected. 260 | 261 | .. contents:: 262 | :local: 263 | 264 | Broken channels on KVM/QEMU hosts 265 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 266 | 267 | Whether you want to get the official QEMU guest agent or the Negotiator project 268 | running, you will need a working bidirectional channel. I'm testing Negotiator 269 | on an Ubuntu 14.04 KVM/QEMU host and I needed several changes to get things 270 | working properly: 271 | 272 | .. code-block:: bash 273 | 274 | $ CHANNELS_DIRECTORY=/var/lib/libvirt/qemu/channel/target 275 | $ sudo mkdir -p $CHANNELS_DIRECTORY 276 | $ sudo chown libvirt-qemu:kvm $CHANNELS_DIRECTORY 277 | 278 | The above should be done by the KVM/QEMU system packages if you ask me, but 279 | anyway. On top of this if you are running Ubuntu with AppArmor enabled (the 280 | default) you may need to apply the following patch: 281 | 282 | .. code-block:: bash 283 | 284 | $ diff -u /etc/apparmor.d/abstractions/libvirt-qemu.orig /etc/apparmor.d/abstractions/libvirt-qemu 285 | --- /etc/apparmor.d/abstractions/libvirt-qemu.orig 2015-09-19 12:46:54.316593334 +0200 286 | +++ /etc/apparmor.d/abstractions/libvirt-qemu 2015-09-24 14:43:43.642064576 +0200 287 | @@ -49,6 +49,9 @@ 288 | /run/shm/ r, 289 | owner /run/shm/spice.* rw, 290 | 291 | + # Local modification to enable the QEMU guest agent. 292 | + owner /var/lib/libvirt/qemu/channel/target/* rw, 293 | + 294 | # 'kill' is not required for sound and is a security risk. Do not enable 295 | # unless you absolutely need it. 296 | deny capability kill, 297 | 298 | Again this should just be part of the KVM/QEMU system packages, but whatever. 299 | The Negotiator project is playing with new-ish functionality so I pretty much 300 | know to expect sharp edges :-) 301 | 302 | Character device detection fails 303 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 304 | 305 | When the ``negotiator-guest`` program fails to detect the correct character 306 | devices it will complain loudly and point you here. Here are some of things 307 | I've run into that can cause this: 308 | 309 | - The virtual channel(s) have not been correctly configured or the correct 310 | configuration hasn't been applied yet. Please carefully follow the 311 | instructions in the *Getting started* section above. 312 | 313 | - The kernel module ``virtio_console`` is not loaded because it is not 314 | available in your kernel. You can check by using the ``lsmod`` command. If 315 | the module is not loaded you'll need to install and boot to a kernel that 316 | does have the module. 317 | 318 | Why another guest agent? 319 | ------------------------ 320 | 321 | The QEMU project provides an `official guest agent`_ and this agent is very 322 | useful to increase integration between QEMU hosts and guests. However the 323 | official QEMU guest agent has two notable shortcomings (for me at least): 324 | 325 | **Extensibility** 326 | The official QEMU guest agent has some generic mechanisms like being able to 327 | write files inside guests, but this is a far cry from a generic, extensible 328 | architecture. Ideally given the host and guest's permission we should be able 329 | to transfer arbitrary data and execute user defined logic on both sides. 330 | 331 | **Platform support** 332 | Despite considerable effort I haven't been able to get a recent version of 333 | the QEMU guest agent running on older Linux distributions (e.g. Ubuntu Linux 334 | 10.04). Older versions of the guest agent can be succesfully compiled for 335 | such distributions but don't support the features I require. By creating my 336 | own guest agent I have more control over platform support (given the 337 | primitives required for communication). 338 | 339 | Note that my project in no way tries to replace the official QEMU guest agent. 340 | For example I have no intention of implementing freezing and thawing of file 341 | systems because the official agent already does that just fine :-). In other 342 | words the two projects share a lot of ideas but have very different goals. 343 | 344 | How does it work? 345 | ----------------- 346 | 347 | The scriptable guest agent infrastructure uses `the same mechanism`_ that the 348 | official QEMU guest agent does: 349 | 350 | - Inside the guest special character devices are created that allow reading and 351 | writing. These character devices are ``/dev/vport[0-9]p[0-9]``. 352 | 353 | - On the host UNIX domain sockets are created that are connected to the 354 | character devices inside the guest. On Ubuntu Linux KVM/QEMU hosts, 355 | these UNIX domain sockets are created in the directory 356 | ``/var/lib/libvirt/qemu/channel/target``. 357 | 358 | Contact 359 | ------- 360 | 361 | The latest version of `negotiator` is available on PyPI_ and GitHub_. You can 362 | find the documentation on `Read The Docs`_. For bug reports please create an 363 | issue on GitHub_. If you have questions, suggestions, etc. feel free to send me 364 | an e-mail at `peter@peterodding.com`_. 365 | 366 | License 367 | ------- 368 | 369 | This software is licensed under the `MIT license`_. 370 | 371 | © 2019 Peter Odding. 372 | 373 | .. External references: 374 | .. _environment variables: http://negotiator.readthedocs.org/en/latest/#negotiator_host.GuestChannel.prepare_environment 375 | .. _GitHub: https://github.com/xolox/python-negotiator 376 | .. _KVM: https://en.wikipedia.org/wiki/Kernel-based_Virtual_Machine 377 | .. _Linux: https://en.wikipedia.org/wiki/Linux 378 | .. _MIT license: http://en.wikipedia.org/wiki/MIT_License 379 | .. _negotiator-common: https://pypi.python.org/pypi/negotiator-common 380 | .. _negotiator-guest: https://pypi.python.org/pypi/negotiator-guest 381 | .. _negotiator-host: https://pypi.python.org/pypi/negotiator-host 382 | .. _official guest agent: http://wiki.libvirt.org/page/Qemu_guest_agent 383 | .. _peter@peterodding.com: peter@peterodding.com 384 | .. _PyPI: https://pypi.python.org/pypi/negotiator-host 385 | .. _QEMU: https://en.wikipedia.org/wiki/QEMU 386 | .. _Read The Docs: http://negotiator.readthedocs.org/en/latest/ 387 | .. _supervisord: http://supervisord.org/ 388 | .. _the same mechanism: http://www.linux-kvm.org/page/VMchannel_Requirements 389 | -------------------------------------------------------------------------------- /common/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Peter Odding 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /common/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | recursive-include negotiator_common/scripts * 4 | -------------------------------------------------------------------------------- /common/docs: -------------------------------------------------------------------------------- 1 | ../docs -------------------------------------------------------------------------------- /common/negotiator_common/__init__.py: -------------------------------------------------------------------------------- 1 | # Scriptable KVM/QEMU guest agent in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: December 9, 2019 5 | # URL: https://negotiator.readthedocs.org 6 | 7 | """ 8 | Common shared functionality between the `negotiator` host and guest. 9 | 10 | This Python module contains the functionality that is shared between the 11 | negotiator-host_ and negotiator-guest_ packages. By moving all of the shared 12 | functionality to a separate Python package and using Python package 13 | dependencies to pull in the negotiator-common_ package we stimulate code reuse 14 | while avoiding code duplication. 15 | 16 | .. _negotiator-common: https://pypi.python.org/pypi/negotiator-common 17 | .. _negotiator-guest: https://pypi.python.org/pypi/negotiator-guest 18 | .. _negotiator-host: https://pypi.python.org/pypi/negotiator-host 19 | """ 20 | 21 | # Standard library modules. 22 | import json 23 | import logging 24 | import os 25 | 26 | # External dependencies. 27 | from executor import execute 28 | from humanfriendly import Timer, compact 29 | 30 | # Modules included in our project. 31 | from negotiator_common.utils import format_call 32 | from negotiator_common.config import BUILTIN_COMMANDS_DIRECTORY, USER_COMMANDS_DIRECTORY 33 | 34 | # Semi-standard module versioning. 35 | __version__ = '0.12.2' 36 | 37 | # Initialize a logger for this module. 38 | logger = logging.getLogger(__name__) 39 | 40 | 41 | class NegotiatorInterface(object): 42 | 43 | """ 44 | Common logic shared between the host/guest components. 45 | 46 | This class defines the protocol that's used to communicate between the 47 | Python programs running on the hosts and guests. 48 | """ 49 | 50 | def __init__(self, handle, label): 51 | """ 52 | Initialize a negotiator host or guest agent. 53 | 54 | :param handle: A file like object connected to the other side. 55 | :param label: A string describing the file like object (used in logging). 56 | 57 | This constructor is intended to be called by sub classes to provide the 58 | base class with the context it needs to set up bidirectional 59 | communication between the host and guest agents. 60 | """ 61 | self.conn_handle = handle 62 | self.conn_label = label 63 | # Somewhere in the Python installation process the executable bits of 64 | # the built-in scripts get lost. This is a pragmatic hack to compensate 65 | # for that. 66 | for entry in os.listdir(BUILTIN_COMMANDS_DIRECTORY): 67 | pathname = os.path.join(BUILTIN_COMMANDS_DIRECTORY, entry) 68 | if os.path.isfile(pathname) and not os.access(pathname, os.X_OK): 69 | logger.debug("Making %s executable ..", pathname) 70 | os.chmod(pathname, 0o755) 71 | 72 | def raw_read(self, num_bytes): 73 | """ 74 | Read the given number of bytes from the remote side. 75 | 76 | :param num_bytes: The number of bytes to read (an integer). 77 | :returns: The data read from the remote side (a string). 78 | """ 79 | logger.debug("Preparing to read %i bytes from %s ..", num_bytes, self.conn_label) 80 | data = self.conn_handle.read(num_bytes) 81 | logger.debug("Read %i bytes from %s: %r", len(data), self.conn_label, data) 82 | return data 83 | 84 | def raw_readline(self): 85 | """ 86 | Read a newline terminated string from the remote side. 87 | 88 | :returns: The data read from the remote side (a string). 89 | """ 90 | logger.debug("Preparing to read line from %s ..", self.conn_label) 91 | data = self.conn_handle.readline() 92 | logger.debug("Read %i bytes from %s: %r", len(data), self.conn_label, data) 93 | return data 94 | 95 | def raw_write(self, data): 96 | """ 97 | Write a string of data to the remote side. 98 | 99 | :param data: The data to write tot the remote side (a string). 100 | """ 101 | logger.debug("Preparing to write %i bytes to %s ..", len(data), self.conn_label) 102 | self.conn_handle.write(data) 103 | self.conn_handle.flush() 104 | logger.debug("Finished writing %i bytes to %s.", len(data), self.conn_label) 105 | 106 | def read(self): 107 | """ 108 | Wait for a JSON encoded message from the remote side. 109 | 110 | The basic communication protocol is really simple: 111 | 112 | 1. First an ASCII encoded integer number is received, terminated by a 113 | newline. 114 | 2. Second the number of bytes given by step 1 is read and interpreted 115 | as a JSON encoded value. This step is not terminated by a newline. 116 | 117 | That's it :-). 118 | 119 | :returns: The JSON value decoded to a Python value. 120 | :raises: :exc:`ProtocolError` when the remote side violates the 121 | defined protocol. 122 | """ 123 | logger.debug("Waiting for message from other side ..") 124 | # Wait for a line containing an integer byte count. 125 | line = self.raw_readline().strip() 126 | if not line.isdigit(): 127 | # Complain loudly about protocol errors :-). 128 | raise ProtocolError(compact(""" 129 | Received invalid input from remote side! I was expecting a 130 | byte count, but what I got instead was the line {input}! 131 | """, input=repr(line))) 132 | else: 133 | # First we get a line containing a byte count, then we read 134 | # that number of bytes from the remote side and decode it as a 135 | # JSON encoded message. 136 | num_bytes = int(line, 10) 137 | logger.debug("Reading message of %i bytes ..", num_bytes) 138 | encoded_value = self.raw_read(num_bytes) 139 | try: 140 | decoded_value = json.loads(encoded_value) 141 | logger.debug("Parsed message: %s", decoded_value) 142 | return decoded_value 143 | except Exception as e: 144 | logger.exception("Failed to parse JSON formatted message!") 145 | raise ProtocolError(compact(""" 146 | Failed to decode message from remote side as JSON! 147 | Tried to decode message {message}. Original error: 148 | {error}. 149 | """, message=repr(encoded_value), error=str(e))) 150 | 151 | def write(self, value): 152 | """ 153 | Send a Python value to the other side. 154 | 155 | :param value: Any Python value that can be encoded as JSON. 156 | """ 157 | encoded_message = json.dumps(value) 158 | num_bytes = len(encoded_message) 159 | logger.debug("Sending message of %i bytes: %s", num_bytes, encoded_message) 160 | self.raw_write("%i\n%s" % (num_bytes, encoded_message)) 161 | 162 | def call_remote_method(self, method, *args, **kw): 163 | """ 164 | Call a method on the remote object. 165 | 166 | :param method: The name of the method to call (a string). 167 | :param args: The positional arguments for the method. 168 | :param kw: The keyword arguments for the method. 169 | :returns: The return value of the remote method. 170 | """ 171 | timer = Timer() 172 | logger.debug("Calling remote method %s ..", format_call(method, *args, **kw)) 173 | self.write(dict(method=method, args=args, kw=kw)) 174 | response = self.read() 175 | if response['success']: 176 | logger.debug("Remote method call succeeded in %s and returned %r!", timer, response['result']) 177 | return response['result'] 178 | else: 179 | logger.warning("Remote method call failed after %s: %s", timer, response['error']) 180 | raise RemoteMethodFailed(response['error']) 181 | 182 | def enter_main_loop(self): 183 | """ 184 | Wait for requests from the other side. 185 | 186 | The communication protocol for remote procedure calls is as follows: 187 | 188 | - Every request is a dictionary containing at least a ``command`` key 189 | with a string value (the name of the method to invoke). 190 | 191 | - The value of the optional ``arguments`` key gives a list of 192 | positional arguments to pass to the method. 193 | 194 | - The value of the optional ``keyword-arguments`` key gives a 195 | dictionary of keyword arguments to pass to the method. 196 | 197 | Responses are structured as follows: 198 | 199 | - Every response is a dictionary containing at least a ``success`` key 200 | with a boolean value. 201 | 202 | - If ``success=True`` the key ``result`` gives the return value of the 203 | method. 204 | 205 | - If ``success=False`` the key ``error`` gives a string explaining what 206 | went wrong. 207 | 208 | :raises: :exc:`ProtocolError` when the remote side violates the 209 | defined protocol. 210 | """ 211 | while True: 212 | request = self.read() 213 | method_name = request.get('method') 214 | method = getattr(self, method_name, None) 215 | args = request.get('args', []) 216 | kw = request.get('kw', {}) 217 | if method and not method_name.startswith('_'): 218 | try: 219 | logger.info("Remote is calling local method %s ..", format_call(method_name, *args, **kw)) 220 | result = method(*args, **kw) 221 | logger.info("Local method call was successful and returned result %r.", result) 222 | self.write(dict(success=True, result=result)) 223 | except Exception as e: 224 | logger.exception("Swallowing unexpected exception during local method call so we don't crash!") 225 | self.write(dict(success=False, error=str(e))) 226 | else: 227 | logger.warning("Remote tried to call unsupported method %s!", method_name) 228 | self.write(dict(success=False, error="Method %s not supported" % method_name)) 229 | 230 | def list_commands(self): 231 | """ 232 | Find the names of the user defined commands. 233 | 234 | :returns: A list of executable names (strings). 235 | """ 236 | commands = set() 237 | for directory in (BUILTIN_COMMANDS_DIRECTORY, USER_COMMANDS_DIRECTORY): 238 | if os.path.isdir(directory): 239 | for entry in os.listdir(directory): 240 | pathname = os.path.join(directory, entry) 241 | if os.path.isfile(pathname) and os.access(pathname, os.X_OK): 242 | commands.add(entry) 243 | return list(commands) 244 | 245 | def prepare_environment(self): 246 | """ 247 | Prepare environment variables for command execution. 248 | 249 | This method can be overridden by sub classes to prepare environment 250 | variables for external command execution. 251 | """ 252 | 253 | def execute(self, *command, **options): 254 | """ 255 | Execute a user defined or built-in command. 256 | 257 | :param command: The command name and any arguments (one or more strings). 258 | :param input: The input to feed to the command on its standard input 259 | stream (a string or ``None``). 260 | :returns: The output of the command (a string) or ``None`` if the 261 | command exited with a nonzero exit code. 262 | """ 263 | self.prepare_environment() 264 | command_name = os.path.basename(command[0]) 265 | user_command = os.path.join(USER_COMMANDS_DIRECTORY, command_name) 266 | builtin_command = os.path.join(BUILTIN_COMMANDS_DIRECTORY, command_name) 267 | command = list(command) 268 | command[0] = user_command if os.path.isfile(user_command) else builtin_command 269 | return execute(*command, input=options.get('input', None), capture=True, logger=logger) 270 | 271 | 272 | class ProtocolError(Exception): 273 | 274 | """Exception that is raised when the communication protocol is violated.""" 275 | 276 | 277 | class RemoteMethodFailed(Exception): 278 | 279 | """Exception that is raised when a remote method call failed.""" 280 | -------------------------------------------------------------------------------- /common/negotiator_common/config.py: -------------------------------------------------------------------------------- 1 | # Scriptable KVM/QEMU guest agent in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 3, 2019 5 | # URL: https://negotiator.readthedocs.org 6 | 7 | """Configuration defaults for the `negotiator` project.""" 8 | 9 | import os 10 | 11 | USER_COMMANDS_DIRECTORY = '/usr/lib/negotiator/commands' 12 | """ 13 | The pathname of the directory containing user defined commands 14 | that 'the other side' can invoke through `negotiator`. 15 | """ 16 | 17 | BUILTIN_COMMANDS_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'scripts') 18 | """The directory with built-in commands (a string).""" 19 | 20 | GUEST_TO_HOST_CHANNEL_NAME = 'negotiator-guest-to-host.0' 21 | """The name of the channel that's used for communication initiated by the guest (a string).""" 22 | 23 | HOST_TO_GUEST_CHANNEL_NAME = 'negotiator-host-to-guest.0' 24 | """The name of the channel that's used for communication initiated by the host (a string).""" 25 | 26 | SUPPORTED_CHANNEL_NAMES = (GUEST_TO_HOST_CHANNEL_NAME, HOST_TO_GUEST_CHANNEL_NAME) 27 | """ 28 | A tuple of strings with supported channel names (containing 29 | :data:`GUEST_TO_HOST_CHANNEL_NAME` and :data:`HOST_TO_GUEST_CHANNEL_NAME`). 30 | """ 31 | 32 | DEFAULT_TIMEOUT = 10 33 | """ 34 | The number of seconds to wait for a reply from the other side (an integer). 35 | 36 | If more time elapses an exception is raised causing the process to exit with a 37 | nonzero status code. 38 | """ 39 | -------------------------------------------------------------------------------- /common/negotiator_common/scripts/find-disk-usage: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # find-disk-usage: 4 | # Find the disk usage of all mounted disk devices. 5 | 6 | df -B 1 | while read device_file bytes_total bytes_used bytes_available percentage_used mount_point; do 7 | if [[ $device_file =~ ^/dev/ ]] && [[ $mount_point =~ ^/ ]]; then 8 | echo $device_file $mount_point $bytes_total $bytes_used 9 | fi 10 | done 11 | -------------------------------------------------------------------------------- /common/negotiator_common/scripts/find-distribution-codename: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # find-distribution-codename: 4 | # Prints the distribution codename (a string like `precise' in the case of 5 | # Ubuntu) using the lsb_release command. 6 | 7 | lsb_release --short --codename 8 | -------------------------------------------------------------------------------- /common/negotiator_common/scripts/find-distribution-release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # find-distribution-release: 4 | # Prints the distribution release (a string like `12.04' in the case of Ubuntu) 5 | # using the lsb_release command. 6 | 7 | lsb_release --short --release 8 | -------------------------------------------------------------------------------- /common/negotiator_common/scripts/find-distributor-id: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # find-distributor-id: 4 | # Prints the distributor ID (a string like `Ubuntu') 5 | # using the lsb_release command. 6 | 7 | lsb_release --short --id 8 | -------------------------------------------------------------------------------- /common/negotiator_common/scripts/find-ip-addresses: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # find-ip-addresses: 4 | # Find the IPv4 addresses in use. Prints one IP address per line in CIDR 5 | # notation. See also: http://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation. 6 | 7 | ip addr show | grep '\' | awk '{print $2}' 8 | -------------------------------------------------------------------------------- /common/negotiator_common/utils.py: -------------------------------------------------------------------------------- 1 | # Scriptable KVM/QEMU guest agent in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: October 11, 2019 5 | # URL: https://negotiator.readthedocs.org 6 | 7 | """Miscellaneous functionality.""" 8 | 9 | # Standard library modules. 10 | import signal 11 | 12 | 13 | def format_call(function, *args, **kw): 14 | """ 15 | Format a Python function call into a human readable string. 16 | 17 | :param function: The name of the function that's called (a string). 18 | :param args: The positional arguments to the function (if any). 19 | :param kw: The keyword arguments to the function (if any). 20 | """ 21 | formatted_arguments = [] 22 | for argument in args: 23 | formatted_arguments.append(repr(argument)) 24 | for keyword, value in kw.items(): 25 | formatted_arguments.append("%s=%r" % (keyword, value)) 26 | return "%s(%s)" % (function, ', '.join(formatted_arguments)) 27 | 28 | 29 | class GracefulShutdown(object): 30 | 31 | """ 32 | Context manager to enable graceful handling of ``SIGTERM``. 33 | 34 | This context manager translates termination signals (``SIGTERM``) into 35 | :class:`TerminationError` exceptions. 36 | """ 37 | 38 | def __enter__(self): 39 | """Start intercepting termination signals.""" 40 | self.previous_handler = signal.signal(signal.SIGTERM, self.signal_handler) 41 | 42 | def __exit__(self, exc_type, exc_value, traceback): 43 | """Stop intercepting termination signals.""" 44 | signal.signal(signal.SIGTERM, self.previous_handler) 45 | 46 | def signal_handler(self, signum, frame): 47 | """Raise :class:`TerminationError` when the timeout elapses.""" 48 | raise TerminationError() 49 | 50 | 51 | class TimeOut(object): 52 | 53 | """Context manager that enforces timeouts using UNIX alarm signals.""" 54 | 55 | def __init__(self, num_seconds): 56 | """ 57 | Initialize the context manager. 58 | 59 | :param num_seconds: The number of seconds after which to interrupt the 60 | running operation (an integer). 61 | """ 62 | self.num_seconds = num_seconds 63 | 64 | def __enter__(self): 65 | """Schedule the timeout.""" 66 | self.previous_handler = signal.signal(signal.SIGALRM, self.signal_handler) 67 | signal.alarm(self.num_seconds) 68 | 69 | def __exit__(self, exc_type, exc_value, traceback): 70 | """Clear the timeout and restore the previous signal handler.""" 71 | signal.alarm(0) 72 | signal.signal(signal.SIGALRM, self.previous_handler) 73 | 74 | def signal_handler(self, signum, frame): 75 | """Raise :class:`TimeOutError` when the timeout elapses.""" 76 | raise TimeOutError() 77 | 78 | 79 | class TerminationError(SystemExit): 80 | 81 | """Exception that is raised when ``SIGTERM`` is received.""" 82 | 83 | 84 | class TimeOutError(Exception): 85 | 86 | """Exception raised by the :class:`TimeOut` context manager.""" 87 | -------------------------------------------------------------------------------- /common/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Setup script for the `negotiator-common' package. 4 | # 5 | # Author: Peter Odding 6 | # Last Change: April 26, 2018 7 | # URL: https://negotiator.readthedocs.org 8 | 9 | """Setup script for the ``negotiator-common`` package.""" 10 | 11 | # Standard library modules. 12 | import os 13 | import re 14 | 15 | # De-facto standard solution for Python packaging. 16 | from setuptools import setup, find_packages 17 | 18 | # Find the directory where the source distribution was unpacked. 19 | source_directory = os.path.dirname(os.path.abspath(__file__)) 20 | 21 | # Find the current version. 22 | module = os.path.join(source_directory, 'negotiator_common', '__init__.py') 23 | for line in open(module, 'r'): 24 | match = re.match(r'^__version__\s*=\s*["\']([^"\']+)["\']$', line) 25 | if match: 26 | version_string = match.group(1) 27 | break 28 | else: 29 | raise Exception("Failed to extract version from %s!" % module) 30 | 31 | # Fill in the long description (for the benefit of PyPI) 32 | # with the contents of README.rst (rendered by GitHub). 33 | try: 34 | readme_file = os.path.join(source_directory, 'README.rst') 35 | readme_text = open(readme_file, 'r').read() 36 | except IOError: 37 | # This happens on readthedocs.org. 38 | readme_text = '' 39 | 40 | setup(name='negotiator-common', 41 | version=version_string, 42 | description="Scriptable KVM/QEMU guest agent (common functionality)", 43 | long_description=readme_text, 44 | url='https://negotiator.readthedocs.org', 45 | author="Peter Odding", 46 | author_email='peter@peterodding.com', 47 | license='MIT', 48 | packages=find_packages(), 49 | include_package_data=True, 50 | install_requires=[ 51 | 'executor >= 1.3', 52 | 'humanfriendly >= 4.12', 53 | ], 54 | classifiers=[ 55 | 'Development Status :: 4 - Beta', 56 | 'Environment :: Console', 57 | 'Intended Audience :: Developers', 58 | 'Intended Audience :: Information Technology', 59 | 'Intended Audience :: System Administrators', 60 | 'License :: OSI Approved :: MIT License', 61 | 'Operating System :: POSIX', 62 | 'Operating System :: POSIX :: Linux', 63 | 'Operating System :: Unix', 64 | 'Programming Language :: Python', 65 | 'Programming Language :: Python :: 2', 66 | 'Programming Language :: Python :: 2.6', 67 | 'Programming Language :: Python :: 2.7', 68 | 'Programming Language :: Python :: 3', 69 | 'Programming Language :: Python :: 3.4', 70 | 'Topic :: Communications', 71 | 'Topic :: Software Development', 72 | 'Topic :: System', 73 | 'Topic :: System :: Installation/Setup', 74 | 'Topic :: System :: Operating System', 75 | 'Topic :: System :: Operating System Kernels :: Linux', 76 | 'Topic :: System :: Systems Administration', 77 | ]) 78 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API documentation 2 | ================= 3 | 4 | The following documentation is based on the source code of version |release| of 5 | the `negotiator` project: 6 | 7 | .. contents:: 8 | :local: 9 | 10 | :mod:`negotiator_host` 11 | ---------------------- 12 | 13 | .. automodule:: negotiator_host 14 | :members: 15 | 16 | :mod:`negotiator_host.cli` 17 | -------------------------- 18 | 19 | .. automodule:: negotiator_host.cli 20 | :members: 21 | 22 | :mod:`negotiator_guest` 23 | ----------------------- 24 | 25 | .. automodule:: negotiator_guest 26 | :members: 27 | 28 | :mod:`negotiator_guest.cli` 29 | --------------------------- 30 | 31 | .. automodule:: negotiator_guest.cli 32 | :members: 33 | 34 | :mod:`negotiator_common` 35 | ------------------------ 36 | 37 | .. automodule:: negotiator_common 38 | :members: 39 | 40 | 41 | :mod:`negotiator_common.config` 42 | ------------------------------- 43 | 44 | .. automodule:: negotiator_common.config 45 | :members: 46 | 47 | :mod:`negotiator_common.utils` 48 | ------------------------------ 49 | 50 | .. automodule:: negotiator_common.utils 51 | :members: 52 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx documentation build configuration file.""" 2 | 3 | import os 4 | import sys 5 | 6 | # Add the host/guest/common directories to the module path. 7 | docs_directory = os.path.dirname(os.path.abspath(__file__)) 8 | for package_directory in ['host', 'guest', 'common']: 9 | sys.path.insert(0, os.path.join(docs_directory, '..', package_directory)) 10 | 11 | # -- General configuration ----------------------------------------------------- 12 | 13 | # Sphinx extension module names. 14 | extensions = [ 15 | 'sphinx.ext.autodoc', 16 | 'sphinx.ext.intersphinx', 17 | 'humanfriendly.sphinx', 18 | ] 19 | 20 | # Paths that contain templates, relative to this directory. 21 | templates_path = ['templates'] 22 | 23 | # The suffix of source filenames. 24 | source_suffix = '.rst' 25 | 26 | # The master toctree document. 27 | master_doc = 'index' 28 | 29 | # General information about the project. 30 | project = 'negotiator' 31 | copyright = '2019, Peter Odding' 32 | 33 | # The version info for the project you're documenting, acts as replacement for 34 | # |version| and |release|, also used in various other places throughout the 35 | # built documents. 36 | 37 | # Find the package version and make it the release. 38 | from negotiator_host import __version__ as negotiator_version # noqa 39 | 40 | # The short X.Y version. 41 | version = '.'.join(negotiator_version.split('.')[:2]) 42 | 43 | # The full version, including alpha/beta/rc tags. 44 | release = negotiator_version 45 | 46 | # The language for content autogenerated by Sphinx. Refer to documentation 47 | # for a list of supported languages. 48 | language = 'en' 49 | 50 | # List of patterns, relative to source directory, that match files and 51 | # directories to ignore when looking for source files. 52 | exclude_patterns = ['build'] 53 | 54 | # If true, '()' will be appended to :func: etc. cross-reference text. 55 | add_function_parentheses = True 56 | 57 | # The name of the Pygments (syntax highlighting) style to use. 58 | pygments_style = 'sphinx' 59 | 60 | # Refer to the Python standard library. 61 | # From: http://twistedmatrix.com/trac/ticket/4582. 62 | intersphinx_mapping = dict( 63 | python2=('https://docs.python.org/2/', None), 64 | python3=('https://docs.python.org/3/', None), 65 | humanfriendly=('https://humanfriendly.readthedocs.io/en/latest/', None), 66 | ) 67 | 68 | # -- Options for HTML output --------------------------------------------------- 69 | 70 | # The theme to use for HTML and HTML Help pages. See the documentation for 71 | # a list of builtin themes. 72 | html_theme = 'nature' 73 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Scriptable KVM/QEMU guest agent implemented in Python 2 | ===================================================== 3 | 4 | Welcome to the documentation of `negotiator` version |release|! The 5 | following sections are available: 6 | 7 | .. contents:: 8 | :local: 9 | 10 | User documentation 11 | ------------------ 12 | 13 | The readme is the best place to start reading, it's targeted at all users and 14 | documents the command line interface: 15 | 16 | .. toctree:: 17 | readme.rst 18 | 19 | API documentation 20 | ----------------- 21 | 22 | The following API documentation is automatically generated from the source code: 23 | 24 | .. toctree:: 25 | api.rst 26 | 27 | Change log 28 | ---------- 29 | 30 | The change log lists notable changes to the project: 31 | 32 | .. toctree:: 33 | changelog.rst 34 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -e ./common 2 | -e ./host 3 | -e ./guest 4 | -------------------------------------------------------------------------------- /guest/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Peter Odding 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /guest/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | -------------------------------------------------------------------------------- /guest/docs: -------------------------------------------------------------------------------- 1 | ../docs -------------------------------------------------------------------------------- /guest/negotiator_guest/__init__.py: -------------------------------------------------------------------------------- 1 | # Scriptable KVM/QEMU guest agent in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: December 11, 2019 5 | # URL: https://negotiator.readthedocs.org 6 | 7 | """ 8 | The guest agent daemon and client. 9 | 10 | This module implements the guest agent, the Python daemon process that's always 11 | running inside KVM/QEMU guests. 12 | """ 13 | 14 | # Standard library modules. 15 | import errno 16 | import fcntl 17 | import itertools 18 | import logging 19 | import multiprocessing 20 | import os 21 | import signal 22 | import sys 23 | import time 24 | 25 | # External dependencies. 26 | from humanfriendly import Timer, compact 27 | 28 | # Modules included in our project. 29 | from negotiator_common import NegotiatorInterface 30 | from negotiator_common.utils import GracefulShutdown 31 | 32 | # Semi-standard module versioning. 33 | __version__ = '0.12.2' 34 | 35 | # Initialize a logger for this module. 36 | logger = logging.getLogger(__name__) 37 | 38 | 39 | class GuestAgent(NegotiatorInterface): 40 | 41 | """Implementation of the daemon running inside KVM/QEMU guests.""" 42 | 43 | def __init__(self, character_device, retry=False): 44 | """ 45 | Initialize a negotiator guest agent. 46 | 47 | :param character_device: The absolute pathname of the character device 48 | that we should use to connect to the host (a 49 | string). 50 | :param retry: :data:`True` to retry ``EBUSY`` errors, :data:`False` 51 | otherwise (defaults to :data:`False`). 52 | 53 | .. note:: When ``retry`` is :data:`True` it is (somewhat theoretically) 54 | possible for infinite retrying to cause control to never be 55 | returned to the caller. This is why callers are expected to 56 | use :class:`~negotiator_common.utils.TimeOut` or a similar 57 | solution. 58 | """ 59 | custom_open = self.retry_open if retry else open 60 | super(GuestAgent, self).__init__( 61 | handle=custom_open(character_device, 'r+'), 62 | label="character device %s" % character_device, 63 | ) 64 | 65 | def retry_open(self, character_device, mode): 66 | """Open the character device and retry ``EBUSY`` errors.""" 67 | while True: 68 | try: 69 | return open(character_device, mode) 70 | except EnvironmentError as e: 71 | if e.errno == errno.EBUSY: 72 | logger.debug("Retrying access to %s after EBUSY error ..", character_device) 73 | time.sleep(1) 74 | else: 75 | raise 76 | 77 | def raw_readline(self): 78 | """ 79 | Read a newline terminated string from the remote side. 80 | 81 | This method overrides the 82 | :func:`~negotiator_common.NegotiatorInterface.raw_readline()` method 83 | of the :func:`~negotiator_common.NegotiatorInterface` class to 84 | implement blocking reads based on :data:`os.O_ASYNC` and 85 | :data:`signal.SIGIO` (see also :class:`WaitForRead`). 86 | 87 | :returns: The data read from the remote side (a string). 88 | """ 89 | while True: 90 | # Check if the channel contains data. 91 | logger.debug("Preparing to read line from %s ..", self.conn_label) 92 | data = self.conn_handle.readline() 93 | if data: 94 | break 95 | # If the readline() above returns an empty string the channel 96 | # is (probably) not connected. At this point we'll bother to 97 | # prepare a convoluted way to block until the channel does 98 | # become connected. 99 | logger.debug("Got an empty read, emulating blocking read of %s ..", self.conn_label) 100 | # Set the O_ASYNC flag on the file descriptor connected to the 101 | # character device (this is required to use SIGIO signals). 102 | flags = fcntl.fcntl(self.conn_handle, fcntl.F_GETFL) 103 | fcntl.fcntl(self.conn_handle, fcntl.F_SETFL, flags | os.O_ASYNC) 104 | # Spawn a subprocess to reliably handle SIGIO signals. Due to the 105 | # nature of (SIGIO) signals more than one signal may be delivered 106 | # and this is a big problem when you want to do more than just call 107 | # sys.exit(). The alternative to this would be signal.pause() but 108 | # that function has an inherent race condition. To fix that race 109 | # condition there is sigsuspend() but this function is not 110 | # available in the Python standard library. 111 | waiter = WaitForRead() 112 | # If we get killed we need to make sure we take the subprocess 113 | # down with us, otherwise the subprocess may still be reading 114 | # from the character device when we are restarted and that's a 115 | # problem because the character device doesn't allow multiple 116 | # readers; all but the first reader will get the error 117 | # `IOError: [Errno 16] Device or resource busy'. 118 | with GracefulShutdown(): 119 | try: 120 | # Start the subprocess. 121 | waiter.start() 122 | # Connect the file descriptor to the subprocess. 123 | fcntl.fcntl(self.conn_handle, fcntl.F_SETOWN, waiter.pid) 124 | # The channel may have become connected after we last got an empty 125 | # read but before we spawned our subprocess, so check one more 126 | # time to make sure. 127 | data = self.conn_handle.readline() 128 | if data: 129 | break 130 | # If there is still no data available we'll wait for the 131 | # subprocess to indicate that data has become available. 132 | waiter.join() 133 | # Let's see if the subprocess is right :-) 134 | data = self.conn_handle.readline() 135 | if data: 136 | break 137 | finally: 138 | logger.debug("Terminating subprocess with process id %i ..", waiter.pid) 139 | waiter.terminate() 140 | # If the convoluted way to simulate blocking reads above ever 141 | # fails we don't want this method to turn into a `busy loop'. 142 | logger.debug("Blocking read emulation seems to have failed, falling back to 1 second polling interval ..") 143 | time.sleep(1) 144 | logger.debug("Read %i bytes from %s: %r", len(data), self.conn_label, data) 145 | return data 146 | 147 | 148 | class WaitForRead(multiprocessing.Process): 149 | 150 | """Used by :func:`GuestAgent.raw_readline()` to implement blocking reads.""" 151 | 152 | def run(self): 153 | """Endless loop that waits for one or more ``SIGIO`` signals to arrive.""" 154 | logger.debug("Installing SIGIO signal handler ..") 155 | signal.signal(signal.SIGIO, self.signal_handler) 156 | timer = Timer() 157 | for seconds in itertools.count(): 158 | logger.debug("Waiting for SIGIO signal (%s) ..", timer) 159 | time.sleep(seconds) 160 | 161 | def signal_handler(self, signal_number, frame): 162 | """Signal handler for ``SIGIO`` signals that immediately exits the process.""" 163 | sys.exit(0) 164 | 165 | 166 | def find_character_device(port_name): 167 | """ 168 | Find the character device for the given port name. 169 | 170 | :param port_name: The name of the virtio port (a string). 171 | :returns: The absolute pathname of a character device (a string). 172 | :raises: :exc:`Exception` when the character device cannot be found. 173 | """ 174 | root = '/sys/class/virtio-ports' 175 | logger.debug("Automatically selecting appropriate character device based on %s ..", root) 176 | for entry in os.listdir(root): 177 | name_file = os.path.join(root, entry, 'name') 178 | if os.path.isfile(name_file): 179 | with open(name_file) as handle: 180 | contents = handle.read().strip() 181 | if contents == port_name: 182 | character_device = '/dev/%s' % entry 183 | logger.debug("Selected character device: %s", character_device) 184 | return character_device 185 | raise Exception(compact(""" 186 | Failed to select the appropriate character device for the port name 187 | {name}! This is probably caused by a configuration issue on either the 188 | QEMU host or inside the QEMU guest. Please refer to the following web 189 | page for help: http://negotiator.readthedocs.org/en/latest/#character-device-detection-fails 190 | """, name=repr(port_name))) 191 | -------------------------------------------------------------------------------- /guest/negotiator_guest/cli.py: -------------------------------------------------------------------------------- 1 | # Scriptable KVM/QEMU guest agent in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: December 9, 2019 5 | # URL: https://negotiator.readthedocs.org 6 | 7 | """ 8 | Usage: negotiator-guest [OPTIONS] 9 | 10 | Communicate from a KVM/QEMU guest system to its host or start the 11 | guest daemon to allow the host to execute commands on its guests. 12 | 13 | Supported options: 14 | 15 | -l, --list-commands 16 | 17 | List the commands that the host exposes to its guests. 18 | 19 | -e, --execute=COMMAND 20 | 21 | Execute the given command on the KVM/QEMU host. The standard output stream 22 | of the command on the host is intercepted and copied to the standard output 23 | stream on the guest. If the command exits with a nonzero status code the 24 | negotiator-guest program will also exit with a nonzero status code. 25 | 26 | -d, --daemon 27 | 28 | Start the guest daemon. When using this command line option the 29 | `negotiator-guest' program never returns (unless an unexpected error 30 | condition occurs). 31 | 32 | -t, --timeout=SECONDS 33 | 34 | Set the number of seconds before a remote call without a response times 35 | out. A value of zero disables the timeout (in this case the command can 36 | hang indefinitely). The default is 10 seconds. 37 | 38 | -c, --character-device=PATH 39 | 40 | By default the appropriate character device is automatically selected based 41 | on /sys/class/virtio-ports/*/name. If the automatic selection doesn't work, 42 | you can set the absolute pathname of the character device that's used to 43 | communicate with the negotiator-host daemon running on the KVM/QEMU host. 44 | 45 | -v, --verbose 46 | 47 | Increase logging verbosity (can be repeated). 48 | 49 | -q, --quiet 50 | 51 | Decrease logging verbosity (can be repeated). 52 | 53 | -h, --help 54 | 55 | Show this message and exit. 56 | """ 57 | 58 | # Standard library modules. 59 | import getopt 60 | import logging 61 | import shlex 62 | import sys 63 | 64 | # External dependencies. 65 | import coloredlogs 66 | from humanfriendly import Timer 67 | from humanfriendly.terminal import usage, warning 68 | 69 | # Modules included in our project. 70 | from negotiator_common.config import GUEST_TO_HOST_CHANNEL_NAME, HOST_TO_GUEST_CHANNEL_NAME, DEFAULT_TIMEOUT 71 | from negotiator_common.utils import TimeOut 72 | from negotiator_guest import GuestAgent, find_character_device 73 | 74 | # Initialize a logger for this module. 75 | logger = logging.getLogger(__name__) 76 | 77 | 78 | def main(): 79 | """Command line interface for the ``negotiator-guest`` program.""" 80 | # Initialize logging to the terminal and system log. 81 | coloredlogs.install(syslog=True) 82 | # Parse the command line arguments. 83 | list_commands = False 84 | execute_command = None 85 | start_daemon = False 86 | timeout = DEFAULT_TIMEOUT 87 | character_device = None 88 | try: 89 | options, arguments = getopt.getopt(sys.argv[1:], 'le:dt:c:vqh', [ 90 | 'list-commands', 'execute=', 'daemon', 'timeout=', 91 | 'character-device=', 'verbose', 'quiet', 'help' 92 | ]) 93 | for option, value in options: 94 | if option in ('-l', '--list-commands'): 95 | list_commands = True 96 | elif option in ('-e', '--execute'): 97 | execute_command = value 98 | elif option in ('-d', '--daemon'): 99 | start_daemon = True 100 | elif option in ('-t', '--timeout'): 101 | timeout = int(value) 102 | elif option in ('-c', '--character-device'): 103 | character_device = value 104 | elif option in ('-v', '--verbose'): 105 | coloredlogs.increase_verbosity() 106 | elif option in ('-q', '--quiet'): 107 | coloredlogs.decrease_verbosity() 108 | elif option in ('-h', '--help'): 109 | usage(__doc__) 110 | sys.exit(0) 111 | if not (list_commands or execute_command or start_daemon): 112 | usage(__doc__) 113 | sys.exit(0) 114 | except Exception: 115 | warning("Error: Failed to parse command line arguments!") 116 | sys.exit(1) 117 | # Start the guest daemon. 118 | try: 119 | if not character_device: 120 | channel_name = HOST_TO_GUEST_CHANNEL_NAME if start_daemon else GUEST_TO_HOST_CHANNEL_NAME 121 | character_device = find_character_device(channel_name) 122 | if start_daemon: 123 | agent = GuestAgent(character_device=character_device, retry=False) 124 | agent.enter_main_loop() 125 | elif list_commands: 126 | with TimeOut(timeout): 127 | agent = GuestAgent(character_device, retry=True) 128 | print('\n'.join(agent.call_remote_method('list_commands'))) 129 | elif execute_command: 130 | with TimeOut(timeout): 131 | timer = Timer() 132 | agent = GuestAgent(character_device, retry=True) 133 | output = agent.call_remote_method('execute', *shlex.split(execute_command), capture=True) 134 | logger.debug("Took %s to execute remote command.", timer) 135 | print(output.rstrip()) 136 | except Exception: 137 | logger.exception("Caught a fatal exception! Terminating ..") 138 | sys.exit(1) 139 | -------------------------------------------------------------------------------- /guest/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Setup script for the `negotiator-guest' package. 4 | # 5 | # Author: Peter Odding 6 | # Last Change: December 9, 2019 7 | # URL: https://negotiator.readthedocs.org 8 | 9 | """Setup script for the ``negotiator-guest`` package.""" 10 | 11 | # Standard library modules. 12 | import os 13 | import re 14 | 15 | # De-facto standard solution for Python packaging. 16 | from setuptools import setup, find_packages 17 | 18 | # Find the directory where the source distribution was unpacked. 19 | source_directory = os.path.dirname(os.path.abspath(__file__)) 20 | 21 | # Find the current version. 22 | module = os.path.join(source_directory, 'negotiator_guest', '__init__.py') 23 | for line in open(module, 'r'): 24 | match = re.match(r'^__version__\s*=\s*["\']([^"\']+)["\']$', line) 25 | if match: 26 | version_string = match.group(1) 27 | break 28 | else: 29 | raise Exception("Failed to extract version from %s!" % module) 30 | 31 | # Fill in the long description (for the benefit of PyPI) 32 | # with the contents of README.rst (rendered by GitHub). 33 | try: 34 | readme_file = os.path.join(source_directory, 'README.rst') 35 | readme_text = open(readme_file, 'r').read() 36 | except IOError: 37 | # This happens on readthedocs.org. 38 | readme_text = '' 39 | 40 | setup(name='negotiator-guest', 41 | version=version_string, 42 | description="Scriptable KVM/QEMU guest agent (guest side of things)", 43 | long_description=readme_text, 44 | url='https://negotiator.readthedocs.org', 45 | author="Peter Odding", 46 | author_email='peter@peterodding.com', 47 | license='MIT', 48 | packages=find_packages(), 49 | entry_points=dict(console_scripts=[ 50 | 'negotiator-guest = negotiator_guest.cli:main' 51 | ]), 52 | install_requires=[ 53 | 'coloredlogs >= 5.0', 54 | 'negotiator-common >= 0.12.2', 55 | ], 56 | classifiers=[ 57 | 'Development Status :: 4 - Beta', 58 | 'Environment :: Console', 59 | 'Intended Audience :: Developers', 60 | 'Intended Audience :: Information Technology', 61 | 'Intended Audience :: System Administrators', 62 | 'License :: OSI Approved :: MIT License', 63 | 'Operating System :: POSIX', 64 | 'Operating System :: POSIX :: Linux', 65 | 'Operating System :: Unix', 66 | 'Programming Language :: Python', 67 | 'Programming Language :: Python :: 2', 68 | 'Programming Language :: Python :: 2.6', 69 | 'Programming Language :: Python :: 2.7', 70 | 'Programming Language :: Python :: 3', 71 | 'Programming Language :: Python :: 3.4', 72 | 'Topic :: Communications', 73 | 'Topic :: Software Development', 74 | 'Topic :: System', 75 | 'Topic :: System :: Installation/Setup', 76 | 'Topic :: System :: Operating System', 77 | 'Topic :: System :: Operating System Kernels :: Linux', 78 | 'Topic :: System :: Systems Administration', 79 | ]) 80 | -------------------------------------------------------------------------------- /host/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Peter Odding 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /host/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | -------------------------------------------------------------------------------- /host/docs: -------------------------------------------------------------------------------- 1 | ../docs -------------------------------------------------------------------------------- /host/negotiator_host/__init__.py: -------------------------------------------------------------------------------- 1 | # Scriptable KVM/QEMU guest agent in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: December 9, 2019 5 | # URL: https://negotiator.readthedocs.org 6 | 7 | """ 8 | Channel for communication with guests. 9 | 10 | This module implements the :class:`GuestChannel` class which provides the 11 | host side of the channel between QEMU hosts and guests. Channel objects can be 12 | used to query and command running guests. 13 | """ 14 | 15 | # Standard library modules. 16 | import logging 17 | import multiprocessing 18 | import os 19 | import socket 20 | import time 21 | import xml.etree.ElementTree 22 | 23 | # Modules included in our project. 24 | from negotiator_common import NegotiatorInterface 25 | from negotiator_common.config import GUEST_TO_HOST_CHANNEL_NAME, HOST_TO_GUEST_CHANNEL_NAME, SUPPORTED_CHANNEL_NAMES 26 | from negotiator_common.utils import GracefulShutdown 27 | 28 | # External dependencies. 29 | from executor import ExternalCommandFailed, execute 30 | 31 | # Semi-standard module versioning. 32 | __version__ = '0.12.2' 33 | 34 | # Initialize a logger for this module. 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | class HostDaemon(object): 39 | 40 | """The host daemon automatically manages a group of processes that handle "guest to host" calls.""" 41 | 42 | def __init__(self): 43 | """Initialize the host daemon.""" 44 | self.workers = {} 45 | self.guests_to_ignore = set() 46 | self.enter_main_loop() 47 | 48 | def enter_main_loop(self): 49 | """Create and maintain active channels for all running guests.""" 50 | with GracefulShutdown(): 51 | try: 52 | while True: 53 | self.update_workers() 54 | time.sleep(10) 55 | finally: 56 | for channel in self.workers.values(): 57 | channel.terminate() 58 | 59 | def update_workers(self): 60 | """Automatically spawn subprocesses (workers) to maintain connections to all guests.""" 61 | logger.debug("Synchronizing workers to channels ..") 62 | running_guests = set(find_running_guests()) 63 | self.cleanup_workers(running_guests) 64 | self.spawn_workers(running_guests) 65 | 66 | def cleanup_workers(self, running_guests): 67 | """Cleanup crashed workers and workers for guests that are no longer running.""" 68 | for guest_name in list(self.workers.keys()): 69 | # Check for and cleanup crashed workers. 70 | if not self.workers[guest_name].is_alive(): 71 | logger.warning("[%s] Cleaning up crashed worker ..", guest_name) 72 | self.workers.pop(guest_name) 73 | # Check for and terminate workers for guests that are no longer running. 74 | if guest_name not in running_guests: 75 | logger.info("[%s] Terminating worker because guest is no longer running ..", guest_name) 76 | self.workers[guest_name].terminate() 77 | self.workers.pop(guest_name) 78 | 79 | def spawn_workers(self, running_guests): 80 | """Spawn new workers on demand (ignoring guests known not to support negotiator).""" 81 | for guest_name in sorted(running_guests - self.guests_to_ignore): 82 | if guest_name not in self.workers: 83 | available_channels = find_channels_of_guest(guest_name) 84 | if GUEST_TO_HOST_CHANNEL_NAME in available_channels: 85 | logger.info("[%s] Initializing worker for guest ..", guest_name) 86 | self.workers[guest_name] = AutomaticGuestChannel( 87 | guest_name=guest_name, unix_socket=available_channels[GUEST_TO_HOST_CHANNEL_NAME], 88 | ) 89 | self.workers[guest_name].start() 90 | else: 91 | # Don't keep running 'virsh dumpxml' for this guest when we 92 | # know that it is not configured to support negotiator. 93 | logger.info("[%s] Doesn't support negotiator, adding to ignore list ..", guest_name) 94 | self.guests_to_ignore.add(guest_name) 95 | 96 | 97 | class AutomaticGuestChannel(multiprocessing.Process): 98 | 99 | """ 100 | Thin wrapper for :class:`GuestChannel` that puts it in a separate process. 101 | 102 | Uses :class:`multiprocessing.Process` to isolate guest channels in 103 | separate processes. 104 | """ 105 | 106 | def __init__(self, guest_name, unix_socket): 107 | """ 108 | Initialize a :class:`GuestChannel` in a separate process. 109 | 110 | :param guest_name: The name of the guest to connect to (a string). 111 | :param unix_socket: The absolute pathname of the UNIX socket that we 112 | should connect to (a string). 113 | """ 114 | # Initialize the super class. 115 | super(AutomaticGuestChannel, self).__init__() 116 | # Store the arguments to the constructor. 117 | self.guest_name = guest_name 118 | self.unix_socket = unix_socket 119 | 120 | def run(self): 121 | """Start the main loop of the common negotiator interface.""" 122 | try: 123 | # Initialize the guest to host channel. 124 | channel = GuestChannel(self.guest_name, self.unix_socket) 125 | # Wait for messages from the other side. 126 | channel.enter_main_loop() 127 | except GuestChannelInitializationError: 128 | # We know what the reason is here, so there's no need to log a noisy traceback. 129 | logger.error("[%s] Failed to initialize channel to guest! (worker will respawn in a bit)", self.guest_name) 130 | except Exception: 131 | # Unhandled exceptions get a traceback in the log output to make it easier to debug problems. 132 | logger.exception( 133 | "[%s] Caught exception while connecting to guest! (worker will respawn in a bit)", 134 | self.guest_name, 135 | ) 136 | 137 | 138 | class GuestChannel(NegotiatorInterface): 139 | 140 | """ 141 | The host side of the channel connecting KVM/QEMU hosts and guests. 142 | 143 | See also :class:`AutomaticGuestChannel` which wraps 144 | :class:`GuestChannel` and puts it in its own process. 145 | """ 146 | 147 | def __init__(self, guest_name, unix_socket=None): 148 | """ 149 | Initialize a negotiator host agent. 150 | 151 | :param guest_name: The name of the guest to connect to (a string). 152 | :param unix_socket: The absolute pathname of the UNIX socket that we 153 | should connect to (a string, optional). 154 | """ 155 | self.guest_name = guest_name 156 | # Figure out the pathname of the UNIX socket? 157 | if not unix_socket: 158 | available_channels = find_channels_of_guest(guest_name) 159 | if HOST_TO_GUEST_CHANNEL_NAME in available_channels: 160 | logger.debug("[%s] Found UNIX socket using channel discovery.", self.guest_name) 161 | unix_socket = available_channels[HOST_TO_GUEST_CHANNEL_NAME] 162 | else: 163 | msg = "No UNIX socket pathname provided and auto-detection failed!" 164 | raise GuestChannelInitializationError(msg) 165 | # Connect to the UNIX socket. 166 | logger.debug("[%s] Opening UNIX socket (%s) ..", self.guest_name, unix_socket) 167 | self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 168 | try: 169 | logger.debug("[%s] Connecting to UNIX socket ..", self.guest_name) 170 | self.socket.connect(unix_socket) 171 | except Exception: 172 | raise GuestChannelInitializationError("Guest refused connection attempt!") 173 | logger.debug("[%s] Successfully connected to UNIX socket!", self.guest_name) 174 | # Initialize the super class, passing it a file like object connected 175 | # to the character device in read/write mode. 176 | super(GuestChannel, self).__init__(handle=self.socket.makefile(), 177 | label="UNIX socket %s" % unix_socket) 178 | 179 | def prepare_environment(self): 180 | """ 181 | Prepare environment variables for command execution on KVM/QEMU hosts. 182 | 183 | The following environment variables are currently exposed to commands: 184 | 185 | ``$NEGOTIATOR_GUEST`` 186 | The name of the KVM/QEMU guest that invoked the command. 187 | """ 188 | os.environ['NEGOTIATOR_GUEST'] = self.guest_name 189 | 190 | 191 | class GuestChannelInitializationError(Exception): 192 | 193 | """Exception raised by :class:`GuestChannel` when socket initialization fails.""" 194 | 195 | 196 | class GuestDiscoveryError(Exception): 197 | 198 | """Exception raised by :func:`find_running_guests()` when ``virsh list`` fails.""" 199 | 200 | 201 | def find_supported_guests(): 202 | """ 203 | Find guests supporting the negotiator interface. 204 | 205 | :returns: A generator of strings with guest names. 206 | 207 | This function uses :func:`find_running_guests()` to determine which guests 208 | are currently running and then uses :func:`find_channels_of_guest()` to 209 | determine which guests support the negotiator interface. 210 | """ 211 | for guest_name in sorted(find_running_guests()): 212 | matches = find_channels_of_guest(guest_name) 213 | if HOST_TO_GUEST_CHANNEL_NAME in matches: 214 | yield guest_name 215 | 216 | 217 | def find_channels_of_guest(guest_name): 218 | """ 219 | Find the pathnames of the channels associated to a guest. 220 | 221 | :param guest_name: The name of the guest (a string). 222 | :returns: A dictionary with channel names (strings) as keys and pathnames 223 | of UNIX socket files (strings) as values. If no channels are 224 | detected an empty dictionary will be returned. 225 | 226 | This function uses ``virsh dumpxml`` and parses the XML output to 227 | determine the pathnames of the channels associated to the guest. 228 | """ 229 | logger.debug("Discovering '%s' channels using 'virsh dumpxml' command ..", guest_name) 230 | domain_xml = execute('virsh', 'dumpxml', guest_name, capture=True) 231 | parsed_xml = xml.etree.ElementTree.fromstring(domain_xml) 232 | channels = {} 233 | for channel in parsed_xml.findall('devices/channel'): 234 | if channel.attrib.get('type') == 'unix': 235 | source = channel.find('source') 236 | target = channel.find('target') 237 | if source is not None and target is not None and target.attrib.get('type') == 'virtio': 238 | name = target.attrib.get('name') 239 | path = source.attrib.get('path') 240 | if name in SUPPORTED_CHANNEL_NAMES: 241 | channels[name] = path 242 | if channels: 243 | logger.debug("Discovered '%s' channels: %s", guest_name, channels) 244 | else: 245 | logger.debug("No channels found for guest '%s'.", guest_name) 246 | return channels 247 | 248 | 249 | def find_running_guests(): 250 | """ 251 | Find the names of the guests running on the current host. 252 | 253 | This function parses the output of the ``virsh list`` command instead of 254 | using the libvirt API because of two reasons: 255 | 256 | 1. I'm under the impression that the libvirt API is still very much in flux 257 | and large changes are still being made, so it's not the most stable 258 | foundation for Negotiator to find running guests. 259 | 260 | 2. The Python libvirt API needs to match the version of the libvirt API on 261 | the host system and there is AFAIK no obvious way to express this in the 262 | ``setup.py`` script of Negotiator. 263 | 264 | :returns: A generator of strings. 265 | :raises: :exc:`GuestDiscoveryError` when ``virsh list`` fails. 266 | """ 267 | try: 268 | logger.debug("Discovering running guests using 'virsh list' command ..") 269 | output = execute('virsh', '--quiet', 'list', '--all', capture=True, logger=logger) 270 | except ExternalCommandFailed: 271 | raise GuestDiscoveryError("The 'virsh list' command failed! Most likely libvirtd isn't running...") 272 | else: 273 | for line in output.splitlines(): 274 | logger.debug("Parsing 'virsh list' output: %r", line) 275 | try: 276 | vm_id, vm_name, vm_status = line.split(None, 2) 277 | if vm_status == 'running': 278 | yield vm_name 279 | except Exception: 280 | logger.warning("Failed to parse 'virsh list' output! (%r)", line) 281 | -------------------------------------------------------------------------------- /host/negotiator_host/cli.py: -------------------------------------------------------------------------------- 1 | # Scriptable KVM/QEMU guest agent in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 3, 2019 5 | # URL: https://negotiator.readthedocs.org 6 | 7 | """ 8 | Usage: negotiator-host [OPTIONS] GUEST_NAME 9 | 10 | Communicate from a KVM/QEMU host system with running guest systems using a 11 | guest agent daemon running inside the guests. 12 | 13 | Supported options: 14 | 15 | -g, --list-guests 16 | 17 | List the names of the guests that have the appropriate channel. 18 | 19 | -c, --list-commands 20 | 21 | List the commands that the guest exposes to its host. 22 | 23 | -e, --execute=COMMAND 24 | 25 | Execute the given command inside GUEST_NAME. The standard output stream of 26 | the command inside the guest is intercepted and copied to the standard 27 | output stream on the host. If the command exits with a nonzero status code 28 | the negotiator-host program will also exit with a nonzero status code. 29 | 30 | -t, --timeout=SECONDS 31 | 32 | Set the number of seconds before a remote call without a response times 33 | out. A value of zero disables the timeout (in this case the command can 34 | hang indefinitely). The default is 10 seconds. 35 | 36 | -d, --daemon 37 | 38 | Start the host daemon that answers real time requests from guests. 39 | 40 | -v, --verbose 41 | 42 | Increase logging verbosity (can be repeated). 43 | 44 | -q, --quiet 45 | 46 | Decrease logging verbosity (can be repeated). 47 | 48 | -h, --help 49 | 50 | Show this message and exit. 51 | """ 52 | 53 | # Standard library modules. 54 | import functools 55 | import getopt 56 | import logging 57 | import shlex 58 | import sys 59 | 60 | # External dependencies. 61 | import coloredlogs 62 | from humanfriendly import Timer 63 | from humanfriendly.terminal import usage, warning 64 | 65 | # Modules included in our project. 66 | from negotiator_common.config import DEFAULT_TIMEOUT 67 | from negotiator_common.utils import TimeOut 68 | from negotiator_host import GuestDiscoveryError, HostDaemon, GuestChannel, find_supported_guests 69 | 70 | # Initialize a logger for this module. 71 | logger = logging.getLogger(__name__) 72 | 73 | 74 | def main(): 75 | """Command line interface for the ``negotiator-host`` program.""" 76 | # Initialize logging to the terminal and system log. 77 | coloredlogs.install(syslog=True) 78 | # Parse the command line arguments. 79 | actions = [] 80 | context = Context() 81 | try: 82 | options, arguments = getopt.getopt(sys.argv[1:], 'gce:t:dvqh', [ 83 | 'list-guests', 'list-commands', 'execute=', 'timeout=', 'daemon', 84 | 'verbose', 'quiet', 'help' 85 | ]) 86 | for option, value in options: 87 | if option in ('-g', '--list-guests'): 88 | actions.append(context.print_guest_names) 89 | elif option in ('-c', '--list-commands'): 90 | assert len(arguments) == 1, \ 91 | "Please provide the name of a guest as the 1st and only positional argument!" 92 | actions.append(functools.partial(context.print_commands, arguments[0])) 93 | elif option in ('-e', '--execute'): 94 | assert len(arguments) == 1, \ 95 | "Please provide the name of a guest as the 1st and only positional argument!" 96 | actions.append(functools.partial(context.execute_command, arguments[0], value)) 97 | elif option in ('-t', '--timeout'): 98 | context.timeout = int(value) 99 | elif option in ('-d', '--daemon'): 100 | actions.append(HostDaemon) 101 | elif option in ('-v', '--verbose'): 102 | coloredlogs.increase_verbosity() 103 | elif option in ('-q', '--quiet'): 104 | coloredlogs.decrease_verbosity() 105 | elif option in ('-h', '--help'): 106 | usage(__doc__) 107 | sys.exit(0) 108 | if not actions: 109 | usage(__doc__) 110 | sys.exit(0) 111 | except Exception: 112 | warning("Failed to parse command line arguments!") 113 | sys.exit(1) 114 | # Execute the requested action(s). 115 | try: 116 | for action in actions: 117 | action() 118 | except GuestDiscoveryError as e: 119 | # Don't spam the logs with tracebacks when the libvirt daemon is down. 120 | logger.error("%s", e) 121 | sys.exit(1) 122 | except Exception: 123 | # Do log a traceback for `unexpected' exceptions. 124 | logger.exception("Caught a fatal exception! Terminating ..") 125 | sys.exit(1) 126 | 127 | 128 | class Context(object): 129 | 130 | """Enables :func:`main()` to inject a custom timeout into partially applied actions.""" 131 | 132 | def __init__(self): 133 | """Initialize a context for executing commands on the host.""" 134 | self.timeout = DEFAULT_TIMEOUT 135 | 136 | def print_guest_names(self): 137 | """Print the names of the guests that Negotiator can connect with.""" 138 | guests = find_supported_guests() 139 | if guests: 140 | print('\n'.join(sorted(guests))) 141 | 142 | def print_commands(self, guest_name): 143 | """Print the commands supported by the guest.""" 144 | with TimeOut(self.timeout): 145 | channel = GuestChannel(guest_name=guest_name) 146 | print('\n'.join(sorted(channel.call_remote_method('list_commands')))) 147 | 148 | def execute_command(self, guest_name, command_line): 149 | """Execute a command inside the named guest.""" 150 | with TimeOut(self.timeout): 151 | timer = Timer() 152 | channel = GuestChannel(guest_name=guest_name) 153 | output = channel.call_remote_method('execute', *shlex.split(command_line), capture=True) 154 | logger.debug("Took %s to execute remote command.", timer) 155 | print(output.rstrip()) 156 | -------------------------------------------------------------------------------- /host/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Setup script for the `negotiator-host' package. 4 | # 5 | # Author: Peter Odding 6 | # Last Change: December 9, 2019 7 | # URL: https://negotiator.readthedocs.org 8 | 9 | """Setup script for the ``negotiator-host`` package.""" 10 | 11 | # Standard library modules. 12 | import os 13 | import re 14 | 15 | # De-facto standard solution for Python packaging. 16 | from setuptools import setup, find_packages 17 | 18 | # Find the directory where the source distribution was unpacked. 19 | source_directory = os.path.dirname(os.path.abspath(__file__)) 20 | 21 | # Find the current version. 22 | module = os.path.join(source_directory, 'negotiator_host', '__init__.py') 23 | for line in open(module, 'r'): 24 | match = re.match(r'^__version__\s*=\s*["\']([^"\']+)["\']$', line) 25 | if match: 26 | version_string = match.group(1) 27 | break 28 | else: 29 | raise Exception("Failed to extract version from %s!" % module) 30 | 31 | # Fill in the long description (for the benefit of PyPI) 32 | # with the contents of README.rst (rendered by GitHub). 33 | try: 34 | readme_file = os.path.join(source_directory, 'README.rst') 35 | readme_text = open(readme_file, 'r').read() 36 | except IOError: 37 | # This happens on readthedocs.org. 38 | readme_text = '' 39 | 40 | setup(name='negotiator-host', 41 | version=version_string, 42 | description="Scriptable KVM/QEMU guest agent (host side of things)", 43 | long_description=readme_text, 44 | url='https://negotiator.readthedocs.org', 45 | author="Peter Odding", 46 | author_email='peter@peterodding.com', 47 | license='MIT', 48 | packages=find_packages(), 49 | entry_points=dict(console_scripts=[ 50 | 'negotiator-host = negotiator_host.cli:main' 51 | ]), 52 | install_requires=[ 53 | 'coloredlogs >= 5.0', 54 | 'negotiator-common >= 0.12.2', 55 | ], 56 | classifiers=[ 57 | 'Development Status :: 4 - Beta', 58 | 'Environment :: Console', 59 | 'Intended Audience :: Developers', 60 | 'Intended Audience :: Information Technology', 61 | 'Intended Audience :: System Administrators', 62 | 'License :: OSI Approved :: MIT License', 63 | 'Operating System :: POSIX', 64 | 'Operating System :: POSIX :: Linux', 65 | 'Operating System :: Unix', 66 | 'Programming Language :: Python', 67 | 'Programming Language :: Python :: 2', 68 | 'Programming Language :: Python :: 2.6', 69 | 'Programming Language :: Python :: 2.7', 70 | 'Programming Language :: Python :: 3', 71 | 'Programming Language :: Python :: 3.4', 72 | 'Topic :: Communications', 73 | 'Topic :: Software Development', 74 | 'Topic :: System', 75 | 'Topic :: System :: Installation/Setup', 76 | 'Topic :: System :: Operating System', 77 | 'Topic :: System :: Operating System Kernels :: Linux', 78 | 'Topic :: System :: Systems Administration', 79 | ]) 80 | -------------------------------------------------------------------------------- /requirements-checks.txt: -------------------------------------------------------------------------------- 1 | # Python packages required to run `make check'. 2 | flake8 >= 2.6.0 3 | flake8-docstrings >= 0.2.8 4 | pyflakes >= 1.2.3 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | --------------------------------------------------------------------------------