├── .arcconfig ├── .arclint ├── .gitignore ├── LICENSE ├── README.rst ├── conf ├── 99-testcloud-nonroot-libvirt-access.rules ├── domain-template.jinja └── settings-example.py ├── docs ├── LICENSE ├── Makefile └── source │ ├── api.rst │ ├── conf.py │ ├── indepth.rst │ └── index.rst ├── requirements.txt ├── run_testcloud.py ├── setup.py ├── test ├── test_cli.py ├── test_config.py ├── test_image.py └── test_instance.py ├── testcloud ├── __init__.py ├── cli.py ├── config.py ├── exceptions.py ├── image.py ├── instance.py └── util.py └── tox.ini /.arcconfig: -------------------------------------------------------------------------------- 1 | { 2 | "project_id" : "testCloud", 3 | "conduit_uri" : "https://phab.qa.fedoraproject.org", 4 | "arc.land.onto.default" : "dev", 5 | "arc.feature.start.default" : "dev", 6 | "unit.engine" : "PytestTestEngine" 7 | } 8 | -------------------------------------------------------------------------------- /.arclint: -------------------------------------------------------------------------------- 1 | { 2 | "linters": { 3 | "flake8": { 4 | "type": "flake8", 5 | "include": "(\\.py$)", 6 | "exclude": "(^conf/)" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compile and editor files 2 | *.py[co] 3 | *~ 4 | *.swp 5 | 6 | # configuration 7 | conf/settings.py 8 | 9 | # building & packages 10 | /build/ 11 | /dist/ 12 | /*.egg-info 13 | /*.tar.gz 14 | /*.rpm 15 | /docs/build/ 16 | 17 | # virtualenv 18 | /env_*/ 19 | 20 | # unit testing 21 | /.coverage 22 | /.cache 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ######### 2 | testcloud 3 | ######### 4 | 5 | Project Moved 6 | ------------- 7 | 8 | testcloud has moved to `Pagure `_. All future development and handling of tickets and pull requests will be done there. 9 | -------------------------------------------------------------------------------- /conf/99-testcloud-nonroot-libvirt-access.rules: -------------------------------------------------------------------------------- 1 | /* If you are running testcloud as a non-administrative user (ie. not in wheel) or 2 | on a system that doesn't have a polkit agent running (custom setups, headless 3 | systems etc.), you may need to adjust local polkit configuration to allow 4 | non-admin users to manage VMs with libvirt. 5 | 6 | Copy this to /etc/polkit-1/rules.d/ . Then restart polkit 7 | $ systemctl restart polkit 8 | and if the user in question is a member of the unix group 'testcloud', that 9 | user should be able to run testcloud with no additional permissions. 10 | */ 11 | 12 | polkit.addRule(function(action, subject) { 13 | if (action.id == "org.libvirt.unix.manage" && 14 | subject.isInGroup('testcloud')) { 15 | return polkit.Result.YES; 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /conf/domain-template.jinja: -------------------------------------------------------------------------------- 1 | 2 | {{ domain_name }} 3 | {{ uuid }} 4 | {{ memory }} 5 | {{ memory }} 6 | 1 7 | 8 | hvm 9 | 10 | 11 | 12 | kvm64 13 | 14 | 15 | 16 | 17 | 18 | 19 | destroy 20 | restart 21 | restart 22 | 23 | 24 | 25 | 26 | 27 | /usr/bin/qemu-kvm 28 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /conf/settings-example.py: -------------------------------------------------------------------------------- 1 | # Commented out default values with details are displayed below. If you want 2 | # to change the values, make sure this file is available in one of the three 3 | # supported config locations: 4 | # - conf/settings.py in the git checkout 5 | # - ~/.config/testcloud/settings.py 6 | # - /etc/testcloud/settings.py 7 | 8 | 9 | #DOWNLOAD_PROGRESS = True 10 | #LOG_FILE = None 11 | 12 | 13 | ## Directories for data and cached downloaded images ## 14 | 15 | #DATA_DIR = "/var/lib/testcloud/" 16 | #STORE_DIR = "/var/lib/testcloud/backingstores" 17 | 18 | 19 | ## Data for cloud-init ## 20 | 21 | #PASSWORD = 'passw0rd' 22 | #HOSTNAME = 'testcloud' 23 | 24 | #META_DATA = """instance-id: iid-123456 25 | #local-hostname: %s 26 | #""" 27 | ## Read http://cloudinit.readthedocs.io/en/latest/topics/examples.html to see 28 | ## what options you can use here. 29 | #USER_DATA = """#cloud-config 30 | #password: %s 31 | #chpasswd: { expire: False } 32 | #ssh_pwauth: True 33 | #""" 34 | #ATOMIC_USER_DATA = """#cloud-config 35 | #password: %s 36 | #chpasswd: { expire: False } 37 | #ssh_pwauth: True 38 | #runcmd: 39 | # - [ sh, -c, 'echo -e "ROOT_SIZE=4G\nDATA_SIZE=10G" > /etc/sysconfig/docker-storage-setup'] 40 | #""" 41 | 42 | 43 | ## Extra cmdline args for the qemu invocation ## 44 | ## Customize as needed :) 45 | 46 | #CMD_LINE_ARGS = [] 47 | 48 | # The timeout, in seconds, to wait for an instance to boot before 49 | # failing the boot process. Setting this to 0 disables waiting and 50 | # returns immediately after starting the boot process. 51 | #BOOT_TIMEOUT = 30 52 | 53 | # ram size, in MiB 54 | #RAM = 512 55 | 56 | # Desired size, in GiB of instance disks. 0 leaves disk capacity 57 | # identical to source image 58 | #DISK_SIZE = 0 59 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | The documentation for testCloud contained in the docs/ directory is licensed 2 | under the Creative Commons Attribution 4.0 International License. To view a 3 | copy of this license, visit http://creativecommons.org/licenses/by/4.0/ 4 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/testCloud.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/testCloud.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/testCloud" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/testCloud" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | .. This work is licensed under the Creative Commons Attribution 4.0 2 | International License. To view a copy of this license, visit 3 | http://creativecommons.org/licenses/by/4.0/. 4 | 5 | ================ 6 | testcloud API 7 | ================ 8 | 9 | 10 | instance 11 | ======== 12 | 13 | .. automodule:: testcloud.instance 14 | :members: 15 | 16 | image 17 | ===== 18 | 19 | .. automodule:: testcloud.image 20 | :members: 21 | 22 | util 23 | ==== 24 | 25 | .. automodule:: testcloud.util 26 | :members: 27 | 28 | cli 29 | ==== 30 | 31 | .. automodule:: testcloud.cli 32 | :members: 33 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # testCloud documentation build configuration file, created by 4 | # sphinx-quickstart on Wed May 20 14:59:21 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.abspath('../../')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.autodoc', 33 | 'sphinx.ext.todo', 34 | 'sphinx.ext.viewcode', 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | #source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'testCloud' 51 | copyright = u'2016-2017, testCloud devs' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '0.1.12' 59 | # The full version, including alpha/beta/rc tags. 60 | release = version 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | #language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | #today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | #today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = [] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all 77 | # documents. 78 | #default_role = None 79 | 80 | # If true, '()' will be appended to :func: etc. cross-reference text. 81 | #add_function_parentheses = True 82 | 83 | # If true, the current module name will be prepended to all description 84 | # unit titles (such as .. function::). 85 | #add_module_names = True 86 | 87 | # If true, sectionauthor and moduleauthor directives will be shown in the 88 | # output. They are ignored by default. 89 | #show_authors = False 90 | 91 | # The name of the Pygments (syntax highlighting) style to use. 92 | pygments_style = 'sphinx' 93 | 94 | # A list of ignored prefixes for module index sorting. 95 | #modindex_common_prefix = [] 96 | 97 | # If true, keep warnings as "system message" paragraphs in the built documents. 98 | #keep_warnings = False 99 | 100 | 101 | # -- Options for HTML output ---------------------------------------------- 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | html_theme = 'nature' 106 | 107 | # Theme options are theme-specific and customize the look and feel of a theme 108 | # further. For a list of options available for each theme, see the 109 | # documentation. 110 | #html_theme_options = {} 111 | 112 | # Add any paths that contain custom themes here, relative to this directory. 113 | #html_theme_path = [] 114 | 115 | # The name for this set of Sphinx documents. If None, it defaults to 116 | # " v documentation". 117 | #html_title = None 118 | 119 | # A shorter title for the navigation bar. Default is the same as html_title. 120 | #html_short_title = None 121 | 122 | # The name of an image file (relative to this directory) to place at the top 123 | # of the sidebar. 124 | #html_logo = None 125 | 126 | # The name of an image file (within the static path) to use as favicon of the 127 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 128 | # pixels large. 129 | #html_favicon = None 130 | 131 | # Add any paths that contain custom static files (such as style sheets) here, 132 | # relative to this directory. They are copied after the builtin static files, 133 | # so a file named "default.css" will overwrite the builtin "default.css". 134 | html_static_path = ['_static'] 135 | 136 | # Add any extra paths that contain custom files (such as robots.txt or 137 | # .htaccess) here, relative to this directory. These files are copied 138 | # directly to the root of the documentation. 139 | #html_extra_path = [] 140 | 141 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 142 | # using the given strftime format. 143 | #html_last_updated_fmt = '%b %d, %Y' 144 | 145 | # If true, SmartyPants will be used to convert quotes and dashes to 146 | # typographically correct entities. 147 | #html_use_smartypants = True 148 | 149 | # Custom sidebar templates, maps document names to template names. 150 | #html_sidebars = {} 151 | 152 | # Additional templates that should be rendered to pages, maps page names to 153 | # template names. 154 | #html_additional_pages = {} 155 | 156 | # If false, no module index is generated. 157 | #html_domain_indices = True 158 | 159 | # If false, no index is generated. 160 | #html_use_index = True 161 | 162 | # If true, the index is split into individual pages for each letter. 163 | #html_split_index = False 164 | 165 | # If true, links to the reST sources are added to the pages. 166 | #html_show_sourcelink = True 167 | 168 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 169 | #html_show_sphinx = True 170 | 171 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 172 | #html_show_copyright = True 173 | 174 | # If true, an OpenSearch description file will be output, and all pages will 175 | # contain a tag referring to it. The value of this option must be the 176 | # base URL from which the finished HTML is served. 177 | #html_use_opensearch = '' 178 | 179 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 180 | #html_file_suffix = None 181 | 182 | # Output file base name for HTML help builder. 183 | htmlhelp_basename = 'testClouddoc' 184 | 185 | 186 | # -- Options for LaTeX output --------------------------------------------- 187 | 188 | latex_elements = { 189 | # The paper size ('letterpaper' or 'a4paper'). 190 | #'papersize': 'letterpaper', 191 | 192 | # The font size ('10pt', '11pt' or '12pt'). 193 | #'pointsize': '10pt', 194 | 195 | # Additional stuff for the LaTeX preamble. 196 | #'preamble': '', 197 | } 198 | 199 | # Grouping the document tree into LaTeX files. List of tuples 200 | # (source start file, target name, title, 201 | # author, documentclass [howto, manual, or own class]). 202 | latex_documents = [ 203 | ('index', 'testCloud.tex', u'testCloud Documentation', 204 | u'testCloud devs', 'manual'), 205 | ] 206 | 207 | # The name of an image file (relative to this directory) to place at the top of 208 | # the title page. 209 | #latex_logo = None 210 | 211 | # For "manual" documents, if this is true, then toplevel headings are parts, 212 | # not chapters. 213 | #latex_use_parts = False 214 | 215 | # If true, show page references after internal links. 216 | #latex_show_pagerefs = False 217 | 218 | # If true, show URL addresses after external links. 219 | #latex_show_urls = False 220 | 221 | # Documents to append as an appendix to all manuals. 222 | #latex_appendices = [] 223 | 224 | # If false, no module index is generated. 225 | #latex_domain_indices = True 226 | 227 | 228 | # -- Options for manual page output --------------------------------------- 229 | 230 | # One entry per manual page. List of tuples 231 | # (source start file, name, description, authors, manual section). 232 | man_pages = [ 233 | ('index', 'testcloud', u'testCloud Documentation', 234 | [u'testCloud devs'], 1) 235 | ] 236 | 237 | # If true, show URL addresses after external links. 238 | #man_show_urls = False 239 | 240 | 241 | # -- Options for Texinfo output ------------------------------------------- 242 | 243 | # Grouping the document tree into Texinfo files. List of tuples 244 | # (source start file, target name, title, author, 245 | # dir menu entry, description, category) 246 | texinfo_documents = [ 247 | ('index', 'testCloud', u'testCloud Documentation', 248 | u'testCloud devs', 'testCloud', 'One line description of project.', 249 | 'Miscellaneous'), 250 | ] 251 | 252 | # Documents to append as an appendix to all manuals. 253 | #texinfo_appendices = [] 254 | 255 | # If false, no module index is generated. 256 | #texinfo_domain_indices = True 257 | 258 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 259 | #texinfo_show_urls = 'footnote' 260 | 261 | # If true, do not generate a @detailmenu in the "Top" node's menu. 262 | #texinfo_no_detailmenu = False 263 | -------------------------------------------------------------------------------- /docs/source/indepth.rst: -------------------------------------------------------------------------------- 1 | .. This work is licensed under the Creative Commons Attribution 4.0 2 | International License. To view a copy of this license, visit 3 | http://creativecommons.org/licenses/by/4.0/. 4 | 5 | =================== 6 | testcloud, in Depth 7 | =================== 8 | 9 | The best way to understand exactly what testcloud is doing is to read through 10 | the code but the following is a more human-comprehension-friendly method of 11 | describing many of the details regarding how testcloud works. 12 | 13 | 14 | Instances and Images 15 | ==================== 16 | 17 | Two of the more important higher-level concepts in testcloud are what we refer 18 | to as Instances and Images. 19 | 20 | testcloud Image 21 | --------------- 22 | 23 | At this point, testcloud only supports qcow2 cloud images made from relatively 24 | recent Fedora releases. There are plans to support more distros and more image 25 | types in the future. 26 | 27 | The image is representation of a cloud image, where it was downloaded from, how 28 | to download the image and where it lives on the local filesystem. At this time, 29 | images are assumed to already be downloaded if another image sharing the exact 30 | same filename already exists - not the most resilient method ever conceived but 31 | it does work in most cases. 32 | 33 | testcloud Instance 34 | ------------------ 35 | 36 | A reasonable description of a testcloud instance is that it is a cloud image 37 | backed virtual machine. This virtual machine can be created, stopped, started 38 | or removed using the CLI interface from testcloud. 39 | 40 | testcloud instances make heavy use of `libvirt `_ and 41 | virt-install, part of the `virt-manager `_ application. 42 | 43 | General Process 44 | =============== 45 | 46 | Each instance has its own directory in the ``DATA_DIR`` and the tuple of 47 | (``INSTANCE_NAME``. ``IMAGE_FILENAME``) is considered to be unique. This method 48 | of delineating unique instances isn't perfect (should probably use image hash 49 | instead of filename) but it works well enough for now 50 | 51 | 52 | Directories and Files 53 | ===================== 54 | 55 | Globally, testcloud requires a few directories for storing images and instance 56 | metadata. 57 | 58 | ``/var/lib/testcloud/`` 59 | testcloud stores its data in here 60 | 61 | ``/var/lib/testcloud/backingstores`` 62 | default location for cached images 63 | 64 | ``/var/lib/testcloud/instances`` 65 | every instance has a unique directory, stored in here 66 | 67 | 68 | Outside of the global directories, each instance has a directory (sharing the 69 | instance name) inside ``/var/lib/testcloud/instances/``. 70 | 71 | 72 | ``/var/lib/testcloud/instances//`` 73 | Directory to hold instance-specific data 74 | 75 | ``/var/lib/testcloud/instances//-base.qcow2`` 76 | copy-on-write image used as a backing store for the instance - this doesn't 77 | store the entire image - just the changes made from the base image the instance 78 | has been booted from 79 | 80 | ``/var/lib/testcloud/instances//-seed.img`` 81 | image holding cloud-init source data used on boot 82 | 83 | ``/var/lib/testcloud/instances//meta/`` 84 | directory containing data from which the ``-seed.img`` is built 85 | 86 | ``/var/lib/testcloud/instances//meta/meta-data`` 87 | raw meta-data for cloud-init 88 | 89 | ``/var/lib/testcloud/instances//meta/user-data`` 90 | raw user-data for cloud-init 91 | 92 | 93 | Booting Instances 94 | ================= 95 | 96 | The creation process for a testcloud instance roughly follows this process: 97 | 98 | * download the referenced image, if it doesn't already exist in the backingstores 99 | 100 | * create an instance directory for the new instance, if an instance with the 101 | same name already exists, quit with an error 102 | 103 | * create cloud-init metadata and the qcow2 backing store for the instance 104 | 105 | * use ``virt-install`` to create a new virtual machine, using the backing store 106 | and the cloud-init ``seed.img`` as data stores. 107 | 108 | Once the instance is booted, it can be stopped, started again or deleted. 109 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. testcloud documentation master file, created by 2 | sphinx-quickstart on Wed May 20 14:59:21 2015. 3 | 4 | .. This work is licensed under the Creative Commons Attribution 4.0 5 | International License. To view a copy of this license, visit 6 | http://creativecommons.org/licenses/by/4.0/. 7 | 8 | ================================================== 9 | testcloud - the best pretend cloud you'll ever use 10 | ================================================== 11 | 12 | testcloud is a relatively simple system which is capable of booting images 13 | designed for cloud systems on a local system with minimial configuration. 14 | testcloud is desiged to be (and remain) somewhat simple, trading fancy cloud 15 | system features for ease of use and sanity in development. 16 | 17 | 18 | Installing testcloud 19 | ==================== 20 | 21 | testcloud is available from the Fedora repositories for F23 and later. 22 | 23 | dnf install testcloud 24 | 25 | 26 | Using testcloud 27 | =============== 28 | 29 | The main testcloud interface uses the terminal and the binary named 30 | ``testcloud``. testcloud operations can be split into two major categories: 31 | image commands and instance commands. 32 | 33 | Image Commands 34 | -------------- 35 | 36 | ``testcloud image list`` 37 | List all of the images currently cached by testcloud 38 | 39 | ``testcloud image remove `` 40 | Remove the image named ```` from the local image backing store. Make sure 41 | to replace ```` with the name of an image which is currently 42 | cached. 43 | 44 | Instance Commands 45 | ----------------- 46 | 47 | ``testcloud instance list`` 48 | List all of the instances currently running and spawned by testcloud. Adding 49 | the ``--all`` flag will list all instances spawned by testcloud whether the 50 | instance is currently running or not 51 | 52 | 53 | ``testcloud instance create -u `` 54 | Create a new testcloud instance using the name ```` and the 55 | image stored at the url ````. Currently supported image url types 56 | are ``http(s)://`` and ``file://``. Run ``testcloud instance create --help`` 57 | for information on other options for image creation. 58 | 59 | 60 | ``testcloud instance stop `` 61 | Stop the instance with name ```` 62 | 63 | ``testcloud instance start `` 64 | Start the instance with name ```` 65 | 66 | ``testcloud instance remove `` 67 | Remove the instance with name ````. This command will fail if 68 | the instance is not currently stopped 69 | 70 | 71 | Getting Help 72 | ============ 73 | 74 | Self service methods for asking questions and filing tickets: 75 | 76 | * `Source Repository `_ 77 | 78 | * `Currently Open Issues `_ 79 | 80 | For other questions, the best places to ask are: 81 | 82 | * `The #fedora-qa IRC channel on Freenode `_ 83 | 84 | * `The qa-devel mailing list `_ 85 | 86 | Licenses 87 | ======== 88 | 89 | The testcloud library is licensed as `GNU General Public Licence v2.0 or later 90 | `_. 91 | 92 | The documentation for testcloud is licensed under a `Creative Commons 93 | Atribution-ShareAlike 4.0 International License `_. 94 | 95 | 96 | Other Documentation 97 | =================== 98 | 99 | .. toctree:: 100 | :maxdepth: 2 101 | 102 | indepth 103 | api 104 | 105 | ================== 106 | Indices and tables 107 | ================== 108 | 109 | * :ref:`genindex` 110 | * :ref:`modindex` 111 | * :ref:`search` 112 | 113 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This is a list of pypi packages to be installed into virtualenv. Alternatively, 2 | # you can install these as RPMs instead of pypi packages. See README. 3 | 4 | Jinja2 5 | libvirt-python 6 | requests 7 | 8 | # Test suite requirements 9 | mock 10 | pytest 11 | pytest-cov 12 | -------------------------------------------------------------------------------- /run_testcloud.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python 2 | from testcloud import cli 3 | 4 | cli.main() 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, Command 2 | import codecs 3 | import re 4 | import os 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | 9 | def read(*parts): 10 | return codecs.open(os.path.join(here, *parts), 'r').read() 11 | 12 | 13 | def find_version(*file_paths): 14 | version_file = read(*file_paths) 15 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 16 | version_file, re.M) 17 | if version_match: 18 | return version_match.group(1) 19 | raise RuntimeError("Unable to find version string.") 20 | 21 | 22 | class PyTest(Command): 23 | user_options = [] 24 | 25 | def initialize_options(self): 26 | pass 27 | 28 | def finalize_options(self): 29 | pass 30 | 31 | def run(self): 32 | import subprocess 33 | errno = subprocess.call(['py.test']) 34 | raise SystemExit(errno) 35 | 36 | 37 | setup(name='testcloud', 38 | version=find_version('testcloud', '__init__.py'), 39 | description="small helper script to download and " 40 | "boot cloud images locally", 41 | author="Mike Ruckman", 42 | author_email="roshi@fedoraproject.org", 43 | license="GPLv2+", 44 | url="https://github.com/Rorosha/testcloud", 45 | packages=["testcloud"], 46 | package_dir={"testcloud": "testcloud"}, 47 | include_package_data=True, 48 | cmdclass={'test': PyTest}, 49 | entry_points=dict(console_scripts=["testcloud=testcloud.cli:main"]), 50 | install_requires=[ 51 | 'Jinja2', 52 | 'libvirt-python', 53 | 'requests', 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /test/test_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | """ This module is for testing the behaviour of cli functions.""" 8 | 9 | 10 | class TestCLI: 11 | def test_run(self): 12 | pass 13 | 14 | def test_main(self): 15 | pass 16 | -------------------------------------------------------------------------------- /test/test_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | from testcloud import config 8 | 9 | 10 | REF_DATA_DIR = "/some/random/dir/for/testing/" 11 | REF_STORE_DIR = "/some/random/dir/for/testing/backingstore/" 12 | REF_CONF_CONTENTS = """DATA_DIR = "{}" 13 | STORE_DIR = "{}/backingstores" 14 | """.format(REF_DATA_DIR, REF_STORE_DIR) 15 | 16 | 17 | class TestConfig(object): 18 | def setup_method(self, method): 19 | config._config = None 20 | 21 | def test_get_config_object(self, monkeypatch): 22 | '''Simple test to grab a config object, will return default config 23 | values. 24 | ''' 25 | 26 | monkeypatch.setattr(config, 'CONF_DIRS', []) 27 | ref_conf = config.ConfigData() 28 | 29 | test_conf = config.get_config() 30 | 31 | assert ref_conf.META_DATA == test_conf.META_DATA 32 | 33 | def test_missing_config_file(self, monkeypatch): 34 | '''Make sure that None is returned if no config files are found''' 35 | monkeypatch.setattr(config, 'CONF_DIRS', []) 36 | 37 | test_config_filename = config._find_config_file() 38 | assert test_config_filename is None 39 | 40 | # these are actually functional tests since they touch the filesystem but 41 | # leaving them here as we have no differentiation between unit and 42 | # functional tests at the moment 43 | def test_load_config_object(self, tmpdir): 44 | '''load config object from file, make sure it's loaded properly''' 45 | 46 | refdir = tmpdir.mkdir('conf') 47 | ref_conf_filename = '{}/{}'.format(refdir, config.CONF_FILE) 48 | with open(ref_conf_filename, 'w+') as ref_conffile: 49 | ref_conffile.write(REF_CONF_CONTENTS) 50 | 51 | test_config = config._load_config(ref_conf_filename) 52 | 53 | assert test_config.DATA_DIR == REF_DATA_DIR 54 | 55 | def test_merge_config_file(self, tmpdir): 56 | '''merge loaded config object with defaults, make sure that the defaults 57 | are overridden. 58 | ''' 59 | 60 | refdir = tmpdir.mkdir('conf') 61 | ref_conf_filename = '{}/{}'.format(refdir, config.CONF_FILE) 62 | with open(ref_conf_filename, 'w+') as ref_conffile: 63 | ref_conffile.write(REF_CONF_CONTENTS) 64 | 65 | test_config_obj = config._load_config(ref_conf_filename) 66 | 67 | test_config = config.ConfigData() 68 | assert test_config.DATA_DIR != REF_DATA_DIR 69 | 70 | test_config.merge_object(test_config_obj) 71 | assert test_config.DATA_DIR == REF_DATA_DIR 72 | 73 | def test_load_merge_config_file(self, tmpdir, monkeypatch): 74 | '''get config, making sure that an addional config file is found. make 75 | sure that default values are properly overridden. 76 | ''' 77 | 78 | refdir = tmpdir.mkdir('conf') 79 | ref_conf_filename = '{}/{}'.format(refdir, config.CONF_FILE) 80 | with open(ref_conf_filename, 'w+') as ref_conffile: 81 | ref_conffile.write(REF_CONF_CONTENTS) 82 | 83 | monkeypatch.setattr(config, 'CONF_DIRS', [str(refdir)]) 84 | test_config = config.get_config() 85 | 86 | assert test_config.DATA_DIR == REF_DATA_DIR 87 | -------------------------------------------------------------------------------- /test/test_image.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | """ This module is for testing the behaviour of the Image class.""" 8 | 9 | import pytest 10 | 11 | from testcloud import image 12 | from testcloud import exceptions 13 | 14 | 15 | class TestImage: 16 | 17 | def test_image_download_path(self): 18 | pass 19 | 20 | def test_image_name(self): 21 | pass 22 | 23 | def test_download(self): 24 | pass 25 | 26 | # def test_save_pristine(self): 27 | # pass 28 | # 29 | # def test_load_pristine(self): 30 | # pass 31 | 32 | 33 | class TestImageUriProcess(object): 34 | """The basic idea of what these tests do is to make sure that uris are 35 | parsed properly. http, https and file are OK and supported. ftp is an 36 | an example of a type which is not currently supported and should raise an 37 | exception.""" 38 | 39 | def setup_method(self, method): 40 | self.image_name = 'image.img' 41 | self.len_data = 3 42 | 43 | def test_http_ur1(self): 44 | ref_type = 'http' 45 | ref_path = 'localhost/images/{}'.format(self.image_name) 46 | ref_uri = '{}://{}'.format(ref_type, ref_path) 47 | 48 | test_image = image.Image(ref_uri) 49 | test_data = test_image._process_uri(ref_uri) 50 | 51 | assert len(test_data) == self.len_data 52 | assert test_data['type'] == ref_type 53 | assert test_data['name'] == self.image_name 54 | assert test_data['path'] == ref_path 55 | 56 | def test_https_uri(self): 57 | ref_type = 'https' 58 | ref_path = 'localhost/images/{}'.format(self.image_name) 59 | ref_uri = '{}://{}'.format(ref_type, ref_path) 60 | 61 | test_image = image.Image(ref_uri) 62 | test_data = test_image._process_uri(ref_uri) 63 | 64 | assert len(test_data) == self.len_data 65 | assert test_data['type'] == ref_type 66 | assert test_data['name'] == self.image_name 67 | assert test_data['path'] == ref_path 68 | 69 | def test_file_uri(self): 70 | ref_type = 'file' 71 | ref_path = '/srv/images/{}'.format(self.image_name) 72 | ref_uri = '{}://{}'.format(ref_type, ref_path) 73 | 74 | test_image = image.Image(ref_uri) 75 | test_data = test_image._process_uri(ref_uri) 76 | 77 | assert len(test_data) == self.len_data 78 | assert test_data['type'] == ref_type 79 | assert test_data['name'] == self.image_name 80 | assert test_data['path'] == ref_path 81 | 82 | def test_invalid_uri_type(self): 83 | ref_type = 'ftp' 84 | ref_path = '/localhost/images/{}'.format(self.image_name) 85 | ref_uri = '{}://{}'.format(ref_type, ref_path) 86 | 87 | with pytest.raises(exceptions.TestcloudImageError): 88 | image.Image(ref_uri) 89 | 90 | def test_invalid_uri(self): 91 | ref_uri = 'leprechaunhandywork' 92 | 93 | with pytest.raises(exceptions.TestcloudImageError): 94 | image.Image(ref_uri) 95 | -------------------------------------------------------------------------------- /test/test_instance.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | """ This module is for testing the behaviour of the Image class.""" 8 | 9 | import os 10 | 11 | import mock 12 | 13 | from testcloud import instance, image, config 14 | 15 | 16 | class TestInstance: 17 | 18 | def test_expand_qcow(self): 19 | pass 20 | 21 | def test_create_seed(self): 22 | pass 23 | 24 | def test_set_seed_path(self): 25 | pass 26 | 27 | def test_download_initrd_and_kernel(self): 28 | pass 29 | 30 | def test_boot_base(self): 31 | pass 32 | 33 | def test_boot_atomic(self): 34 | pass 35 | 36 | def test_boot_pristine(self): 37 | pass 38 | 39 | 40 | class TestFindInstance(object): 41 | 42 | def setup_method(self, method): 43 | self.conf = config.ConfigData() 44 | 45 | def test_non_existant_instance(self, monkeypatch): 46 | ref_name = 'test-123' 47 | ref_image = image.Image('file:///someimage.qcow2') 48 | 49 | stub_listdir = mock.Mock() 50 | stub_listdir.return_value = [] 51 | monkeypatch.setattr(os, 'listdir', stub_listdir) 52 | 53 | test_instance = instance.find_instance(ref_name, ref_image) 54 | 55 | assert test_instance is None 56 | 57 | def test_find_exist_instance(self, monkeypatch): 58 | ref_name = 'test-123' 59 | ref_image = image.Image('file:///someimage.qcow2') 60 | ref_path = os.path.join(self.conf.DATA_DIR, 61 | 'instances/{}'.format(ref_name)) 62 | 63 | stub_listdir = mock.Mock() 64 | stub_listdir.return_value = [ref_name] 65 | monkeypatch.setattr(os, 'listdir', stub_listdir) 66 | 67 | test_instance = instance.find_instance(ref_name, ref_image) 68 | 69 | assert test_instance.path == ref_path 70 | -------------------------------------------------------------------------------- /testcloud/__init__.py: -------------------------------------------------------------------------------- 1 | # NOTE: if you update version, *make sure* to also update `docs/source/conf.py` 2 | __version__ = '0.1.12' 3 | -------------------------------------------------------------------------------- /testcloud/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | """ 8 | This is the primary user entry point for testcloud 9 | """ 10 | 11 | import argparse 12 | import logging 13 | from time import sleep 14 | import os 15 | from . import config 16 | from . import image 17 | from . import instance 18 | from . import util 19 | from .exceptions import DomainNotFoundError, TestcloudCliError, TestcloudInstanceError 20 | 21 | config_data = config.get_config() 22 | 23 | # Only log to a file when specifically configured to 24 | if config_data.LOG_FILE is not None: 25 | logging.basicConfig(filename=config_data.LOG_FILE, level=logging.DEBUG) 26 | 27 | log = logging.getLogger('testcloud') 28 | log.addHandler(logging.NullHandler()) # this is needed when running in library mode 29 | 30 | description = """Testcloud is a small wrapper program designed to quickly and 31 | simply boot images designed for cloud systems.""" 32 | 33 | 34 | ################################################################################ 35 | # instance handling functions 36 | ################################################################################ 37 | 38 | def _list_instance(args): 39 | """Handler for 'list' command. Expects the following elements in args: 40 | * name(str) 41 | 42 | :param args: args from argparser 43 | """ 44 | instances = instance.list_instances(args.connection) 45 | 46 | print("{:<16} {:^30} {:<10}".format("Name", "IP", "State")) 47 | print("-"*60) 48 | for inst in instances: 49 | if args.all or inst['state'] == 'running': 50 | print("{:<27} {:^22} {:<10}".format(inst['name'], 51 | inst['ip'], 52 | inst['state'])) 53 | 54 | print("") 55 | 56 | 57 | def _create_instance(args): 58 | """Handler for 'instance create' command. Expects the following elements in args: 59 | * name(str) 60 | 61 | :param args: args from argparser 62 | """ 63 | 64 | log.debug("create instance") 65 | 66 | tc_image = image.Image(args.url) 67 | tc_image.prepare() 68 | 69 | existing_instance = instance.find_instance(args.name, image=tc_image, 70 | connection=args.connection) 71 | 72 | # can't create existing instances 73 | if existing_instance is not None: 74 | raise TestcloudCliError("A testcloud instance named {} already " 75 | "exists at {}. Use 'testcloud instance start " 76 | "{}' to start the instance or remove it before" 77 | " re-creating ".format(args.name, 78 | existing_instance.path, 79 | args.name)) 80 | 81 | else: 82 | tc_instance = instance.Instance(args.name, image=tc_image, connection=args.connection) 83 | 84 | # set ram size 85 | tc_instance.ram = args.ram 86 | 87 | # set disk size 88 | tc_instance.disk_size = args.disksize 89 | 90 | # prepare instance 91 | tc_instance.prepare() 92 | 93 | # create instance domain 94 | tc_instance.spawn_vm() 95 | 96 | # start created domain 97 | tc_instance.start(args.timeout) 98 | 99 | # find vm ip 100 | vm_ip = find_vm_ip(args.name, args.connection) 101 | 102 | # Write ip to file 103 | tc_instance.create_ip_file(vm_ip) 104 | print("The IP of vm {}: {}".format(args.name, vm_ip)) 105 | 106 | 107 | def _start_instance(args): 108 | """Handler for 'instance start' command. Expects the following elements in args: 109 | * name(str) 110 | 111 | :param args: args from argparser 112 | """ 113 | log.debug("start instance: {}".format(args.name)) 114 | 115 | tc_instance = instance.find_instance(args.name, connection=args.connection) 116 | 117 | if tc_instance is None: 118 | raise TestcloudCliError("Cannot start instance {} because it does " 119 | "not exist".format(args.name)) 120 | 121 | tc_instance.start(args.timeout) 122 | with open(os.path.join(config_data.DATA_DIR, 'instances', args.name, 'ip'), 'r') as ip_file: 123 | vm_ip = ip_file.read() 124 | print("The IP of vm {}: {}".format(args.name, vm_ip)) 125 | 126 | 127 | def _stop_instance(args): 128 | """Handler for 'instance stop' command. Expects the following elements in args: 129 | * name(str) 130 | 131 | :param args: args from argparser 132 | """ 133 | log.debug("stop instance: {}".format(args.name)) 134 | 135 | tc_instance = instance.find_instance(args.name, connection=args.connection) 136 | 137 | if tc_instance is None: 138 | raise TestcloudCliError("Cannot stop instance {} because it does " 139 | "not exist".format(args.name)) 140 | 141 | tc_instance.stop() 142 | 143 | 144 | def _remove_instance(args): 145 | """Handler for 'instance remove' command. Expects the following elements in args: 146 | * name(str) 147 | 148 | :param args: args from argparser 149 | """ 150 | log.debug("remove instance: {}".format(args.name)) 151 | 152 | tc_instance = instance.find_instance(args.name, connection=args.connection) 153 | 154 | if tc_instance is None: 155 | raise TestcloudCliError("Cannot remove instance {} because it does " 156 | "not exist".format(args.name)) 157 | 158 | tc_instance.remove(autostop=args.force) 159 | 160 | 161 | def _reboot_instance(args): 162 | """Handler for 'instance reboot' command. Expects the following elements in args: 163 | * name(str) 164 | 165 | :param args: args from argparser 166 | """ 167 | _stop_instance(args) 168 | _start_instance(args) 169 | 170 | 171 | ################################################################################ 172 | # image handling functions 173 | ################################################################################ 174 | def _list_image(args): 175 | """Handler for 'image list' command. Does not expect anything else in args. 176 | 177 | :param args: args from argparser 178 | """ 179 | log.debug("list images") 180 | images = image.list_images() 181 | print("Current Images:") 182 | for img in images: 183 | print(" {}".format(img)) 184 | 185 | 186 | def _remove_image(args): 187 | """Handler for 'image remove' command. Expects the following elements in args: 188 | * name(str) 189 | 190 | :param args: args from argparser 191 | """ 192 | 193 | log.debug("removing image {}".format(args.name)) 194 | 195 | tc_image = image.find_image(args.name) 196 | 197 | if tc_image is None: 198 | log.error("image {} not found, cannot remove".format(args.name)) 199 | 200 | tc_image.remove() 201 | 202 | 203 | def get_argparser(): 204 | parser = argparse.ArgumentParser(description=description) 205 | subparsers = parser.add_subparsers(title="Command Types", 206 | description="Types of commands available", 207 | help=" --help") 208 | 209 | instarg = subparsers.add_parser("instance", help="help on instance options") 210 | instarg.add_argument("-c", 211 | "--connection", 212 | default="qemu:///system", 213 | help="libvirt connection url to use") 214 | instarg_subp = instarg.add_subparsers(title="instance commands", 215 | description="Commands available for instance operations", 216 | help=" help") 217 | 218 | # instance list 219 | instarg_list = instarg_subp.add_parser("list", help="list instances") 220 | instarg_list.set_defaults(func=_list_instance) 221 | instarg_list.add_argument("--all", 222 | help="list all instances, running and stopped", 223 | action="store_true") 224 | 225 | # instance start 226 | instarg_start = instarg_subp.add_parser("start", help="start instance") 227 | instarg_start.add_argument("name", 228 | help="name of instance to start") 229 | instarg_start.add_argument("--timeout", 230 | help="Time (in seconds) to wait for boot to " 231 | "complete before completion, setting to 0" 232 | " disables all waiting.", 233 | type=int, 234 | default=config_data.BOOT_TIMEOUT) 235 | instarg_start.set_defaults(func=_start_instance) 236 | 237 | # instance stop 238 | instarg_stop = instarg_subp.add_parser("stop", help="stop instance") 239 | instarg_stop.add_argument("name", 240 | help="name of instance to stop") 241 | instarg_stop.set_defaults(func=_stop_instance) 242 | # instance remove 243 | instarg_remove = instarg_subp.add_parser("remove", help="remove instance") 244 | instarg_remove.add_argument("name", 245 | help="name of instance to remove") 246 | instarg_remove.add_argument("-f", 247 | "--force", 248 | help="Stop the instance if it's running", 249 | action="store_true") 250 | instarg_remove.set_defaults(func=_remove_instance) 251 | 252 | instarg_destroy = instarg_subp.add_parser("destroy", help="deprecated alias for remove") 253 | instarg_destroy.add_argument("name", 254 | help="name of instance to remove") 255 | instarg_destroy.add_argument("-f", 256 | "--force", 257 | help="Stop the instance if it's running", 258 | action="store_true") 259 | instarg_destroy.set_defaults(func=_remove_instance) 260 | # instance reboot 261 | instarg_reboot = instarg_subp.add_parser("reboot", help="reboot instance") 262 | instarg_reboot.add_argument("name", 263 | help="name of instance to reboot") 264 | instarg_reboot.add_argument("--timeout", 265 | help="Time (in seconds) to wait for boot to " 266 | "complete before completion, setting to 0" 267 | " disables all waiting.", 268 | type=int, 269 | default=config_data.BOOT_TIMEOUT) 270 | instarg_reboot.set_defaults(func=_reboot_instance) 271 | # instance create 272 | instarg_create = instarg_subp.add_parser("create", help="create instance") 273 | instarg_create.set_defaults(func=_create_instance) 274 | instarg_create.add_argument("name", 275 | help="name of instance to create") 276 | instarg_create.add_argument("--ram", 277 | help="Specify the amount of ram in MiB for the VM.", 278 | type=int, 279 | default=config_data.RAM) 280 | instarg_create.add_argument("--no-graphic", 281 | help="Turn off graphical display.", 282 | action="store_true") 283 | instarg_create.add_argument("--vnc", 284 | help="Turns on vnc at :1 to the instance.", 285 | action="store_true") 286 | instarg_create.add_argument("--atomic", 287 | help="Use this flag if you're booting an Atomic Host.", 288 | action="store_true") 289 | # this might work better as a second, required positional arg 290 | instarg_create.add_argument("-u", 291 | "--url", 292 | help="URL to qcow2 image is required.", 293 | type=str) 294 | instarg_create.add_argument("--timeout", 295 | help="Time (in seconds) to wait for boot to " 296 | "complete before completion, setting to 0" 297 | " disables all waiting.", 298 | type=int, 299 | default=config_data.BOOT_TIMEOUT) 300 | instarg_create.add_argument("--disksize", 301 | help="Desired instance disk size, in GB", 302 | type=int, 303 | default=config_data.DISK_SIZE) 304 | 305 | imgarg = subparsers.add_parser("image", help="help on image options") 306 | imgarg_subp = imgarg.add_subparsers(title="subcommands", 307 | description="Types of commands available", 308 | help=" help") 309 | 310 | # image list 311 | imgarg_list = imgarg_subp.add_parser("list", help="list images") 312 | imgarg_list.set_defaults(func=_list_image) 313 | 314 | # image remove 315 | imgarg_remove = imgarg_subp.add_parser('remove', help="remove image") 316 | imgarg_remove.add_argument("name", 317 | help="name of image to remove") 318 | imgarg_remove.set_defaults(func=_remove_image) 319 | 320 | imgarg_destroy = imgarg_subp.add_parser('destroy', help="deprecated alias for remove") 321 | imgarg_destroy.add_argument("name", 322 | help="name of image to remove") 323 | imgarg_destroy.set_defaults(func=_remove_image) 324 | 325 | return parser 326 | 327 | 328 | def _configure_logging(level=logging.DEBUG): 329 | '''Set up logging framework, when running in main script mode. Should not 330 | be called when running in library mode. 331 | 332 | :param int level: the stream log level to be set (one of the constants from logging.*) 333 | ''' 334 | logging.basicConfig(format='%(levelname)s:%(message)s', level=level) 335 | 336 | 337 | def main(): 338 | parser = get_argparser() 339 | args = parser.parse_args() 340 | 341 | _configure_logging() 342 | 343 | args.func(args) 344 | 345 | 346 | def find_vm_ip(name, connection='qemu:///system'): 347 | """Finds the ip of a local vm given it's name used by libvirt. 348 | 349 | :param str name: name of the VM (as used by libvirt) 350 | :param str connection: name of the libvirt connection uri 351 | :returns: ip address of VM 352 | :rtype: str 353 | """ 354 | 355 | for _ in xrange(100): 356 | vm_xml = util.get_vm_xml(name, connection) 357 | if vm_xml is not None: 358 | break 359 | 360 | else: 361 | sleep(.2) 362 | else: 363 | raise DomainNotFoundError 364 | 365 | vm_mac = util.find_mac(vm_xml) 366 | vm_mac = vm_mac[0] 367 | 368 | # The arp cache takes some time to populate, so this keeps looking 369 | # for the entry until it shows up. 370 | 371 | for _ in xrange(100): 372 | vm_ip = util.find_ip_from_mac(vm_mac.attrib['address']) 373 | 374 | if vm_ip: 375 | break 376 | 377 | sleep(.2) 378 | else: 379 | raise TestcloudInstanceError('Could not find VM\'s ip before timeout') 380 | 381 | return vm_ip 382 | -------------------------------------------------------------------------------- /testcloud/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import imp 3 | 4 | import testcloud 5 | 6 | 7 | DEFAULT_CONF_DIR = os.path.abspath(os.path.dirname(testcloud.__file__)) + '/../conf' 8 | 9 | CONF_DIRS = [DEFAULT_CONF_DIR, 10 | '{}/.config/testcloud'.format(os.environ['HOME']), 11 | '/etc/testcloud' 12 | ] 13 | 14 | CONF_FILE = 'settings.py' 15 | 16 | _config = None 17 | 18 | 19 | def get_config(): 20 | '''Retrieve a config instance. If a config instance has already been parsed, 21 | reuse that parsed instance. 22 | 23 | :return: :class:`.ConfigData` containing configuration values 24 | ''' 25 | 26 | global _config 27 | if not _config: 28 | _config = _parse_config() 29 | return _config 30 | 31 | 32 | def _parse_config(): 33 | '''Parse config file in a supported location and merge with default values. 34 | 35 | :return: loaded config data merged with defaults from :class:`.ConfigData` 36 | ''' 37 | 38 | config = ConfigData() 39 | config_filename = _find_config_file() 40 | 41 | if config_filename is not None: 42 | loaded_config = _load_config(config_filename) 43 | config.merge_object(loaded_config) 44 | 45 | return config 46 | 47 | 48 | def _find_config_file(): 49 | '''Look in supported config dirs for a configuration file. 50 | 51 | :return: filename of first discovered file, None if no files are found 52 | ''' 53 | 54 | for conf_dir in CONF_DIRS: 55 | conf_file = '{}/{}'.format(conf_dir, CONF_FILE) 56 | if os.path.exists(conf_file): 57 | return conf_file 58 | return None 59 | 60 | 61 | def _load_config(conf_filename): 62 | '''Load configuration data from a python file. Only loads attrs which are 63 | named using all caps. 64 | 65 | :param conf_filename: full path to config file to load 66 | :type conf_filename: str 67 | :return: object containing configuration values 68 | ''' 69 | 70 | new_conf = imp.new_module('config') 71 | new_conf.__file__ = conf_filename 72 | try: 73 | with open(conf_filename, 'r') as conf_file: 74 | exec(compile(conf_file.read(), conf_filename, 'exec'), 75 | new_conf.__dict__) 76 | except IOError as e: 77 | e.strerror = 'Unable to load config file {}'.format(e.strerror) 78 | raise 79 | return new_conf 80 | 81 | 82 | class ConfigData(object): 83 | '''Holds configuration data for TestCloud. Is initialized with default 84 | values which can be overridden. 85 | ''' 86 | 87 | DOWNLOAD_PROGRESS = True 88 | LOG_FILE = None 89 | 90 | # Directories testcloud cares about 91 | 92 | DATA_DIR = "/var/lib/testcloud" 93 | STORE_DIR = "/var/lib/testcloud/backingstores" 94 | 95 | # libvirt domain XML Template 96 | # This lives either in the DEFAULT_CONF_DIR or DATA_DIR 97 | XML_TEMPLATE = "domain-template.jinja" 98 | 99 | # Data for cloud-init 100 | 101 | PASSWORD = 'passw0rd' 102 | HOSTNAME = 'testcloud' 103 | 104 | META_DATA = """instance-id: iid-123456 105 | local-hostname: %s 106 | """ 107 | USER_DATA = """#cloud-config 108 | password: %s 109 | chpasswd: { expire: False } 110 | ssh_pwauth: True 111 | """ 112 | ATOMIC_USER_DATA = """#cloud-config 113 | password: %s 114 | chpasswd: { expire: False } 115 | ssh_pwauth: True 116 | runcmd: 117 | - [ sh, -c, 'echo -e "ROOT_SIZE=4G\nDATA_SIZE=10G" > /etc/sysconfig/docker-storage-setup'] 118 | """ 119 | 120 | # Extra cmdline args for the qemu invocation. 121 | # Customize as needed :) 122 | 123 | CMD_LINE_ARGS = [] 124 | 125 | # timeout, in seconds for instance boot process 126 | BOOT_TIMEOUT = 30 127 | 128 | # ram size, in MiB 129 | RAM = 512 130 | 131 | # Desired size, in GiB of instance disks. 0 leaves disk capacity 132 | # identical to source image 133 | DISK_SIZE = 0 134 | 135 | def merge_object(self, obj): 136 | '''Overwrites default values with values from a python object which have 137 | names containing all upper case letters. 138 | 139 | :param obj: python object containing configuration values 140 | :type obj: python object 141 | ''' 142 | 143 | for key in dir(obj): 144 | if key.isupper(): 145 | setattr(self, key, getattr(obj, key)) 146 | -------------------------------------------------------------------------------- /testcloud/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | """ 8 | Exceptions used with testcloud 9 | """ 10 | 11 | 12 | class TestcloudException(BaseException): 13 | """Common ancestor for all Testcloud exceptions""" 14 | pass 15 | 16 | 17 | class TestcloudCliError(TestcloudException): 18 | """Exception for errors having to do with Testcloud CLI processing""" 19 | pass 20 | 21 | 22 | class TestcloudImageError(TestcloudException): 23 | """Exception for errors having to do with images and image fetching""" 24 | pass 25 | 26 | 27 | class TestcloudInstanceError(TestcloudException): 28 | """Exception for errors having to do with instances and instance prep""" 29 | pass 30 | 31 | 32 | class DomainNotFoundError(BaseException): 33 | """Exception to raise if the queried domain can't be found.""" 34 | 35 | def __init__(self): 36 | self.value = "Could not find the requested virsh domain, did it register?" 37 | 38 | def __str__(self): 39 | return repr(self.value) 40 | -------------------------------------------------------------------------------- /testcloud/image.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | """ 8 | Representation of a cloud image which can be used to boot instances 9 | """ 10 | 11 | import sys 12 | import os 13 | import subprocess 14 | import re 15 | import shutil 16 | import logging 17 | 18 | import requests 19 | 20 | from . import config 21 | from .exceptions import TestcloudImageError 22 | 23 | config_data = config.get_config() 24 | 25 | log = logging.getLogger('testcloud.image') 26 | 27 | 28 | def list_images(): 29 | """List the images currently downloaded and available on the system 30 | 31 | :returns: list of images currently available 32 | """ 33 | 34 | image_dir = config_data.STORE_DIR 35 | images = os.listdir(image_dir) 36 | 37 | return images 38 | 39 | 40 | def find_image(name, uri=None): 41 | """Find an image matching a given name and optionally, a uri 42 | 43 | :param name: name of the image to look for 44 | :param uri: source uri to use if the image is found 45 | 46 | :returns: :py:class:`Image` if an image is found, otherwise None 47 | """ 48 | images = list_images() 49 | 50 | if name in images: 51 | if uri is None: 52 | uri = 'file://{}/{}'.format(config_data.STORE_DIR, name) 53 | return Image(uri) 54 | else: 55 | return None 56 | 57 | 58 | class Image(object): 59 | """Handles base cloud images and prepares them for boot. This includes 60 | downloading images from remote systems (http, https supported) or copying 61 | from mounted local filesystems. 62 | """ 63 | 64 | def __init__(self, uri): 65 | """Create a new Image object for Testcloud 66 | 67 | :param uri: URI for the image to be represented. this URI must be of a 68 | supported type (http, https, file) 69 | :raises TestcloudImageError: if the URI is not of a supported type or cannot be parsed 70 | """ 71 | 72 | self.uri = uri 73 | 74 | uri_data = self._process_uri(uri) 75 | 76 | self.name = uri_data['name'] 77 | self.uri_type = uri_data['type'] 78 | 79 | if self.uri_type == 'file': 80 | self.remote_path = uri_data['path'] 81 | else: 82 | self.remote_path = uri 83 | 84 | self.local_path = "{}/{}".format(config_data.STORE_DIR, self.name) 85 | 86 | def _process_uri(self, uri): 87 | """Process the URI given to find the type, path and imagename contained 88 | in that URI. 89 | 90 | :param uri: string URI to be processed 91 | :return: dictionary containing 'type', 'name' and 'path' 92 | :raise TestcloudImageError: if the URI is invalid or uses an unsupported transport 93 | """ 94 | 95 | type_match = re.search(r'(http|https|file)://([\w\.\-/]+)', uri) 96 | 97 | if not type_match: 98 | raise TestcloudImageError('invalid uri: only http, https and file uris' 99 | ' are supported: {}'.format(uri)) 100 | 101 | uri_type = type_match.group(1) 102 | uri_path = type_match.group(2) 103 | 104 | name_match = re.findall('([\w\.\-]+)', uri) 105 | 106 | if not name_match: 107 | raise TestcloudImageError('invalid uri: could not find image name: {}'.format(uri)) 108 | 109 | image_name = name_match[-1] 110 | return {'type': uri_type, 'name': image_name, 'path': uri_path} 111 | 112 | def _download_remote_image(self, remote_url, local_path): 113 | """Download a remote image to the local system, outputting download 114 | progress as it's downloaded. 115 | 116 | :param remote_url: URL of the image 117 | :param local_path: local path (including filename) that the image 118 | will be downloaded to 119 | """ 120 | 121 | u = requests.get(remote_url, stream=True) 122 | if u.status_code == 404: 123 | raise TestcloudImageError('Image not found at the given URL: {}'.format(self.uri)) 124 | 125 | try: 126 | with open(local_path + ".part", 'wb') as f: 127 | file_size = int(u.headers['content-length']) 128 | 129 | log.info("Downloading {0} ({1} bytes)".format(self.name, file_size)) 130 | bytes_downloaded = 0 131 | block_size = 4096 132 | 133 | while True: 134 | 135 | try: 136 | 137 | for data in u.iter_content(block_size): 138 | 139 | bytes_downloaded += len(data) 140 | f.write(data) 141 | bytes_remaining = float(bytes_downloaded) / file_size 142 | if config_data.DOWNLOAD_PROGRESS: 143 | # TODO: Improve this progress indicator by making 144 | # it more readable and user-friendly. 145 | status = r"{0}/{1} [{2:.2%}]".format(bytes_downloaded, 146 | file_size, 147 | bytes_remaining) 148 | status = status + chr(8) * (len(status) + 1) 149 | sys.stdout.write(status) 150 | 151 | except TypeError: 152 | # Rename the file since download has completed 153 | os.rename(local_path + ".part", local_path) 154 | log.info("Succeeded at downloading {0}".format(self.name)) 155 | break 156 | 157 | except OSError: 158 | log.error("Problem writing to {}.".format(config_data.PRISTINE)) 159 | 160 | def _handle_file_url(self, source_path, dest_path, copy=True): 161 | if not os.path.exists(dest_path): 162 | if copy: 163 | shutil.copy(source_path, dest_path) 164 | else: 165 | subprocess.check_call(['ln', '-s', '-f', source_path, dest_path]) 166 | 167 | def _adjust_image_selinux(self, image_path): 168 | """If SElinux is enabled on the system, change the context of that image 169 | file such that libguestfs and qemu can use it. 170 | 171 | :param image_path: path to the image to change the context of 172 | """ 173 | 174 | selinux_active = subprocess.call(['selinuxenabled']) 175 | 176 | if selinux_active != 0: 177 | log.debug('SELinux not enabled, not changing context of' 178 | 'image {}'.format(image_path)) 179 | return 180 | 181 | image_context = subprocess.call(['chcon', 182 | '-h', 183 | '-u', 'system_u', 184 | '-t', 'virt_content_t', 185 | image_path]) 186 | if image_context == 0: 187 | log.debug('successfully changed SELinux context for ' 188 | 'image {}'.format(image_path)) 189 | else: 190 | log.error('Error while changing SELinux context on ' 191 | 'image {}'.format(image_path)) 192 | 193 | def prepare(self, copy=True): 194 | """Prepare the image for local use by either downloading the image from 195 | a remote location or copying/linking it into the image store from a locally 196 | mounted filesystem 197 | 198 | :param copy: if true image will be copied to backingstores else symlink is created 199 | in backingstores instead of copying. Only for file:// type of urls. 200 | """ 201 | 202 | log.debug("Local downloads will be stored in {}.".format( 203 | config_data.STORE_DIR)) 204 | 205 | if self.uri_type == 'file': 206 | self._handle_file_url(self.remote_path, self.local_path, copy=copy) 207 | else: 208 | if not os.path.exists(self.local_path): 209 | self._download_remote_image(self.remote_path, self.local_path) 210 | 211 | self._adjust_image_selinux(self.local_path) 212 | 213 | return self.local_path 214 | 215 | def remove(self): 216 | """Remove the image from disk. This operation cannot be undone. 217 | """ 218 | 219 | log.debug("removing image {}".format(self.local_path)) 220 | os.remove(self.local_path) 221 | 222 | def destroy(self): 223 | '''A deprecated method. Please call :meth:`remove` instead.''' 224 | 225 | log.debug('DEPRECATED: destroy() method was deprecated. Please use remove()') 226 | self.remove() 227 | -------------------------------------------------------------------------------- /testcloud/instance.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | """ 8 | Representation of a Testcloud spawned (or to-be-spawned) virtual machine 9 | """ 10 | 11 | import os 12 | import sys 13 | import subprocess 14 | import glob 15 | import logging 16 | import time 17 | 18 | import libvirt 19 | import shutil 20 | import uuid 21 | import jinja2 22 | 23 | from . import config 24 | from . import util 25 | from .exceptions import TestcloudInstanceError 26 | 27 | config_data = config.get_config() 28 | 29 | log = logging.getLogger('testcloud.instance') 30 | 31 | #: mapping domain state constants from libvirt to a known set of strings 32 | DOMAIN_STATUS_ENUM = {libvirt.VIR_DOMAIN_NOSTATE: 'no state', 33 | libvirt.VIR_DOMAIN_RUNNING: 'running', 34 | libvirt.VIR_DOMAIN_BLOCKED: 'blocked', 35 | libvirt.VIR_DOMAIN_PAUSED: 'paused', 36 | libvirt.VIR_DOMAIN_SHUTDOWN: 'shutdown', 37 | libvirt.VIR_DOMAIN_SHUTOFF: 'shutoff', 38 | libvirt.VIR_DOMAIN_CRASHED: 'crashed', 39 | libvirt.VIR_DOMAIN_PMSUSPENDED: 'suspended' 40 | } 41 | 42 | 43 | def _list_instances(): 44 | """List existing instances currently known to testcloud 45 | 46 | :returns: dict of instance names and their ip address 47 | """ 48 | 49 | instance_list = [] 50 | 51 | instance_dir = os.listdir('{}/instances'.format(config_data.DATA_DIR)) 52 | for dir in instance_dir: 53 | instance_details = {} 54 | instance_details['name'] = dir 55 | try: 56 | with open("{}/instances/{}/ip".format(config_data.DATA_DIR, dir), 'r') as inst: 57 | instance_details['ip'] = inst.readline().strip() 58 | 59 | except IOError: 60 | instance_details['ip'] = None 61 | 62 | instance_list.append(instance_details) 63 | 64 | return instance_list 65 | 66 | 67 | def _list_domains(connection): 68 | """List known domains for a given hypervisor connection. 69 | 70 | :param connection: libvirt compatible hypervisor connection 71 | :returns: dictionary mapping of name -> state 72 | :rtype: dict 73 | """ 74 | 75 | domains = {} 76 | conn = libvirt.openReadOnly(connection) 77 | for domain in conn.listAllDomains(): 78 | try: 79 | # the libvirt docs seem to indicate that the second int is for state 80 | # details, only used when state is ERROR, so only looking at the first 81 | # int returned for domain.state() 82 | domains[domain.name()] = DOMAIN_STATUS_ENUM[domain.state()[0]] 83 | except libvirt.libvirtError as e: 84 | if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: 85 | # the domain disappeared in the meantime, just ignore 86 | continue 87 | else: 88 | raise e 89 | 90 | return domains 91 | 92 | 93 | def _find_domain(name, connection): 94 | '''Find whether a domain exists and get its state. 95 | 96 | :param str name: name of the domain to find 97 | :param str connection: name of libvirt connection uri 98 | :returns: domain state from ``DOMAIN_STATUS_ENUM`` if domain exists, or ``None`` if it doesn't 99 | :rtype: str or None 100 | ''' 101 | 102 | conn = libvirt.openReadOnly(connection) 103 | try: 104 | domain = conn.lookupByName(name) 105 | return DOMAIN_STATUS_ENUM[domain.state()[0]] 106 | except libvirt.libvirtError as e: 107 | if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: 108 | # no such domain 109 | return None 110 | else: 111 | raise e 112 | 113 | 114 | def find_instance(name, image=None, connection='qemu:///system'): 115 | """Find an instance using a given name and image, if it exists. 116 | 117 | Please note that ``connection`` is not taken into account when searching for the instance, but 118 | the instance object returned has the specified connection set. It's your responsibility to 119 | make sure the provided connection is valid for this instance. 120 | 121 | :param str name: name of instance to find 122 | :param image: instance of :py:class:`testcloud.image.Image` 123 | :param str connection: name of libvirt connection uri 124 | :returns: :py:class:`Instance` if the instance exists, ``None`` if it doesn't 125 | """ 126 | 127 | instances = _list_instances() 128 | for inst in instances: 129 | if inst['name'] == name: 130 | return Instance(name, image, connection) 131 | return None 132 | 133 | 134 | def list_instances(connection='qemu:///system'): 135 | """List instances known by testcloud and the state of each instance 136 | 137 | :param connection: libvirt compatible connection to use when listing domains 138 | :returns: dictionary of instance_name to domain_state mapping 139 | """ 140 | domains = _list_domains(connection) 141 | all_instances = _list_instances() 142 | 143 | instances = [] 144 | 145 | for instance in all_instances: 146 | if instance['name'] not in domains.keys(): 147 | log.warn('{} is not registered, might want to delete it.'.format(instance['name'])) 148 | instance['state'] = 'de-sync' 149 | 150 | instances.append(instance) 151 | 152 | else: 153 | 154 | # Add the state of the instance 155 | instance['state'] = domains[instance['name']] 156 | 157 | instances.append(instance) 158 | 159 | return instances 160 | 161 | 162 | class Instance(object): 163 | """Handles creating, starting, stopping and removing virtual machines 164 | defined on the local system, using an existing :py:class:`Image`. 165 | """ 166 | 167 | def __init__(self, name, image=None, connection='qemu:///system', hostname=None): 168 | self.name = name 169 | self.image = image 170 | self.connection = connection 171 | self.path = "{}/instances/{}".format(config_data.DATA_DIR, self.name) 172 | self.seed_path = "{}/{}-seed.img".format(self.path, self.name) 173 | self.meta_path = "{}/meta".format(self.path) 174 | self.local_disk = "{}/{}-local.qcow2".format(self.path, self.name) 175 | self.xml_path = "{}/{}-domain.xml".format(self.path, self.name) 176 | 177 | self.ram = config_data.RAM 178 | # desired size of disk, in GiB 179 | self.disk_size = config_data.DISK_SIZE 180 | self.vnc = False 181 | self.graphics = False 182 | self.atomic = False 183 | self.seed = None 184 | self.kernel = None 185 | self.initrd = None 186 | self.hostname = hostname if hostname else config_data.HOSTNAME 187 | 188 | # get rid of 189 | self.backing_store = image.local_path if image else None 190 | self.image_path = config_data.STORE_DIR + self.name + ".qcow2" 191 | 192 | def prepare(self): 193 | """Create local directories and metadata needed to spawn the instance 194 | """ 195 | # create the dirs needed for this instance 196 | self._create_dirs() 197 | 198 | # generate metadata 199 | self._create_user_data(config_data.PASSWORD) 200 | self._create_meta_data(self.hostname) 201 | 202 | # generate seed image 203 | self._generate_seed_image() 204 | 205 | # deal with backing store 206 | self._create_local_disk() 207 | 208 | def _create_dirs(self): 209 | if not os.path.isdir(self.path): 210 | 211 | log.debug("Creating instance directories") 212 | os.makedirs(self.path) 213 | os.makedirs(self.meta_path) 214 | 215 | def _create_user_data(self, password, overwrite=False, atomic=False): 216 | """Save the right password to the 'user-data' file needed to 217 | emulate cloud-init. Default username on cloud images is "fedora" 218 | 219 | Will not overwrite an existing user-data file unless 220 | the overwrite kwarg is set to True.""" 221 | 222 | if atomic: 223 | file_data = config_data.ATOMIC_USER_DATA % password 224 | 225 | else: 226 | file_data = config_data.USER_DATA % password 227 | 228 | data_path = '{}/meta/user-data'.format(self.path) 229 | 230 | if (os.path.isfile(data_path) and overwrite) or not os.path.isfile(data_path): 231 | with open(data_path, 'w') as user_file: 232 | user_file.write(file_data) 233 | log.debug("Generated user-data for instance {}".format(self.name)) 234 | else: 235 | log.debug("user-data file already exists for instance {}. Not" 236 | " regerating.".format(self.name)) 237 | 238 | def _create_meta_data(self, hostname, overwrite=False): 239 | """Save the required hostname data to the 'meta-data' file needed to 240 | emulate cloud-init. 241 | 242 | Will not overwrite an existing user-data file unless 243 | the overwrite kwarg is set to True.""" 244 | 245 | file_data = config_data.META_DATA % hostname 246 | 247 | meta_path = "{}/meta-data".format(self.meta_path) 248 | if (os.path.isfile(meta_path) and overwrite) or not os.path.isfile(meta_path): 249 | with open(meta_path, 'w') as meta_data_file: 250 | meta_data_file.write(file_data) 251 | 252 | log.debug("Generated meta-data for instance {}".format(self.name)) 253 | else: 254 | log.debug("meta-data file already exists for instance {}. Not" 255 | " regerating.".format(self.name)) 256 | 257 | def _generate_seed_image(self): 258 | """Create a virtual filesystem needed for boot with virt-make-fs on a 259 | given path (it should probably be somewhere in '/tmp'.""" 260 | 261 | log.debug("creating seed image {}".format(self.seed_path)) 262 | 263 | make_image = subprocess.call(['virt-make-fs', 264 | '--type=msdos', 265 | '--label=cidata', 266 | self.meta_path, 267 | self.seed_path]) 268 | 269 | # Check the subprocess.call return value for success 270 | if make_image == 0: 271 | log.info("Seed image generated successfully") 272 | else: 273 | log.error("Seed image generation failed. Exiting") 274 | raise TestcloudInstanceError("Failure during seed image generation") 275 | 276 | def _extract_initrd_and_kernel(self): 277 | """Download the necessary kernel and initrd for booting a specified 278 | cloud image.""" 279 | 280 | if self.image is None: 281 | raise TestcloudInstanceError("attempted to access image " 282 | "information for instance {} but " 283 | "that information was not supplied " 284 | "at creation time".format(self.name)) 285 | 286 | log.info("extracting kernel and initrd from {}".format(self.image.local_path)) 287 | subprocess.call(['virt-builder', '--get-kernel', 288 | self.image.local_path], 289 | cwd=self.path) 290 | 291 | self.kernel = glob.glob("%s/*vmlinuz*" % self.path)[0] 292 | self.initrd = glob.glob("%s/*initramfs*" % self.path)[0] 293 | 294 | if self.kernel is None or self.initrd is None: 295 | raise IndexError("Unable to find kernel or initrd, did they " + 296 | "download?") 297 | sys.exit(1) 298 | 299 | def _create_local_disk(self): 300 | """Create a instance using the backing store provided by Image.""" 301 | 302 | if self.image is None: 303 | raise TestcloudInstanceError("attempted to access image " 304 | "information for instance {} but " 305 | "that information was not supplied " 306 | "at creation time".format(self.name)) 307 | 308 | imgcreate_command = ['qemu-img', 309 | 'create', 310 | '-f', 311 | 'qcow2', 312 | '-b', 313 | self.image.local_path, 314 | self.local_disk, 315 | ] 316 | 317 | # make sure to expand the resultant disk if the size is set 318 | if self.disk_size > 0: 319 | imgcreate_command.append("{}G".format(self.disk_size)) 320 | 321 | subprocess.call(imgcreate_command) 322 | 323 | def _get_domain(self): 324 | """Create the connection to libvirt to control instance lifecycle. 325 | returns: libvirt domain object""" 326 | conn = libvirt.open(self.connection) 327 | return conn.lookupByName(self.name) 328 | 329 | def create_ip_file(self, ip): 330 | """Write the ip address found after instance creation to a file 331 | for easier management later. This is likely going to break 332 | and need a better solution.""" 333 | 334 | with open("{}/instances/{}/ip".format(config_data.DATA_DIR, 335 | self.name), 'w') as ip_file: 336 | ip_file.write(ip) 337 | 338 | def write_domain_xml(self): 339 | """Load the default xml template, and populate it with the following: 340 | - name 341 | - uuid 342 | - locations of disks 343 | - network mac address 344 | """ 345 | 346 | # Set up the jinja environment 347 | jinjaLoader = jinja2.FileSystemLoader(searchpath=[config.DEFAULT_CONF_DIR, 348 | config_data.DATA_DIR]) 349 | jinjaEnv = jinja2.Environment(loader=jinjaLoader) 350 | xml_template = jinjaEnv.get_template(config_data.XML_TEMPLATE) 351 | 352 | # Stuff our values in a dict 353 | instance_values = {'domain_name': self.name, 354 | 'uuid': uuid.uuid4(), 355 | 'memory': self.ram * 1024, # MiB to KiB 356 | 'disk': self.local_disk, 357 | 'seed': self.seed_path, 358 | 'mac_address': util.generate_mac_address()} 359 | 360 | # Write out the final xml file for the domain 361 | with open(self.xml_path, 'w') as dom_template: 362 | dom_template.write(xml_template.render(instance_values)) 363 | 364 | return 365 | 366 | def spawn_vm(self): 367 | """Create and boot the instance, using prepared data.""" 368 | 369 | self.write_domain_xml() 370 | 371 | with open(self.xml_path, 'r') as xml_file: 372 | domain_xml = ''.join([x for x in xml_file.readlines()]) 373 | 374 | conn = libvirt.open(self.connection) 375 | conn.defineXML(domain_xml) 376 | 377 | def expand_qcow(self, size="+10G"): 378 | """Expand the storage for a qcow image. Currently only used for Atomic 379 | Hosts.""" 380 | 381 | log.info("expanding qcow2 image {}".format(self.image_path)) 382 | subprocess.call(['qemu-img', 383 | 'resize', 384 | self.image_path, 385 | size]) 386 | 387 | log.info("Resized image for Atomic testing...") 388 | return 389 | 390 | def set_seed(self, path): 391 | """Set the seed image for the instance.""" 392 | self.seed = path 393 | 394 | def boot(self, timeout=config_data.BOOT_TIMEOUT): 395 | """Deprecated alias for :py:meth:`start`""" 396 | 397 | log.warn("instance.boot has been depricated and will be removed in a " 398 | "future release, use instance.start instead") 399 | 400 | self.start(timeout) 401 | 402 | def start(self, timeout=config_data.BOOT_TIMEOUT): 403 | """Start an existing instance and wait up to :py:attr:`timeout` seconds 404 | for a network interface to appear. 405 | 406 | :param int timeout: number of seconds to wait before timing out. 407 | Setting this to 0 will disable timeout, default 408 | is configured with :py:const:`BOOT_TIMEOUT` config 409 | value. 410 | :raises TestcloudInstanceError: if there is an error while creating the 411 | instance or if the timeout is reached 412 | while looking for a network interface 413 | """ 414 | 415 | log.debug("Creating instance {}".format(self.name)) 416 | dom = self._get_domain() 417 | create_status = dom.create() 418 | 419 | # libvirt doesn't directly raise errors on boot failure, check the 420 | # return code to verify that the boot process was successful from 421 | # libvirt's POV 422 | if create_status != 0: 423 | raise TestcloudInstanceError("Instance {} did not start " 424 | "successfully, see libvirt logs for " 425 | "details".format(self.name)) 426 | log.debug("Polling instance for active network interface") 427 | 428 | poll_tick = 0.5 429 | timeout_ticks = timeout / poll_tick 430 | count = 0 431 | 432 | # poll libvirt for domain interfaces, returning when an interface is 433 | # found, indicating that the boot process is post-cloud-init 434 | while count <= timeout_ticks: 435 | domif = dom.interfaceAddresses(0) 436 | 437 | if len(domif) > 0 or timeout_ticks == 0: 438 | log.info("Successfully booted instance {}".format(self.name)) 439 | return 440 | 441 | count += 1 442 | time.sleep(poll_tick) 443 | 444 | # If we get here, the boot process has timed out 445 | raise TestcloudInstanceError("Instance {} has failed to boot in {} " 446 | "seconds".format(self.name, timeout)) 447 | 448 | def stop(self): 449 | """Stop the instance 450 | 451 | :raises TestcloudInstanceError: if the instance does not exist 452 | """ 453 | 454 | log.debug("stopping instance {}.".format(self.name)) 455 | 456 | domain_state = _find_domain(self.name, self.connection) 457 | 458 | if domain_state is None: 459 | raise TestcloudInstanceError("Instance doesn't exist: {}".format(self.name)) 460 | 461 | if domain_state == 'shutoff': 462 | log.debug('Instance already shut off, not stopping: {}'.format(self.name)) 463 | return 464 | 465 | # stop (destroy) the vm 466 | self._get_domain().destroy() 467 | 468 | def remove(self, autostop=True): 469 | """Remove an already stopped instance 470 | 471 | :param bool autostop: if the instance is running, stop it first 472 | :raises TestcloudInstanceError: if the instance does not exist, or is still 473 | running and ``autostop==False`` 474 | """ 475 | 476 | log.debug("removing instance {} from libvirt.".format(self.name)) 477 | 478 | # this should be changed if/when we start supporting configurable 479 | # libvirt connections 480 | domain_state = _find_domain(self.name, self.connection) 481 | 482 | if domain_state == 'running': 483 | if autostop: 484 | self.stop() 485 | else: 486 | raise TestcloudInstanceError( 487 | "Cannot remove running instance {}. Please stop the " 488 | "instance before removing.".format(self.name)) 489 | 490 | # remove from libvirt, assuming that it's stopped already 491 | if domain_state is not None: 492 | self._get_domain().undefine() 493 | log.debug("Unregistering instance from libvirt.") 494 | else: 495 | log.warn('Instance "{}" not found in libvirt "{}". Was it removed already? Should ' 496 | 'you have used a different connection?'.format(self.name, self.connection)) 497 | 498 | log.debug("removing instance {} from disk".format(self.path)) 499 | 500 | # remove from disk 501 | shutil.rmtree(self.path) 502 | 503 | def destroy(self): 504 | '''A deprecated method. Please call :meth:`remove` instead.''' 505 | 506 | log.debug('DEPRECATED: destroy() method was deprecated. Please use remove()') 507 | self.remove() 508 | -------------------------------------------------------------------------------- /testcloud/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2015, Red Hat, Inc. 4 | # License: GPL-2.0+ 5 | # See the LICENSE file for more details on Licensing 6 | 7 | """ 8 | This module contains helper functions for testcloud. 9 | """ 10 | 11 | import subprocess 12 | import logging 13 | 14 | import random 15 | import libvirt 16 | import xml.etree.ElementTree as ET 17 | 18 | from . import config 19 | 20 | log = logging.getLogger('testcloud.util') 21 | config_data = config.get_config() 22 | 23 | 24 | def get_vm_xml(instance_name, connection='qemu:///system'): 25 | """Query virsh for the xml of an instance by name.""" 26 | 27 | con = libvirt.openReadOnly(connection) 28 | try: 29 | domain = con.lookupByName(instance_name) 30 | 31 | except libvirt.libvirtError: 32 | return None 33 | 34 | result = domain.XMLDesc() 35 | 36 | return str(result) 37 | 38 | 39 | def find_mac(xml_string): 40 | """Pass in a virsh xmldump and return a list of any mac addresses listed. 41 | Typically it will just be one. 42 | """ 43 | 44 | xml_data = ET.fromstring(xml_string) 45 | 46 | macs = xml_data.findall("./devices/interface/mac") 47 | 48 | return macs 49 | 50 | 51 | def find_ip_from_mac(mac): 52 | """Look through ``arp -an`` output for the IP of the provided MAC address. 53 | """ 54 | 55 | arp_list = subprocess.check_output(["arp", "-an"]).split("\n") 56 | for entry in arp_list: 57 | if mac in entry: 58 | return entry.split()[1][1:-1] 59 | 60 | 61 | def generate_mac_address(): 62 | """Create a workable mac address for our instances.""" 63 | 64 | hex_mac = [0x52, 0x54, 0x00] # These 3 are the prefix libvirt uses 65 | hex_mac += [random.randint(0x00, 0xff) for x in range(3)] 66 | mac = ':'.join(hex(x)[2:] for x in hex_mac) 67 | 68 | return mac 69 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 99 3 | exclude = conf/*,docs/source/conf.py 4 | 5 | [pytest] 6 | minversion=2.0 7 | python_functions=test should 8 | python_files=test_* functest_* 9 | addopts=test/ --cov-report=term-missing --cov testcloud 10 | --------------------------------------------------------------------------------