juju-quickstart-1.3.1/0000755000175000017500000000000012320751720016246 5ustar frankbanfrankban00000000000000juju-quickstart-1.3.1/Makefile0000644000175000017500000000574312320523571017720 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2012-2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . PYTHON = python SYSDEPS = build-essential python-dev python-pip python-virtualenv VENV = .venv VENV_ACTIVATE = $(VENV)/bin/activate $(VENV_ACTIVATE): test-requirements.pip requirements.pip virtualenv --distribute -p $(PYTHON) $(VENV) $(VENV)/bin/pip install --use-mirrors -r test-requirements.pip || \ (touch test-requirements.pip; exit 1) @touch $(VENV_ACTIVATE) all: setup check: test lint clean: $(PYTHON) setup.py clean rm -rfv build/ dist/ juju_quickstart.egg-info MANIFEST rm -rfv $(VENV) find . -name '*.pyc' -delete find . -name '__pycache__' -type d -delete setup: $(VENV_ACTIVATE) help: @echo -e 'Juju Quickstart - list of make targets:\n' @echo 'make sysdeps - Install the development environment system packages.' @echo 'make - Set up the development and testing environment.' @echo 'make test - Run tests.' @echo 'make lint - Run linter and pep8.' @echo 'make check - Run tests, linter and pep8.' @echo 'make source - Create source package.' @echo 'make install - Install on local system.' @echo 'make run - Run the application in the development environment.\n' @echo ' If "juju switch" has been used to set a default environment, that' @echo ' environment will be used. It is possible to override the default' @echo ' Juju environment by setting the JUJU_ENV environment variable,' @echo ' e.g.: "make run JUJU_ENV=ec2".' @echo 'make debug - Same as the "run" target but with the --debug flag.\n' @echo 'make clean - Get rid of bytecode files, build and dist dirs, venv.' @echo 'make release - Register and upload a release on PyPI.' install: $(PYTHON) setup.py install rm -rfv ./build ./dist ./juju_quickstart.egg-info lint: setup @$(VENV)/bin/flake8 --show-source --exclude=$(VENV) ./quickstart release: check $(PYTHON) setup.py register sdist upload run: setup $(VENV)/bin/python ./juju-quickstart debug: setup $(VENV)/bin/python ./juju-quickstart --debug source: $(PYTHON) setup.py sdist sysdeps: sudo apt-get install --yes $(SYSDEPS) test: setup @$(VENV)/bin/nosetests -s --verbosity=2 \ --with-coverage --cover-package=quickstart quickstart @rm .coverage .PHONY: all clean check debug help install lint release run setup source \ sysdeps test juju-quickstart-1.3.1/requirements.pip0000644000175000017500000000221712310052400021471 0ustar frankbanfrankban00000000000000# Juju Quickstart application requirements. # This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # Note: since the source requirements are dynamically generated parsing the # requirements.pip file, the list below must only include PACKAGE==VERSION # formatted dependencies. Do not include other pip specific requirement # specifications (e.g. -e ... or -r ...). jujuclient==0.17.5 PyYAML==3.10 urwid==1.1.1 juju-quickstart-1.3.1/COPYING0000644000175000017500000010332712247627363017324 0ustar frankbanfrankban00000000000000 GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see .juju-quickstart-1.3.1/juju_quickstart.egg-info/0000755000175000017500000000000012320751720023167 5ustar frankbanfrankban00000000000000juju-quickstart-1.3.1/juju_quickstart.egg-info/SOURCES.txt0000644000175000017500000000262012320751714025056 0ustar frankbanfrankban00000000000000COPYING HACKING.rst MANIFEST.in Makefile README.rst juju-quickstart requirements.pip setup.py test-requirements.pip juju_quickstart.egg-info/PKG-INFO juju_quickstart.egg-info/SOURCES.txt juju_quickstart.egg-info/dependency_links.txt juju_quickstart.egg-info/not-zip-safe juju_quickstart.egg-info/requires.txt juju_quickstart.egg-info/top_level.txt quickstart/__init__.py quickstart/app.py quickstart/juju.py quickstart/manage.py quickstart/packaging.py quickstart/serializers.py quickstart/settings.py quickstart/ssh.py quickstart/utils.py quickstart/watchers.py quickstart/cli/__init__.py quickstart/cli/base.py quickstart/cli/forms.py quickstart/cli/ui.py quickstart/cli/views.py quickstart/models/__init__.py quickstart/models/charms.py quickstart/models/envs.py quickstart/models/fields.py quickstart/tests/__init__.py quickstart/tests/helpers.py quickstart/tests/test_app.py quickstart/tests/test_juju.py quickstart/tests/test_manage.py quickstart/tests/test_serializers.py quickstart/tests/test_ssh.py quickstart/tests/test_utils.py quickstart/tests/test_watchers.py quickstart/tests/cli/__init__.py quickstart/tests/cli/helpers.py quickstart/tests/cli/test_base.py quickstart/tests/cli/test_forms.py quickstart/tests/cli/test_ui.py quickstart/tests/cli/test_views.py quickstart/tests/models/__init__.py quickstart/tests/models/test_charms.py quickstart/tests/models/test_envs.py quickstart/tests/models/test_fields.pyjuju-quickstart-1.3.1/juju_quickstart.egg-info/dependency_links.txt0000644000175000017500000000000112320751714027240 0ustar frankbanfrankban00000000000000 juju-quickstart-1.3.1/juju_quickstart.egg-info/top_level.txt0000644000175000017500000000001312320751714025716 0ustar frankbanfrankban00000000000000quickstart juju-quickstart-1.3.1/juju_quickstart.egg-info/not-zip-safe0000644000175000017500000000000112314322015025407 0ustar frankbanfrankban00000000000000 juju-quickstart-1.3.1/juju_quickstart.egg-info/requires.txt0000644000175000017500000000005412320751714025571 0ustar frankbanfrankban00000000000000jujuclient==0.17.5 PyYAML==3.10 urwid==1.1.1juju-quickstart-1.3.1/juju_quickstart.egg-info/PKG-INFO0000644000175000017500000000604212320751714024271 0ustar frankbanfrankban00000000000000Metadata-Version: 1.1 Name: juju-quickstart Version: 1.3.1 Summary: Juju Quickstart is a Juju plugin which allows for easily setting up a Juju environment in very few steps. The environment is bootstrapped and set up so that it can be managed using a Web interface (the Juju GUI). Home-page: https://launchpad.net/juju-quickstart Author: The Juju GUI team Author-email: juju-gui@lists.ubuntu.com License: UNKNOWN Description: Juju Quickstart =============== Juju Quickstart is an opinionated command-line tool that quickly starts Juju and the GUI, whether you've never installed Juju or you have an existing Juju environment running. Features include the following: * New users are guided, as needed, to install Juju, set up SSH keys, and configure it for first use. * Juju environments can be created and managed from a command line interactive session. * The Juju GUI is automatically installed, adding no additional machines (installing on an existing state server when possible). * Bundles can be deployed, from local files, HTTP(S) URLs, or the charm store, so that a complete topology of services can be set up in one simple command. * Quickstart ends by opening the browser and automatically logging the user into the GUI, to observe and manage the environment visually. * Users with a running Juju environment can run the quickstart command again to simply re-open the GUI without having to find the proper URL and password. To start Juju Quickstart, run the following:: juju-quickstart [-i] Run ``juju-quickstart -h`` for a list of all the available options. Once Juju has been installed, the command can also be run as a juju plugin, without the hyphen (``juju quickstart``). Supported Versions ------------------ Juju Quickstart is available on Ubuntu releases 12.04 LTS (precise), 13.10 (saucy), and 14.04 LTS (trusty). For installation on precise and saucy, you'll need to enable the Juju PPA by first executing:: sudo add-apt-repository ppa:juju/stable sudo apt-get update sudo apt-get install juju-quickstart For trusty the PPA is not required and you simply need to install it with:: sudo apt-get install juju-quickstart Alternatively you may install Juju Quickstart via pip with:: pip install juju-quickstart Keywords: juju quickstart plugin Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Web Environment Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: GNU Affero General Public License v3 Classifier: Operating System :: POSIX Classifier: Programming Language :: Python Classifier: Topic :: System :: Installation/Setup juju-quickstart-1.3.1/test-requirements.pip0000644000175000017500000000163312310052400022447 0ustar frankbanfrankban00000000000000# Juju Quickstart test requirements. # This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . coverage==3.7.1 flake8==2.1.0 mock==1.0.1 nose==1.3.1 -r requirements.pip juju-quickstart-1.3.1/HACKING.rst0000644000175000017500000001506112301131064020037 0ustar frankbanfrankban00000000000000Juju Quickstart =============== Juju Quickstart is a Juju plugin which allows for easily setting up a Juju environment in very few steps. The environment is bootstrapped and set up so that it can be managed using a Web interface (the Juju GUI). Bundle deployments are also supported, and allow for setting up a complete topology of services in one simple command. Creating a development environment ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The development environment is created in a virtualenv. The environment creation requires the *make*, *pip* and *virtualenv* programs to be installed. To do that, run the following:: $ make sysdeps At this point, from the root of this branch, run the command:: $ make This command will create a ``.venv`` directory in the branch root, ignored by DVCSes, containing the development virtual environment with all the dependencies. Testing and debugging the application ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Run the tests:: $ make test Run the tests and lint/pep8 checks:: $ make check Display help about all the available make targets, including instructions on setting up and running the application in the development environment:: $ make help Installing the application ~~~~~~~~~~~~~~~~~~~~~~~~~~ To install Juju Quickstart in your local system, run the following:: $ sudo make install This command will take care of installing the requirements and the application itself. Running the application ~~~~~~~~~~~~~~~~~~~~~~~ juju-core will recognize Juju Quickstart as a plugin once the application is installed by the command above. At this point, the application can be started by running ``juju quickstart``. Run the following for the list of all available options:: $ juju quickstart --help Assuming a Juju environment named ``local`` is properly configured in your ``~/.juju/environments.yaml`` file, here is an example invocation:: $ juju quickstart -e local If you have not installed the application using ``sudo make install``, as described above, you can run it locally using the virtualenv's Python installation:: $ .venv/bin/python juju-quickstart --help Creating PPA releases ~~~~~~~~~~~~~~~~~~~~~ The packaging repository (including the ``debian`` directory) can be checked out from lp:~juju-gui/juju-quickstart/packaging/, e.g.:: $ bzr branch lp:~juju-gui/juju-quickstart/packaging/ packaging $ cd packaging Check that the packaging version reflects the latest Quickstart version. The packaging version can be found in the ``debian/changelog`` file present in the packaging branch root. To print the version of the current Quickstart, from the juju-quickstart branch root, run the following:: $ .venv/bin/python juju-quickstart --version If the ``debian/changelog`` file is outdated, install the ``devscripts`` package and use ``dch`` to update the changelog, e.g.:: $ sudo apt-get install devscripts $ dch -i # Executed from the packaging branch root. At this point, edit the changelog as required, commit and push the changes back to the packaging branch trunk, and follow the instructions below. The recipe for creating packages is in . We currently publish beta releases on the Juju Quickstart Beta PPA: see . When a beta release is ready to be published, we move over the packages from the Juju Quickstart Beta PPA to the juju stable packages PPA in . Packages depend on `python-jujuclient` and `python-websocket-client` to be available. They are available in saucy, and they are also stored in our PPA in order to support previous Ubuntu releases. Creating PyPI releases ~~~~~~~~~~~~~~~~~~~~~~ Juju Quickstart is present on PyPI: see . It is possible to register and upload a new release on PyPI by just running ``make release`` and providing your PyPI credentials. Updating application and test dependencies ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Test dependencies are listed in the ``test-requirements.pip`` file in the branch root, application ones in the ``requirements.pip`` file. The former includes the latter, so any updates to the application requirements will also update the test dependencies and therefore the testing virtual environment. Note that, since the source requirements are dynamically generated parsing ``requirements.pip``, that file must only include PACKAGE==VERSION formatted dependencies, and not other pip specific requirement specifications. Also ensure, before updating the application dependencies, that those packages are available in the main Ubuntu repositories for the series we support (from precise to saucy), or in the Juju Quickstart Beta PPA: see . Please also keep up to date the possible values for the environments.yaml default-series field (see ``quickstart.settings.JUJU_DEFAULT_SERIES``) and the set of series supported by the Juju GUI charm (see ``quickstart.settings.JUJU_GUI_SUPPORTED_SERIES``). Debugging bundle support ~~~~~~~~~~~~~~~~~~~~~~~~ When deploying a bundle, Quickstart just start the import process sending an API request to the GUI charm builtin server, and then lets the user observe the deployment process using the GUI. Under the hood, a bundle deployment is executed by the GUI builtin server, which in turn leverages the juju-deployer library. Since juju-deployer is not asynchronous, the actual deployment is executed in a separate process. Sometimes, when an error occurs, it is not obvious where to retrieve information about what is going on. The GUI builtin server exposes some bundle information in two places: - https:///gui-server-info displays in JSON format the current status of all scheduled/started/completed bundle deployments; - /var/log/upstart/guiserver.log is the builtin server log file, which includes logs output from the juju-deployer library. Moreover, setting `builtin-server-logging=debug` gives more debugging information, e.g. it prints to the log the contents of the WebSocket messages sent by the client (usually the Juju GUI) and by the Juju API server. As mentioned, juju-deployer works on its own sandbox and uses its own API connections, and for this reason the WebSocket traffic it generates is not logged. Sometimes, while debugging, it is convenient to restart the builtin server (which also empties the bundle deployments queue). To do that, run the following in the Juju GUI machine: service guiserver restart juju-quickstart-1.3.1/setup.cfg0000644000175000017500000000007312320751720020067 0ustar frankbanfrankban00000000000000[egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 juju-quickstart-1.3.1/juju-quickstart0000755000175000017500000000216112251372515021345 0ustar frankbanfrankban00000000000000#!/usr/bin/env python # This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart plugin entry point.""" from __future__ import unicode_literals import sys from quickstart import ( app, manage, ) if __name__ == '__main__': options = manage.setup() try: manage.run(options) except app.ProgramExit as err: sys.exit(bytes(err)) juju-quickstart-1.3.1/setup.py0000644000175000017500000000500412272750554017771 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart distribution file.""" import os from setuptools import ( find_packages, setup, ) ROOT = os.path.abspath(os.path.dirname(__file__)) PROJECT_NAME = 'quickstart' project = __import__(PROJECT_NAME) description_path = os.path.join(ROOT, 'README.rst') requirements_path = os.path.join(ROOT, 'requirements.pip') requirements = [i.strip() for i in open(requirements_path).readlines()] install_requires = [i for i in requirements if i and not i.startswith('#')] os.chdir(ROOT) data_files = [] for dirpath, dirnames, filenames in os.walk(PROJECT_NAME): for i, dirname in enumerate(dirnames): if dirname.startswith('.'): del dirnames[i] if '__init__.py' in filenames: continue elif filenames: for f in filenames: data_files.append(os.path.join( dirpath[len(PROJECT_NAME) + 1:], f)) setup( name='juju-quickstart', version=project.get_version(), description=project.__doc__, long_description=open(description_path).read(), author='The Juju GUI team', author_email='juju-gui@lists.ubuntu.com', url='https://launchpad.net/juju-quickstart', keywords='juju quickstart plugin', packages=find_packages(), package_data={PROJECT_NAME: data_files}, scripts=['juju-quickstart'], install_requires=install_requires, zip_safe=False, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: GNU Affero General Public License v3', 'Operating System :: POSIX', 'Programming Language :: Python', 'Topic :: System :: Installation/Setup', ], ) juju-quickstart-1.3.1/MANIFEST.in0000644000175000017500000000224112310316775020011 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2012-2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . include COPYING include HACKING.rst include MANIFEST.in include Makefile include README.rst # Note: since the source requirements are dynamically generated parsing the # requirements.pip file, removing it from this list will break the source # distribution and therefore the Debian package builds. Do not do that. include requirements.pip include test-requirements.pip juju-quickstart-1.3.1/README.rst0000644000175000017500000000346212314144222017736 0ustar frankbanfrankban00000000000000Juju Quickstart =============== Juju Quickstart is an opinionated command-line tool that quickly starts Juju and the GUI, whether you've never installed Juju or you have an existing Juju environment running. Features include the following: * New users are guided, as needed, to install Juju, set up SSH keys, and configure it for first use. * Juju environments can be created and managed from a command line interactive session. * The Juju GUI is automatically installed, adding no additional machines (installing on an existing state server when possible). * Bundles can be deployed, from local files, HTTP(S) URLs, or the charm store, so that a complete topology of services can be set up in one simple command. * Quickstart ends by opening the browser and automatically logging the user into the GUI, to observe and manage the environment visually. * Users with a running Juju environment can run the quickstart command again to simply re-open the GUI without having to find the proper URL and password. To start Juju Quickstart, run the following:: juju-quickstart [-i] Run ``juju-quickstart -h`` for a list of all the available options. Once Juju has been installed, the command can also be run as a juju plugin, without the hyphen (``juju quickstart``). Supported Versions ------------------ Juju Quickstart is available on Ubuntu releases 12.04 LTS (precise), 13.10 (saucy), and 14.04 LTS (trusty). For installation on precise and saucy, you'll need to enable the Juju PPA by first executing:: sudo add-apt-repository ppa:juju/stable sudo apt-get update sudo apt-get install juju-quickstart For trusty the PPA is not required and you simply need to install it with:: sudo apt-get install juju-quickstart Alternatively you may install Juju Quickstart via pip with:: pip install juju-quickstart juju-quickstart-1.3.1/quickstart/0000755000175000017500000000000012320751720020440 5ustar frankbanfrankban00000000000000juju-quickstart-1.3.1/quickstart/settings.py0000644000175000017500000000440612317567755022701 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013-2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart settings.""" from __future__ import unicode_literals import os # The URL containing information about the last Juju GUI charm version. CHARMWORLD_API = 'http://manage.jujucharms.com/api/3/charm/precise/juju-gui' # The default Juju GUI charm URL to use when it is not possible to retrieve it # from the charmworld API, e.g. due to temporary connection/charmworld errors. DEFAULT_CHARM_URL = 'cs:precise/juju-gui-86' # The quickstart app short description. DESCRIPTION = 'set up a Juju environment (including the GUI) in very few steps' # The URL namespace for bundles in jujucharms.com. JUJUCHARMS_BUNDLE_URL = 'https://jujucharms.com/bundle/' # The path to the Juju command. JUJU_CMD = '/usr/bin/juju' # The possible values for the environments.yaml default-series field. JUJU_DEFAULT_SERIES = ('precise', 'quantal', 'raring', 'saucy', 'trusty') # Retrieve the current juju-core home. JUJU_HOME = os.getenv('JUJU_HOME', '~/.juju') # The name of the Juju GUI charm. JUJU_GUI_CHARM_NAME = 'juju-gui' # The name of the Juju GUI service. JUJU_GUI_SERVICE_NAME = JUJU_GUI_CHARM_NAME # The set of series supported by the Juju GUI charm. JUJU_GUI_SUPPORTED_SERIES = ('precise',) # The preferred series for the Juju GUI charm. It will be the newest, # assuming our naming convention holds. JUJU_GUI_PREFERRED_SERIES = sorted(JUJU_GUI_SUPPORTED_SERIES).pop() # The minimum Juju GUI charm revision supporting bundle deployments. MINIMUM_CHARM_REVISION_FOR_BUNDLES = 80 juju-quickstart-1.3.1/quickstart/cli/0000755000175000017500000000000012320751720021207 5ustar frankbanfrankban00000000000000juju-quickstart-1.3.1/quickstart/cli/base.py0000644000175000017500000001600612261541244022500 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart Urwid application base handling. A collection of objects which help in building a Quickstart CLI application skeleton. Views use these functions to set up the Urwid top widget and to start the application main loop. See the quickstart.cli.views module docstring for further details. """ from __future__ import unicode_literals from collections import namedtuple import urwid from quickstart.cli import ui # Define the application as a named tuple of callables. App = namedtuple( 'App', [ 'set_title', 'get_title', 'set_contents', 'get_contents', 'set_status', 'get_status', 'set_message', 'get_message', 'set_return_value_on_exit', ], ) class _MainLoop(urwid.MainLoop): """A customized Urwid loop. Allow for setting the unhandled_input callable after the loop initialization. """ def set_unhandled_input(self, unhandled_input): """Set the unhandled_input callable. The passed unhandled_input is a callable called when input is not handled by the application top widget. """ self._unhandled_input = unhandled_input def get_alarms(self): """Return all the alarms set for this loop. Improves the level of event loop introspection so that code and tests can easily access the alarms list. The alarms list is a sequence of (time, callback) tuples. """ return self.event_loop._alarms def setup_urwid_app(): """Configure a Urwid application suitable for being used by views. Build the Urwid top widget and instantiate a main loop. The top widget is basically a frame, composed by a header, some contents, and a footer. This application skeleton is Quickstart branded, and exposes functions that can be used by views to change the contents of the frame. Return a tuple (loop, app) where loop is the interactive session main loop (ready to be started invoking loop.run()) and app is a named tuple of callables exposing an API to be used by views to customize the application. The API exposed by app is limited by design, and includes: - set_title(text): set/change the title displayed in the application header (e.g.: app.set_title('my title')); - get_title(): return the current application title; - set_contents(widget): set/change the application body contents. A Urwid ListBox widget instance is usually provided, which replaces the current application contents; - get_contents(): return the current application contents widget; - set_status(text): set/change the status text displayed in the application footer (e.g.: set_status('press play on tape')). The status message can also be passed as a (style, text) tuple, as usual in Urwid code, e.g.: app.set_status(('error', 'error message')); - get_status(): return the current status message; - set_message(text): set/change a notification message, which is displayed in the footer for a couple of seconds before disappearing; - get_message(): return the message currently displayed in the notifications area; - set_return_value_on_exit(value): set the value to be encapsulated in the AppExit exception raised when the user quits the application with the exit shortcut. See the quickstart.cli.views module docstring for more information about this functionality. """ # Set up the application header. title = urwid.Text('\npreparing...') header_line = urwid.Divider('\N{LOWER ONE QUARTER BLOCK}') header = urwid.Pile([ urwid.AttrMap(ui.padding(title), 'header'), urwid.AttrMap(header_line, 'line header'), urwid.Divider(), ]) # Set up the application default contents. # View code is assumed to replace the placeholder widget using # app.set_contents(widget). placeholder = urwid.ListBox(urwid.SimpleFocusListWalker([])) contents = ui.padding(urwid.AttrMap(placeholder, None)) def set_contents(widget): contents.original_widget = widget # Set up the application footer. # The CTRL-x shortcut is automatically set up by views.show(). message = urwid.Text('', align='center') base_status = urwid.Text('^X exit ') status = urwid.Text('') status_columns = urwid.Columns([('pack', base_status), status]) footer_line = urwid.Divider('\N{UPPER ONE EIGHTH BLOCK}') footer = urwid.Pile([ message, urwid.Divider(), urwid.AttrMap(ui.padding(status_columns), 'footer'), urwid.AttrMap(footer_line, 'line footer'), ]) # Compose the components in a frame, and set up the top widget. The top # widget is the topmost widget used for painting the screen. page = urwid.Frame(contents, header=header, footer=footer) top_widget = urwid.Overlay( page, urwid.SolidFill('\N{MEDIUM SHADE}'), align='center', width=('relative', 90), valign='middle', height=('relative', 90), min_width=78, min_height=20) # Instantiate the Urwid main loop. loop = _MainLoop( top_widget, palette=ui.PALETTE, unhandled_input=ui.exit_and_return(None)) # Add a timeout to the notification message. timeout_message = ui.TimeoutText( message, 3, loop.set_alarm_in, loop.remove_alarm) # Allow views to set the value returned when the user quits the session. def set_return_value_on_exit(return_value): unhandled_input = ui.exit_and_return(return_value) loop.set_unhandled_input(unhandled_input) # Create the App named tuple. If, in the future, we have a view that # requires additional capabilities or API access, this is the place to add # those. app = App( set_title=lambda msg: title.set_text('\n{}'.format(msg)), get_title=lambda: title.text.lstrip(), set_contents=set_contents, get_contents=lambda: contents.original_widget, set_status=lambda msg: status.set_text(msg), get_status=lambda: status.text, set_message=lambda msg: timeout_message.set_text(('message', msg)), get_message=lambda: timeout_message.text, set_return_value_on_exit=set_return_value_on_exit, ) return loop, app juju-quickstart-1.3.1/quickstart/cli/ui.py0000644000175000017500000002267712263254230022214 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart Urwid related utility objects.""" from __future__ import unicode_literals import functools import urwid # Define the shortcut used to quit the interactive session. EXIT_KEY = 'ctrl x' # Define the color palette used by the Urwid application. PALETTE = [ # Class name, foreground color, background color. # See . (None, 'light gray', 'black'), ('dialog', 'dark gray', 'light gray'), ('dialog header', 'light gray,bold', 'dark blue'), ('control alert', 'light red', 'light gray'), ('controls', 'dark gray', 'light gray'), ('edit', 'white,underline', 'black'), ('error', 'light red', 'black'), ('error status', 'light red', 'light gray'), ('error selected', 'light red', 'dark blue'), ('field button', 'white,underline', 'black'), ('field button selected', 'white,underline', 'dark blue'), ('footer', 'black', 'light gray'), ('message', 'white', 'dark green'), ('header', 'white', 'dark magenta'), ('highlight', 'white', 'black'), ('line header', 'dark gray', 'dark magenta'), ('line footer', 'light gray', 'light gray'), ('optional', 'light magenta', 'black'), ('optional status', 'light magenta', 'light gray'), ('selected', 'white', 'dark blue'), ] # Map attributes to new attributes to apply when the widget is selected. FOCUS_MAP = { None: 'selected', 'control alert': 'error selected', 'error': 'error selected', 'field button': 'field button selected', 'highlight': 'selected', } # Define a default padding for the Urwid application. padding = functools.partial(urwid.Padding, left=2, right=2) class AppExit(Exception): """Used by views to stop the interactive execution returning a value.""" def __init__(self, return_value=None): """Set the value to return to the view caller (default is None).""" self.return_value = return_value def __str__(self): return b'{}: {!r}'.format(self.__class__.__name__, self.return_value) def exit_and_return(return_value): """Return a function that can be used as unhandled_input for an Urwid app. The resulting function terminates the interactive session with the given return_value when the user hits CTRL-x. """ def unhandled_input(key): if key == EXIT_KEY: raise AppExit(return_value) return unhandled_input def create_controls(*args): """Create a row of control widgets surrounded by line boxes.""" controls = urwid.Columns([padding(urwid.LineBox(arg)) for arg in args]) return urwid.Pile([ urwid.Divider(top=1, bottom=1), urwid.AttrMap(controls, 'controls') ]) class MenuButton(urwid.Button): """A customized Urwid button widget. This behaves like a regular button, but also takes a callback that is called when the button is clicked. """ def __init__(self, caption, callback): super(MenuButton, self).__init__('') urwid.connect_signal(self, 'click', callback) icon = urwid.SelectableIcon(caption, 0) # Replace the original widget: it seems ugly but it is Urwid idiomatic. self._w = urwid.AttrMap(icon, None, FOCUS_MAP) def show_dialog( app, title, message, actions=None, dismissable=True, width=None, height=None): """Display an interactive modal dialog. This function receives the following arguments: - app: the App named tuple used to configure the current interactive session (see the quickstart.cli.base module); - title: the title of the message dialog; - message: the help message displayed in the dialog; - actions: the actions which can be executed from the dialog, as a sequence of (caption, callback) tuples. Those pairs are used to generate the clickable controls (MenuButton instances) displayed in the dialog; - dismissable: if set to True, a "cancel" button is prepended to the list of controls. Clicking the "cancel" button, the dialog just disappears without further changes to the state of the application; - width and height: optional dialog width and height as defined in Urwid, e.g. 'pack', 'relative' or the number of rows/columns. If not provided, the function tries to generate suitable defaults. Return a function that can be called to dismiss the dialog. """ original_contents = app.get_contents() # Set up the dialog's header. header = urwid.Pile([ urwid.Divider(), urwid.Text(title, align='center'), urwid.Divider(), ]) # Set up the controls displayed in the dialog. if actions is None: controls = [] else: controls = [ MenuButton(caption, callback) for caption, callback in actions] # The dialog is removed by restoring the view original contents. dismiss = thunk(app.set_contents, original_contents) if dismissable: controls.insert(0, MenuButton('cancel', dismiss)) # Create the listbox that replaces the original view contents. widgets = [ urwid.AttrMap(header, 'dialog header'), urwid.Divider(), urwid.Text(message, align='center'), create_controls(*controls), ] listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) if width is None: # Calculate the dialog width: the max from the title/message length + # two padding spaces for each side. width = max(len(title), len(message)) + 4 if height is None: # A height of eleven is usually enough for the dialog. height = 11 contents = urwid.Overlay( urwid.AttrMap(listbox, 'dialog'), original_contents, align='center', width=width, valign='middle', height=height) app.set_contents(contents) return dismiss class TabNavigationListBox(urwid.ListBox): """A ListBox supporting tab navigation.""" key_conversion_map = {'tab': 'down', 'shift tab': 'up'} def keypress(self, size, key): """Override to convert tabs to up/down keys.""" key = self.key_conversion_map.get(key, key) return super(TabNavigationListBox, self).keypress(size, key) def thunk(function, *args, **kwargs): """Create and return a callable binding the given method and args/kwargs. This is useful when the given function is used as a signal subscriber, e.g. as a callback to be called when an Urwid signal is sent. In most cases, the widget which generated the event is sent as first argument to the callback. Moreover, urwid.connect_signal handles only one user argument. See . This function helps when the callback does not require the original widget and/or when it instead requires more than one argument, e.g.: def save(contents, commit=False): ... button = MenuButton('save and commit', ui.thunk(save, contents, True)) This example uses the MenuButton widget defined above in this module. """ def callback(*ignored_args, **ignored_kwargs): return function(*args, **kwargs) return callback class TimeoutText(object): """Wrap urwid.Text widget instances. The resulting widget, when set_text is called, displays text messages only for the given number of seconds. """ def __init__(self, widget, seconds, set_alarm, remove_alarm): """Create the wrapper widget. Receives the text widget to be wrapped, the number of seconds before the message disappears, the functions used to set and to remove an alarm on the loop (usually loop.set_alarm_in and loop.remove_alarm). """ self.original_widget = widget self.seconds = seconds self._set_alarm = set_alarm self._remove_alarm = remove_alarm self._handle = None def __getattr__(self, attr): """Allow access to the original widget's attributes.""" return getattr(self.original_widget, attr) def set_text(self, text): """Set the text message on the original widget. Set up an alert that will clear the message after the given number of seconds. Remove any previously set alarms if required. """ handle = self._handle if handle is not None: self._remove_alarm(handle) self.original_widget.set_text(text) self._handle = self._set_alarm(self.seconds, self._alarm_callback) def _alarm_callback(self, *args): """Remove the message from the original widget. This method is called by the alarm set up in self.set_text(). """ self.original_widget.set_text('') self._handle = None juju-quickstart-1.3.1/quickstart/cli/forms.py0000644000175000017500000002351712265766334022736 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart CLI forms management. This module contains a collection of functions which help creating and manipulating forms in Urwid. """ from __future__ import unicode_literals import functools import urwid from quickstart.cli import ui # Define the value used in boolean widgets to specify they allow mixed state. MIXED = 'mixed' def _generate(generate_callable, edit_widget): """Update the widget contents using the given generate_callable. The passed callable function takes no arguments and returns a string. """ edit_widget.set_edit_text(generate_callable()) def _create_generate_widget(generate_callable, edit_widget): """Create and return a button widget used to generate values for a field. Receives the generate callable and the target edit widget. """ generate_callback = ui.thunk(_generate, generate_callable, edit_widget) generate_button = ui.MenuButton( ('field button', 'Click here to automatically generate'), generate_callback) return urwid.Columns([ ('pack', generate_button), urwid.Text(' this value'), ]) def _create_buttons_grid_widget(choices, edit_widget): """Create and return a grid of button widgets. Buttons are associated with the given choices, and when clicked, udpate the given edit_widget's text. """ callback = edit_widget.set_edit_text buttons = [ ui.MenuButton(('field button', choice), ui.thunk(callback, choice)) for choice in choices ] cell_width = max(button.base_widget.pack()[0] for button in buttons) return urwid.GridFlow(buttons, cell_width, 1, 0, 'left') def _create_choices_widget(choices, required, edit_widget): """Create and return a choices widget. The widget displays the given choices for a specific form field. Clicking a choice updates the corresponding edit widget. """ widgets = [urwid.Text('possible values are:')] buttons_grid = _create_buttons_grid_widget(choices, edit_widget) widgets.append(buttons_grid) if not required: button = ui.MenuButton( ('field button', 'left empty'), ui.thunk(edit_widget.set_edit_text, '')) widgets.append(urwid.Columns([ ('pack', urwid.Text('but this field can also be ')), button, ])) help = urwid.Text( 'click the choices to auto-fill the field with the standard options') widgets.append(help) return urwid.Pile(widgets) def create_string_widget(field, value, error): """Create a string widget and return a tuple (widget, value_getter). Receives a Field instance (see quickstart.models.fields), the field value, and an error string (or None if the field has no errors). In the returned tuple, widget is a Urwid widget suitable for editing string values, and value_getter is a callable returning the value currently stored in the widget. The value_getter callable must be called without arguments. """ if value is None: # Unset values are converted to empty strings. value = '' elif not isinstance(value, unicode): # We do not expect byte strings, and all other values are converted to # unicode strings. value = unicode(value) caption_class = 'highlight' if field.required else 'optional' caption = [] widgets = [] if error: caption.append(('error', '\N{BULLET} ')) # Display the error message above the edit widget. widgets.append(urwid.Text(('error', error))) caption.append((caption_class, '{}: '.format(field.label))) edit_widget = urwid.Edit(edit_text=value) widget = urwid.Columns([('pack', urwid.Text(caption)), edit_widget]) if field.readonly: # Disable the widget if the field is not editable. widgets.append(urwid.WidgetDisable(widget)) else: widgets.append(urwid.AttrMap(widget, 'edit')) if field.help: # Display the field help message below the edit widget. widgets.append(urwid.Text(field.help)) if not field.readonly: # Can we display suggestions for this field? suggestions = getattr(field, 'suggestions', ()) if suggestions: widgets.append( _create_buttons_grid_widget(suggestions, edit_widget)) # Can the value be automatically generated? generate_callable = getattr(field, 'generate', None) if generate_callable is not None: widgets.append( _create_generate_widget(generate_callable, edit_widget)) # If the value must be in a range of choices, display the possible # choices as part of the help message. choices = getattr(field, 'choices', None) if choices is not None: choices_widget = _create_choices_widget( tuple(choices), field.required, edit_widget) widgets.append(choices_widget) if field.default is not None: widgets.append( urwid.Text('default if not set: {}'.format(field.default))) widgets.append(urwid.Divider()) return urwid.Pile(widgets), edit_widget.get_edit_text def create_bool_widget(field, value, error): """Create a boolean widget and return a tuple (widget, value_getter). Receives a Field instance (see quickstart.models.fields), the field value, and an error string (or None if the field has no errors). In the returned tuple, widget is a Urwid widget suitable for editing boolean values (a checkbox), and value_getter is a callable returning the value currently stored in the widget. The value_getter callable receives no arguments. """ if value is None: # Unset values are converted to a more convenient value for the # checkbox (a boolean or a mixed state if allowed by the field). value = MIXED if field.allow_mixed else bool(field.default) label = ('highlight', field.label) widget = urwid.CheckBox(label, state=value, has_mixed=field.allow_mixed) widgets = [] if error: # Display the error message above the checkbox. widgets.append(urwid.Text(('error', error))) if field.readonly: # Disable the widget if the field is not editable. widgets.append(urwid.WidgetDisable(widget)) else: widgets.append(widget) if field.help: # Display the field help message below the checkbox. widgets.append(urwid.Text(field.help)) widgets.append(urwid.Divider()) def get_state(): # Reconvert mixed value to None value. state = widget.get_state() return None if state == MIXED else state return urwid.Pile(widgets), get_state def create_form(field_value_pairs, errors): """Create and return the form widgets for each field/value pair. Return a tuple (widgets, values_getter) in which: - widgets is a list if Urwid objects that can be used to build view contents (e.g. by wrapping them in a urwid.ListBox); - values_getter is a function returning a dictionary mapping field names to values. By calling the values_getter function it is always possible to retrieve the current new field values, even if they have been changed by the user. The field_value_pairs argument is a list of (field, value) tuples where field is a Field instance (see quickstart.models.fields) and value is the corresponding field's value. The errors argument is a dictionary mapping field names to error messages. Passing an empty dictionary means the form has no errors. """ form = {} widgets = [] if errors: # Inform the user that the form has errors that need to be fixed. num_errors = len(errors) msg = 'error' if num_errors == 1 else '{} errors'.format(num_errors) widgets.extend([ urwid.Text(('error', 'please correct the {} below'.format(msg))), urwid.Divider(), ]) # Build a widget and populate the form for each field/value pair. for field, value in field_value_pairs: error = errors.get(field.name) if field.field_type == 'bool': # Boolean values are represented as checkboxes. widget_factory = create_bool_widget else: # All the other field types are displayed as strings. widget_factory = create_string_widget widget, value_getter = widget_factory(field, value, error) widgets.append(widget) form[field.name] = value_getter return widgets, functools.partial(_get_data, form) def _get_data(form): """Return a dictionary mapping the given form field names to values. This is done just calling all the value getters in the form. """ return dict((key, value_getter()) for key, value_getter in form.items()) def create_actions(actions): """Return the control widgets based on the given actions. The actions arguments is as a sequence of (caption, callback) tuples. Those pairs are used to generate the clickable controls (MenuButton instances) used to manipulate the form. """ return ui.create_controls( *(ui.MenuButton(caption, callback) for caption, callback in actions)) juju-quickstart-1.3.1/quickstart/cli/views.py0000644000175000017500000004662012265766334022745 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart CLI application views. This module contains the Quickstart view implementations along with a function (show) to easily start a view automatically creating an Urwid application. To start a Quickstart interactive session, just run the following: show(view, *args) The code above sets up a Quickstart branded CLI application, then calls the given view callable passing the application object ready to be configured and all the given optional arguments. Finally the interactive session is started, and the show function blocks until the user or the view itself request to exit the application. A view is a callable receiving an App object (a named tuple of functions) and other optional arguments (based on specific view needs). A view function can configure the Urwid application using the API exposed by the application object (see quickstart.cli.base.setup_urwid_app). Assume a view is defined like the following: def myview(app, title): app.set_title(title) app.set_return_value_on_exit(42) The view above, requiring a title argument, can be started this way: show(myview, 'this title will be shown in the header') At this point the application main loop is started, and the user can interact with the CLI interface. There are two ways to stop the interactive session: 1) the user explicitly requests to exit. The Urwid application is automatically configured to allow the user to quit whenever she wants by pressing a keyboard shortcut; 2) a view decides it is time to quit (e.g. reacting to an event/input). In both cases, the show function returns something to the caller: 1) when the user explicitly requests to quit, None is returned by default. However, the view can change this default value by calling app.set_return_value_on_exit(some value). For instance, the show(myview...) call above would return 42. It is safe to call the set_return_value_on_exit API multiple times in order to overwrite the value returned on user exit; 2) to force the end of the interactive session, a view can raise a quickstart.cli.ui.AppExit exception, passing a return value: if the application is exited this way, then show() returns the value encapsulated in the exception. Note that this exception can be raised as a reaction to an event, and not in the first execution of the view body, i.e. during the app configuration. The above is better described by code: from quickstart.cli import views, ui def button_view(app): def exit(): raise ui.AppExit(True) app.set_return_value_on_exit(False) app.set_title('behold the button below') button = ui.MenuButton('press to exit', ui.thunk(exit)) widgets = urwid.ListBox(urwid.SimpleFocusListWalker([button])) app.set_contents(widgets) pressed = views.show(button_view) In this example the button_view function configures the App to show a button. Clicking that button an AppExit(True) is raised. The return value on exit instead is set by the view itself to False. This means that "pressed" will be True if the user exited using the button, or False if the user exited using the global shortcut. As a final note, it is absolutely safe for a view to call, directly or indirectly, other views, as long as all the arguments required by the other views, including the App object, are properly provided. This is effectively the proposed solution to build multi-views CLI applications in Quickstart. """ from __future__ import unicode_literals import copy import functools import operator import urwid from quickstart import settings from quickstart.cli import ( base, forms, ui, ) from quickstart.models import envs def show(view, *args): """Start an Urwid interactive session showing the given view. The view is called passing an App named tuple and the provided *args. Block until the main loop is stopped, either by the user with the exit shortcut or by the view itself. In both cases, an ui.AppExit is raised, and the return value is encapsulated in the exception. """ loop, app = base.setup_urwid_app() try: # Execute the view. view(app, *args) # Start the Urwid interactive session (main loop). loop.run() except ui.AppExit as err: return err.return_value def env_index(app, env_type_db, env_db, save_callable): """Show the Juju environments list. The env_detail view is displayed when the user clicks on an environment. From here it is also possible to switch to the edit view in order to create a new environment. Receives: - env_type_db: the environments meta information; - env_db: the environments database; - save_callable: a function called to save a new environment database. """ env_db = copy.deepcopy(env_db) # All the environment views return a tuple (new_env_db, env_data). # Set the env_data to None in the case the user quits the application # without selecting an environment to use. app.set_return_value_on_exit((env_db, None)) detail_view = functools.partial( env_detail, app, env_type_db, env_db, save_callable) edit_view = functools.partial( env_edit, app, env_type_db, env_db, save_callable) # Alphabetically sort the existing environments. environments = sorted([ envs.get_env_data(env_db, env_name) for env_name in env_db['environments'] ], key=operator.itemgetter('name')) def create_and_start_local_env(): # Automatically create and use a local environment named "local". # This closure can only be called when there are no environments in the # database. For this reason, the new environment is set as default. # Exit the interactive session selecting the newly created environment. env_data = envs.create_local_env_data( env_type_db, 'local', is_default=True) # Add the new environment to the environments database. envs.set_env_data(env_db, None, env_data) save_callable(env_db) # Use the newly created environment. raise ui.AppExit((env_db, env_data)) if environments: title = 'Select an existing Juju environment or create a new one' widgets = [ urwid.Text(('highlight', 'Manage existing environments:')), urwid.Divider(), ] else: title = 'No Juju environments already set up: please create one' widgets = [ urwid.Text([ ('highlight', 'Welcome to Juju Quickstart!'), '\nYou can use this interactive session to manage your Juju ' 'environments.\nInteractive mode has been automatically ' 'started because no environments have been found. After ' 'creating your first environment you can start Juju ' 'Quickstart in interactive mode again by passing the -i flag, ' 'e.g.:\n', ('highlight', '$ juju quickstart -i'), '\n\nAt the bottom of the page you can find links to manually ' 'create new environments. If you instead prefer to quickly ' 'start your Juju experience in a local environment (LXC), ' 'just click the link below:' ]), urwid.Divider(), ui.MenuButton( '\N{BULLET} automatically create and bootstrap a local ' 'environment', ui.thunk(create_and_start_local_env)), ] app.set_title(title) # Start creating the page contents: a list of selectable environments. # Wouldn't it be nice if we were able to highlight in some way the # currently running environments? Unfortunately this requires calling # "juju status" for each environment in the list, which is expensive and # time consuming. focus_position = None errors_found = default_found = False existing_widgets_num = len(widgets) for position, env_data in enumerate(environments): bullet = '\N{BULLET}' # Is this environment the default one? if env_data['is-default']: default_found = True # The first two positions are the section header and the divider. focus_position = position + existing_widgets_num bullet = '\N{CHECK MARK}' # Is this environment valid? env_metadata = envs.get_env_metadata(env_type_db, env_data) errors = envs.validate(env_metadata, env_data) if errors: errors_found = True bullet = ('error', bullet) # Create a label for the environment. env_short_description = envs.get_env_short_description(env_data) text = [bullet, ' {}'.format(env_short_description)] widgets.append(ui.MenuButton(text, ui.thunk(detail_view, env_data))) # Add the buttons used to create new environments. widgets.extend([ urwid.Divider(), urwid.Text(( 'highlight', 'Create a new environment:')), urwid.Divider(), ]) # The Juju GUI can be safely installed in the bootstrap node only if its # series is "precise". Suggest this setting by pre-filling the value. preferred_series = settings.JUJU_GUI_PREFERRED_SERIES widgets.extend([ ui.MenuButton( ['\N{BULLET} new ', ('highlight', label), ' environment'], ui.thunk(edit_view, { 'type': env_type, 'default-series': preferred_series}) ) for env_type, label in envs.get_supported_env_types(env_type_db) ]) # Set up the application status messages. status = [' \N{UPWARDS ARROW LEFTWARDS OF DOWNWARDS ARROW} navigate '] if default_found: status.append(' \N{CHECK MARK} default ') if errors_found: status.extend([('error status', ' \N{BULLET}'), ' has errors ']) app.set_status(status) # Set up the application contents. contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) if focus_position is not None: contents.set_focus(focus_position) app.set_contents(contents) def env_detail(app, env_type_db, env_db, save_callable, env_data): """Show details on a Juju environment. From this view it is possible to start the environment, set it as default, edit/remove the environment. Receives: - env_type_db: the environments meta information; - env_db: the environments database; - save_callable: a function called to save a new environment database; - env_data: the environment data. """ env_db = copy.deepcopy(env_db) # All the environment views return a tuple (new_env_db, env_data). # Set the env_data to None in the case the user quits the application # without selecting an environment to use. app.set_return_value_on_exit((env_db, None)) index_view = functools.partial( env_index, app, env_type_db, env_db, save_callable) edit_view = functools.partial( env_edit, app, env_type_db, env_db, save_callable, env_data) def use(env_data): # Quit the interactive session returning the (possibly modified) # environment database and the environment data corresponding to the # selected environment. raise ui.AppExit((env_db, env_data)) def set_default(env_data): # Set this environment as the default one, save the env_db and return # to the index view. env_name = env_data['name'] env_db['default'] = env_name save_callable(env_db) app.set_message('{} successfully set as default'.format(env_name)) index_view() def remove(env_data): # The environment deletion is confirmed: remove the environment from # the database, save the new env_db and return to the index view. env_name = env_data['name'] envs.remove_env(env_db, env_name) save_callable(env_db) app.set_message('{} successfully removed'.format(env_name)) index_view() def confirm_removal(env_data): # Ask confirmation before removing an environment. ui.show_dialog( app, 'Remove the {} environment'.format(env_data['name']), 'This action cannot be undone!', actions=[ (('control alert', 'confirm'), ui.thunk(remove, env_data)), ], ) env_metadata = envs.get_env_metadata(env_type_db, env_data) app.set_title(envs.get_env_short_description(env_data)) # Validate the environment. errors = envs.validate(env_metadata, env_data) widgets = [] field_value_pairs = envs.map_fields_to_env_data(env_metadata, env_data) for field, value in field_value_pairs: if field.required or (value is not None): label = '{}: '.format(field.name) if field.name in errors: label = ('error', label) text = [label, ('highlight', field.display(value))] widgets.append(urwid.Text(text)) controls = [ui.MenuButton('back', ui.thunk(index_view))] status = [' \N{RIGHTWARDS ARROW OVER LEFTWARDS ARROW} navigate '] if errors: status.extend([ ('error status', ' \N{LOWER SEVEN EIGHTHS BLOCK}'), ' field error ', ]) else: # Without errors, it is possible to use/start this environment. controls.append(ui.MenuButton('use', ui.thunk(use, env_data))) app.set_status(status) if not env_data['is-default']: controls.append( ui.MenuButton('set default', ui.thunk(set_default, env_data))) controls.extend([ ui.MenuButton('edit', ui.thunk(edit_view)), ui.MenuButton( ('control alert', 'remove'), ui.thunk(confirm_removal, env_data)), ]) widgets.append(ui.create_controls(*controls)) listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) app.set_contents(listbox) def env_edit(app, env_type_db, env_db, save_callable, env_data): """Create or modify a Juju environment. This view displays an edit form allowing for environment creation/modification. Saving the form redirects to the environment detail view if the values are valid. Receives: - env_type_db: the environments meta information; - env_db: the environments database; - save_callable: a function called to save a new environment database; - env_data: the environment data. The last value (env_data) indicates whether this view is used to create a new environment or to change an existing one. In the former case, env_data does not include the "name" key. If instead the environment already exists, env_data includes the "name" key and all the other environment info. """ env_db = copy.deepcopy(env_db) # All the environment views return a tuple (new_env_db, env_data). # Set the env_data to None in the case the user quits the application # without selecting an environment to use. app.set_return_value_on_exit((env_db, None)) env_metadata = envs.get_env_metadata(env_type_db, env_data) index_view = functools.partial( env_index, app, env_type_db, env_db, save_callable) detail_view = functools.partial( env_detail, app, env_type_db, env_db, save_callable) if 'name' in env_data: exists = True title = 'Edit the {} environment' # Retrieve all the errors for the existing environment. initial_errors = envs.validate(env_metadata, env_data) else: exists = False title = 'Create a new {} environment' # The environment does not exist: avoid bothering the user with errors # before the form is submitted. initial_errors = {} app.set_title(title.format(env_data['type'])) app.set_status([ ' \N{UPWARDS ARROW LEFTWARDS OF DOWNWARDS ARROW}' ' \N{LEFTWARDS ARROW TO BAR OVER RIGHTWARDS ARROW TO BAR}' ' navigate ', ('optional status', ' \N{LOWER SEVEN EIGHTHS BLOCK}'), ' optional field ', ('error status', ' \N{BULLET}'), ' field errors ', ]) def save(env_data, get_new_env_data): # Create a new environment or save changes for an existing one. # The new values are saved only if the new env_data is valid, in which # case also redirect to the environment detail view. new_env_data = get_new_env_data() # Validate the new env_data. errors = envs.validate(env_metadata, new_env_data) new_name = new_env_data['name'] initial_name = env_data.get('name') if (new_name != initial_name) and new_name in env_db['environments']: errors['name'] = 'an environment with this name already exists' # If errors are found, re-render the form passing the errors. This way # the errors are displayed as part of the form and the user is given # the opportunity to fix the invalid values. if errors: return render_form(new_env_data, errors) # Without errors, normalize the new values, update the env_db and save # the resulting environments database. env_data = envs.normalize(env_metadata, new_env_data) # If this is the only environment in the db, set it as the default one. if not env_db['environments']: env_data['is-default'] = True envs.set_env_data(env_db, initial_name, env_data) save_callable(env_db) verb = 'modified' if exists else 'created' app.set_message('{} successfully {}'.format(new_name, verb)) return detail_view(env_data) def cancel(env_data): # Dismiss any changes and return to the index or detail view. return detail_view(env_data) if exists else index_view() def render_form(data, errors): # Render the environment edit form. widgets = [ urwid.Text(env_metadata['description']), urwid.Divider(), ] field_value_pairs = envs.map_fields_to_env_data(env_metadata, data) # Retrieve the form widgets and the data getter function. The latter # can be used to retrieve the field name/value pairs included in the # displayed form, including user's changes (see quickstart.cli.forms). form_widgets, get_new_env_data = forms.create_form( field_value_pairs, errors) widgets.extend(form_widgets) actions = ( ('save', ui.thunk(save, env_data, get_new_env_data)), ('cancel', ui.thunk(cancel, env_data)), ('restore', ui.thunk(render_form, env_data, initial_errors)), ) widgets.append(forms.create_actions(actions)) contents = ui.TabNavigationListBox( urwid.SimpleFocusListWalker(widgets)) app.set_contents(contents) # Render the initial form. render_form(env_data, initial_errors) juju-quickstart-1.3.1/quickstart/cli/__init__.py0000644000175000017500000000310212254355165023325 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart command line interface management. The functions and objects included in this package can be used to build rich command line interfaces using the Urwid console interface library (see ). This package is organized in several modules: - base: the base pieces used to set up Urwid applications; - views: view functions responsible for showing specific contents in the context of a Urwid app; - ui: Urwid related utility objects, including callback wrappers, customized widgets and style specific helpers. Client code usually starts a Quickstart terminal application calling views.show() with the view to display along with the view required arguments. See the quickstart.cli.views module docstring for further details. """ juju-quickstart-1.3.1/quickstart/watchers.py0000644000175000017500000001740112320523571022636 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart utilities for watching Juju environments.""" from __future__ import ( print_function, unicode_literals, ) IPV6_ADDRESS = 'ipv6' NETWORK_PUBLIC = 'public' NETWORK_UNKNOWN = '' def retrieve_public_adddress(addresses): """Parse the given addresses and return a public address if available. The addresses argument is a list of address dictionaries. Cloud addresses look like the following: [ {'NetworkName': '', 'NetworkScope': 'public', 'Type': 'hostname', 'Value': 'eu-west-1.compute.example.com'}, {'NetworkName': '', 'NetworkScope': 'local-cloud', 'Type': 'hostname', 'Value': 'eu-west-1.example.internal'}, {'NetworkName': '', 'NetworkScope': 'public', 'Type': 'ipv4', 'Value': '444.222.444.222'}, {'NetworkName': '', 'NetworkScope': 'local-cloud', 'Type': 'ipv4', 'Value': '10.42.47.10'}, {'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv6', 'Value': 'fe80::92b8:d0ff:fe94:8f8c'}, ] When using the local provider, LXC addresses are like the following: [ {'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv4', 'Value': '10.0.3.42'}, {'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv6', 'Value': 'fe80::216:3eff:fefd:787e'}, ] If the addresses list is empty, or if no public/reachable addresses can be found, this function returns None. """ # This implementation reflects how the public address is retrieved in Juju: # see juju-core/instance/address.go:SelectPublicAddress. public_address = None for address in addresses: value = address['Value'] # Exclude empty values and ipv6 addresses. if value and (address['Type'] != IPV6_ADDRESS): scope = address['NetworkScope'] # If the scope is public then we have found the address. if scope == NETWORK_PUBLIC: return value # If the scope is unknown then store the value. This way the last # address with unknown scope will be returned, and we are able to # return the right LXC address. if scope == NETWORK_UNKNOWN: public_address = value return public_address def parse_machine_change(action, data, current_status, address): """Parse the given machine change. The change is represented by the given action/data pair. Also receive the last known machine status and address, which can be empty strings if those pieces of information are unknown. Output a human readable message each time a relevant change is found. Return the machine status and the address. Raise a ValueError if the machine is removed or in an error state. """ machine_id = data['Id'] status = data['Status'] # Exit with an error if the machine is removed. if action == 'remove': msg = 'machine {} unexpectedly removed'.format(machine_id) raise ValueError(msg.encode('utf-8')) if 'error' in status: msg = 'machine {} is in an error state: {}: {}'.format( machine_id, status, data['StatusInfo']) raise ValueError(msg.encode('utf-8')) # Notify when the machine becomes reachable. Starting from juju-core 1.18, # the mega-watcher for machines includes addresses for each machine. This # info is the preferred source where to look to retrieve the public address # of units hosted by a specific machine. if not address: addresses = data.get('Addresses', []) public_address = retrieve_public_adddress(addresses) if public_address is not None: address = public_address print('unit placed on {}'.format(address)) # Notify status changes. if status != current_status: if status == 'pending': print('machine {} provisioning is pending'.format( machine_id)) elif status == 'started': print('machine {} is started'.format(machine_id)) return status, address def parse_unit_change(action, data, current_status, address): """Parse the given unit change. The change is represented by the given action/data pair. Also receive the last known unit status and address, which can be empty strings if those pieces of information are unknown. Output a human readable message each time a relevant change is found. Return the unit status, address and machine identifier. Raise a ValueError if the service unit is removed or in an error state. """ unit_name = data['Name'] # Exit with an error if the unit is removed. if action == 'remove': msg = '{} unexpectedly removed'.format(unit_name) raise ValueError(msg.encode('utf-8')) # Exit with an error if the unit is in an error state. status = data['Status'] if 'error' in status: msg = '{} is in an error state: {}: {}'.format( unit_name, status, data['StatusInfo']) raise ValueError(msg.encode('utf-8')) # Notify when the unit becomes reachable. Up to juju-core 1.18, the # mega-watcher for units includes the public address for each unit. This # info is likely to be deprecated in favor of addresses as included in the # mega-watcher for machines, but we still try to retrieve the address here # for backward compatibility. if not address: address = data.get('PublicAddress', '') if address: print('{} placed on {}'.format(unit_name, address)) # Notify status changes. if status != current_status: if status == 'pending': print('{} deployment is pending'.format(unit_name)) elif status == 'installed': print('{} is installed'.format(unit_name)) elif status == 'started': print('{} is ready on machine {}'.format( unit_name, data['MachineId'])) return status, address, data.get('MachineId', '') def unit_machine_changes(changeset): """Parse the changeset and return the units and machines related changes. Changes to units and machines are grouped into two lists, e.g.: unit_changes, machine_changes = unit_machine_changes(changeset) Each list includes (action, data) tuples, in which: - action is he change type (e.g. "change", "remove"); - data is the actual information about the changed entity (as a dict). This function is intended to be used as a processor callable for the watch_changes method of quickstart.juju.Environment. """ unit_changes = [] machine_changes = [] for entity, action, data in changeset: if entity == 'unit': unit_changes.append((action, data)) elif entity == 'machine': machine_changes.append((action, data)) return unit_changes, machine_changes juju-quickstart-1.3.1/quickstart/manage.py0000644000175000017500000005340512320545241022250 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013-2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart application management.""" from __future__ import ( print_function, unicode_literals, ) import argparse import codecs import logging import os import shutil import sys import webbrowser import quickstart from quickstart import ( app, packaging, settings, utils, ) from quickstart.cli import views from quickstart.models import ( charms, envs, ) version = quickstart.get_version() class _DescriptionAction(argparse.Action): """A customized argparse action that just shows a description.""" def __call__(self, parser, *args, **kwargs): print(settings.DESCRIPTION) parser.exit() def _get_packaging_info(juju_source): """Return packaging info based on the given juju source. The juju_source argument can be either "ppa" or "distro". Packaging info is a tuple containing: - distro_only: whether to install juju-core packages from the distro repositories or the external PPA; - distro_only_help: the help text for the --distro-only flag; - ppa_help: the help text for the --ppa flag. """ distro_only_help = ('Do not use external sources when installing and ' 'setting up Juju') ppa_help = 'Use external sources when installing and setting up Juju' disable_help = '\n(enabled by default, use {} to disable)' if juju_source == 'distro': distro_only = True distro_only_help += disable_help.format('--ppa') else: distro_only = False ppa_help += disable_help.format('--distro-only') return distro_only, distro_only_help, ppa_help def _validate_bundle(options, parser): """Validate and process the bundle options. Populate the options namespace with the following names: - bundle_name: the name of the bundle; - bundle_services: a list of service names included in the bundle; - bundle_yaml: the YAML encoded contents of the bundle. - bundle_id: the bundle_id in Charmworld. None if not a 'bundle:' URL. Exit with an error if the bundle options are not valid. """ bundle = options.bundle bundle_id = None jujucharms_prefix = settings.JUJUCHARMS_BUNDLE_URL if bundle.startswith('bundle:') or bundle.startswith(jujucharms_prefix): # Convert "bundle:" or jujucharms.com URLs into Charmworld HTTPS ones. try: bundle, bundle_id = utils.convert_bundle_url(bundle) except ValueError as err: return parser.error('unable to open the bundle: {}'.format(err)) # The next if block below will then load the bundle contents from the # remote location. if bundle.startswith('http://') or bundle.startswith('https://'): # Load the bundle from a remote URL. try: bundle_yaml = utils.urlread(bundle) except IOError as err: return parser.error('unable to open bundle URL: {}'.format(err)) else: # Load the bundle from a file. bundle_file = os.path.abspath(os.path.expanduser(bundle)) if os.path.isdir(bundle_file): bundle_file = os.path.join(bundle_file, 'bundles.yaml') try: bundle_yaml = codecs.open( bundle_file.encode('utf-8'), encoding='utf-8').read() except IOError as err: return parser.error('unable to open bundle file: {}'.format(err)) # Validate the bundle. try: bundle_name, bundle_services = utils.parse_bundle( bundle_yaml, options.bundle_name) except ValueError as err: return parser.error(bytes(err)) # Update the options namespace with the new values. options.bundle_name = bundle_name options.bundle_services = bundle_services options.bundle_yaml = bundle_yaml options.bundle_id = bundle_id def _validate_charm_url(options, parser): """Validate the provided charm URL option. Exit with an error if: - the URL is not a valid charm URL; - the URL represents a local charm; - the charm series is not supported; - a bundle deployment has been requested but the provided charm does not support bundles. Leave the options namespace untouched. """ try: charm = charms.Charm.from_url(options.charm_url) except ValueError as err: return parser.error(bytes(err)) if charm.is_local(): return parser.error(b'local charms are not allowed: {}'.format(charm)) if charm.series not in settings.JUJU_GUI_SUPPORTED_SERIES: return parser.error( 'unsupported charm series: {}'.format(charm.series)) if ( # The user requested a bundle deployment. options.bundle and # This is the official Juju GUI charm. charm.name == settings.JUJU_GUI_CHARM_NAME and not charm.user and # The charm at this revision does not support bundle deployments. charm.revision < settings.MINIMUM_CHARM_REVISION_FOR_BUNDLES ): return parser.error( 'bundle deployments not supported by the requested charm ' 'revision: {}'.format(charm)) def _retrieve_env_db(parser, env_file=None): """Retrieve the environment database (or create an in-memory empty one).""" if env_file is None: return envs.create_empty_env_db() try: return envs.load(env_file) except ValueError as err: return parser.error(bytes(err)) def _create_save_callable(parser, env_file): """Return a function that can be used to save an env_db to the env_file. The returned function is used as save_callable by the environments management views. The resulting function uses the given parser instance to exit the application with an error if an OSError exception is raised while saving the environments database. """ backup_function = utils.run_once(shutil.copyfile) def save_callable(env_db): try: envs.save(env_file, env_db, backup_function=backup_function) except OSError as err: return parser.error(bytes(err)) return save_callable def _start_interactive_session(parser, env_type_db, env_db, env_file): """Start the Urwid interactive session. Return the env_data corresponding to the user selected environment. Exit the application if the user exits the interactive session without selecting an environment to start. """ save_callable = _create_save_callable(parser, env_file) new_env_db, env_data = views.show( views.env_index, env_type_db, env_db, save_callable) if new_env_db != env_db: print('changes to the environments file have been saved') if env_data is None: # The user exited the interactive session without selecting an # environment to start: this means this was just an environment # editing session and we can just quit now. return sys.exit('quitting') return env_data def _retrieve_env_data(parser, env_type_db, env_db, env_name): """Retrieve and return the env_data corresponding to the given env_name. Invoke a parser error if the environment does not exist or is not valid. """ try: env_data = envs.get_env_data(env_db, env_name) except ValueError as err: # The specified environment does not exist. return parser.error(bytes(err)) env_metadata = envs.get_env_metadata(env_type_db, env_data) errors = envs.validate(env_metadata, env_data) if errors: msg = 'cannot use the {} environment:\n{}'.format( env_name, '\n'.join(errors.values())) return parser.error(msg.encode('utf-8')) return env_data def _setup_env(options, parser): """Set up, validate and process the provided environment related options. Also start the environments management interactive session if required. Exit with an error if options are not valid. """ logging.debug('setting up juju environments') env_name = options.env_name env_file = os.path.abspath(os.path.expanduser(options.env_file)) interactive = options.interactive env_file_exists = os.path.exists(env_file) if not env_file_exists: # If the Juju home is not set up, force the interactive mode and ignore # the user provided env name. interactive = True env_name = None # Validate the environment name. if env_name is None and not interactive: # The user forced non-interactive mode but a default env name cannot # be retrieved. In this case, just exit with an error. return parser.error( 'unable to find an environment name to use\n' 'It is possible to specify the environment to use by either:\n' ' - selecting one from the quickstart interactive session,\n' ' i.e. juju quickstart -i;\n' ' - passing the -e or --environment argument;\n' ' - setting the JUJU_ENV environment variable;\n' ' - using "juju switch" to select the default environment;\n' ' - setting the default environment in {}.'.format(env_file) ) # Retrieve the environment database (or create an in-memory empty one). env_db = _retrieve_env_db(parser, env_file if env_file_exists else None) # Validate the environment. env_type_db = envs.get_env_type_db() if interactive: # Start the interactive session. env_data = _start_interactive_session( parser, env_type_db, env_db, env_file) else: # This is a non-interactive session and we need to validate the # selected environment before proceeding. env_data = _retrieve_env_data(parser, env_type_db, env_db, env_name) # Update the options namespace with the new values. options.admin_secret = env_data.get('admin-secret') options.env_file = env_file options.env_name = env_data['name'] options.env_type = env_data['type'] options.default_series = env_data.get('default-series') options.interactive = interactive def _configure_logging(level): """Set up the application logging.""" root = logging.getLogger() # Remove any previous handler on the root logger. for handler in root.handlers[:]: root.removeHandler(handler) logging.basicConfig( level=level, format=( '%(asctime)s %(levelname)s ' '%(module)s@%(funcName)s:%(lineno)d ' '%(message)s' ), datefmt='%H:%M:%S', ) def _convert_options_to_unicode(options): """Convert all byte string values in the options namespace to unicode. Modify the options in place and return None. """ encoding = sys.stdin.encoding or 'utf-8' for key, value in options._get_kwargs(): if isinstance(value, bytes): setattr(options, key, value.decode(encoding)) def setup(): """Set up the application options and logger. Return the options as a namespace containing the following attributes: - admin_secret: the password to use to access the Juju API or None if no admin-secret is present in the $JUJU_HOME/environment.yaml file; - bundle: the optional bundle (path or URL) to be deployed; - charm_url: the Juju GUI charm URL or None if not specified; - debug: whether debug mode is activated; - distro_only: install Juju only using the distribution packages; - env_file: the absolute path of the Juju environments.yaml file; - env_name: the name of the Juju environment to use; - env_type: the provider type of the selected Juju environment; - interactive: whether to start the interactive session; - open_browser: whether the GUI browser must be opened. The following attributes will also be included in the namespace if a bundle deployment is requested: - bundle_name: the name of the bundle to be deployed; - bundle_services: a list of service names included in the bundle; - bundle_yaml: the YAML encoded contents of the bundle. - bundle_id: the Charmworld identifier for the bundle if a 'bundle:' URL is provided. Exit with an error if the provided arguments are not valid. """ default_env_name = envs.get_default_env_name() default_distro_only, distro_only_help, ppa_help = _get_packaging_info( packaging.JUJU_SOURCE) # Define the help message for the --environment option. env_help = 'The name of the Juju environment to use' if default_env_name is not None: env_help = '{} (%(default)s)'.format(env_help) # Create and set up the arguments parser. parser = argparse.ArgumentParser( description=quickstart.__doc__, epilog=quickstart.FEATURES, formatter_class=argparse.RawTextHelpFormatter) # Note: since we use the RawTextHelpFormatter, when adding/changing options # make sure the help text is nicely displayed on small 80 columns terms. parser.add_argument( 'bundle', default=None, nargs='?', help='The optional bundle to be deployed. The bundle can be:\n' '1) a fully qualified bundle URL, starting with "bundle:"\n' ' e.g. "bundle:mediawiki/single".\n' ' Non promulgated bundles can be requested providing\n' ' the user, e.g. "bundle:~user/mediawiki/single".\n' ' A specific bundle revision can also be requested,\n' ' e.g. "bundle:~myuser/mediawiki/42/single".\n' ' If not specified, the last bundle revision is used;\n' '2) a jujucharms bundle URL, starting with\n' ' "{jujucharm}", e.g.\n' ' "{jujucharm}~user/wiki/1/simple/".\n' ' As seen above, jujucharms bundle URLs can also be\n' ' shortened, e.g.\n' ' "{jujucharm}mediawiki/scalable/";\n' '3) a URL ("http:" or "https:") to a YAML/JSON, e.g.\n' ' "https://raw.github.com/user/my/master/bundles.yaml";\n' '4) a local path to a YAML/JSON file;\n' '5) a path to a directory containing a "bundles.yaml"\n' ' file'.format(jujucharm=settings.JUJUCHARMS_BUNDLE_URL)) parser.add_argument( '-e', '--environment', default=default_env_name, dest='env_name', help=env_help) parser.add_argument( '-n', '--bundle-name', default=None, dest='bundle_name', help='The name of the bundle to use.\n' 'This must be included in the provided bundle YAML/JSON.\n' 'Specifying the bundle name is not required if the\n' 'bundle YAML/JSON only contains one bundle. This option\n' 'is ignored if the bundle file is not specified') parser.add_argument( '-i', '--interactive', action='store_true', dest='interactive', help='Start the environments management interactive session') parser.add_argument( '--environments-file', dest='env_file', default=os.path.join(settings.JUJU_HOME, 'environments.yaml'), help='The path to the Juju environments YAML file\n(%(default)s)') parser.add_argument( '--gui-charm-url', dest='charm_url', help='The Juju GUI charm URL to deploy in the environment.\n' 'If not provided, the last release of the GUI will be\n' 'deployed. The charm URL must include the charm version,\n' 'e.g. "cs:~juju-gui/precise/juju-gui-162". This option is\n' 'ignored if the GUI is already present in the environment') parser.add_argument( '--no-browser', action='store_false', dest='open_browser', help='Avoid opening the browser to the GUI at the end of the\nprocess') parser.add_argument( '--distro-only', action='store_true', dest='distro_only', default=default_distro_only, help=distro_only_help) parser.add_argument( '--ppa', action='store_false', dest='distro_only', default=not default_distro_only, help=ppa_help) parser.add_argument( '--version', action='version', version='%(prog)s {}'.format(version)) parser.add_argument( '--debug', action='store_true', help='Turn debug mode on. When enabled, all the subcommands\n' 'and API calls are logged to stdout, and the Juju\n' 'environment is bootstrapped passing --debug') # This is required by juju-core: see "juju help plugins". parser.add_argument( '--description', action=_DescriptionAction, default=argparse.SUPPRESS, nargs=0, help="Show program's description and exit") # Parse the provided arguments. options = parser.parse_args() # Convert the provided string arguments to unicode. _convert_options_to_unicode(options) # Validate and process the provided arguments. _setup_env(options, parser) if options.bundle is not None: _validate_bundle(options, parser) if options.charm_url is not None: _validate_charm_url(options, parser) # Set up logging. _configure_logging(logging.DEBUG if options.debug else logging.INFO) return options def run(options): """Run the application.""" print('juju quickstart v{}'.format(version)) if options.bundle is not None: print('contents loaded for bundle {} (services: {})'.format( options.bundle_name, len(options.bundle_services))) logging.debug('ensuring juju and lxc are installed') juju_version = app.ensure_dependencies(options.distro_only) logging.debug('ensuring SSH keys are available') app.ensure_ssh_keys() print('bootstrapping the {} environment (type: {})'.format( options.env_name, options.env_type)) is_local = options.env_type == 'local' requires_sudo = False if is_local: # If this is a local environment, notify the user that "sudo" will be # required to bootstrap the application, even in newer Juju versions # where "sudo" is invoked by juju-core itself. print('sudo privileges will be required to bootstrap the environment') # If the Juju version is less than 1.17.2 then use sudo for local envs. requires_sudo = juju_version < (1, 17, 2) already_bootstrapped, bsn_series = app.bootstrap( options.env_name, requires_sudo=requires_sudo, debug=options.debug) # Retrieve the admin-secret for the current environment. try: admin_secret = app.get_admin_secret( options.env_name, settings.JUJU_HOME) except ValueError as err: admin_secret = options.admin_secret if admin_secret is None: # The admin-secret cannot be found in the jenv file and is not # explicitly specified in the environments.yaml file. msg = b'{} or {}'.format(err, options.env_file.encode('utf-8')) raise app.ProgramExit(msg) print('retrieving the Juju API address') api_url = app.get_api_url(options.env_name) print('connecting to {}'.format(api_url)) env = app.connect(api_url, admin_secret) # It is not possible to deploy on the bootstrap node if we are using the # local provider, or if the bootstrap node series is not compatible with # the Juju GUI charm. machine = '0' if is_local or (bsn_series != settings.JUJU_GUI_PREFERRED_SERIES): machine = None unit_name = app.deploy_gui( env, settings.JUJU_GUI_SERVICE_NAME, machine, charm_url=options.charm_url, check_preexisting=already_bootstrapped) address = app.watch(env, unit_name) env.close() url = 'https://{}'.format(address) print('\nJuju GUI URL: {}\npassword: {}\n'.format(url, admin_secret)) gui_api_url = 'wss://{}:443/ws'.format(address) print('connecting to the Juju GUI server') gui_env = app.connect(gui_api_url, admin_secret) # Handle bundle deployment. if options.bundle is not None: services = ', '.join(options.bundle_services) print('requesting a deployment of the {} bundle with the following ' 'services:\n {}'.format(options.bundle_name, services)) # We need to connect to an API WebSocket server supporting bundle # deployments. The GUI builtin server, listening on the Juju GUI # address, exposes an API suitable for deploying bundles. app.deploy_bundle( gui_env, options.bundle_yaml, options.bundle_name, options.bundle_id) print('bundle deployment request accepted\n' 'use the GUI to check the bundle deployment progress') if options.open_browser: token = app.create_auth_token(gui_env) if token is not None: url += '/?authtoken={}'.format(token) webbrowser.open(url) gui_env.close() print( 'done!\n\n' 'Run "juju quickstart -e {env_name}" again if you want\n' 'to reopen and log in to the GUI browser later.\n' 'Run "juju quickstart -i" if you want to manage\n' 'or bootstrap your Juju environments using the\n' 'interactive session.\n' 'Run "{sudo}juju destroy-environment {eflag}{env_name} [-y]"\n' 'to destroy the environment you just bootstrapped.'.format( env_name=options.env_name, sudo='sudo ' if requires_sudo else '', eflag='-e ' if juju_version < (1, 17, 2) else '') ) juju-quickstart-1.3.1/quickstart/packaging.py0000644000175000017500000000253112320545241022736 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart packaging configuration. This module is parsed and modified in the process of packaging quickstart for Ubuntu distributions. DO NOT MODIFY this file without informing server/distro developers. """ # The source from where to install juju-core packages. # Possible values are: # - ppa: the Juju stable packages PPA. This value is usually set in the code # base and PyPI releases; # - distro: the distribution repository. This value is usually set in the deb # releases included in the Ubuntu repositories. JUJU_SOURCE = 'ppa' juju-quickstart-1.3.1/quickstart/models/0000755000175000017500000000000012320751720021723 5ustar frankbanfrankban00000000000000juju-quickstart-1.3.1/quickstart/models/fields.py0000644000175000017500000003144212317464767023571 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart field definitions. A field is a simple object describing a value, e.g. a label or a help text to be associated to that value. A field also provides the logic to display, validate and normalize the given data. This module is useful as part of the environments metadata definition, in which each provider type is associated with a sequence of fields. Those fields describe how an environment of that type should look like, and provide a way to validate the whole environment on a per-field basis. See quickstart.models.envs.get_env_type_db for an example of how this works. """ from __future__ import unicode_literals import uuid class Field(object): """Describe a piece of information. Also provide the logic to display, normalize and validate input data. Field subclasses can define a "field_type" class attribute that can be used by view code to choose an appropriate widget to use for that type of field instances, e.g. a "bool" field type indicates that a checkbox is appropriate when editing that field value. The following field types are defined by fields in this module: - bool: as mentioned, values are expected to be boolean values; If field_type is not specified, view code assumes the values can be edited using the usual input edit widget which handles multi-line strings. Field instances have the following attributes: - name: the key identifying a specific piece of information. In the environments context this can be, for instance, "admin-secret" or "default-series"; - label: a human friendly string identifying this field (e.g. "Admin Secret"); - help: help text associated with this field (e.g. "the password you use for authenticating"); - default: the default value if the value is not set (None). This is used only in the validation process, and can be used by view code to display the default value for a field; - required: True if this is a required field, False otherwise; - readonly: True if the associated value must be considered immutable. Field instances also expose the following methods: - display(value): how the value should be displayed by views (usually just the value itself as a unicode string is returned); - normalize(value): return the normalized value, e.g. a string field might return a stripped version of the input value. Returning None means the value for that field is unset; - validate(value): validate the given value, raising a ValueError if the input value is not valid, returning None otherwise; - generate(): this optional method indicates the value associated with this field can be optionally automatically generated by view code. When implemented, this method must return a suitable generated value. Note that it is not safe to call normalize on a value if that value has not been previously validated. """ # Since this is the default field the type is not specified. field_type = None def __init__( self, name, label=None, help='', default=None, required=False, readonly=False): """Initialize a field. Only the name identifier is required.""" self.name = name self.label = name if label is None else label self.help = help self.default = default self.required = required self.readonly = readonly def __repr__(self): name = self.name.encode('utf-8') return b'<{}: {}>'.format(self.__class__.__name__, name) def display(self, value): """Return a value to display. Override this method to change how the value is displayed in view code. """ return unicode(value) def normalize(self, value): """Return a normalized version of the given value.""" return value def validate(self, value): """Validate the given value. Raise a ValueError if the given value is required, it is not set and no default is provided. """ if self.required and (value is None) and (self.default is None): msg = 'a value is required for the {} field'.format(self.label) raise ValueError(msg.encode('utf-8')) class StringField(Field): """Values associated with this field must be strings.""" def normalize(self, value): """Strip the string. Return None if the value is not set.""" if value is None: return None return value.strip() or None def validate(self, value): """Check that the value is a string.""" if not isinstance(value, (unicode, type(None))): # Assume view code always works with unicode strings. msg = 'the {} field requires a string value'.format(self.label) raise ValueError(msg.encode('utf-8')) super(StringField, self).validate(self.normalize(value)) class IntField(Field): """Values associated with this field must be integers.""" def __init__(self, name, min_value=None, max_value=None, **kwargs): """Initialize an integer field. The "min_value" and "max_value" keyword arguments, if provided, are used in the validation process. """ super(IntField, self).__init__(name, **kwargs) self.min_value = min_value self.max_value = max_value def normalize(self, value): """Return the value as an integer. Return None if the value is an empty string or None. In these cases, the field value is considered not set. """ if isinstance(value, unicode): value = value.strip() if value in ('', None): return None return int(value) def validate(self, value): """Validate the integer value. Raise a ValueError if: - the normalized value is None but the value is required; - the normalized field is set but it is not an integer number; - the normalized field is a number but not in the range defined by self.min_value and self.max_value. """ label = self.label # Ensure the value, if set, is an integer. msg = 'the {} field requires an integer value'.format(label) # Avoid implicit boolean to integer conversion. if isinstance(value, bool): raise ValueError(msg.encode('utf-8')) try: value = self.normalize(value) except (TypeError, ValueError): raise ValueError(msg.encode('utf-8')) # Ensure the value is set if required. super(IntField, self).validate(value) if value is None: return # Ensure the value is in the given range. min_value = self.min_value max_value = self.max_value if (min_value is not None) and (max_value is not None): if not (min_value <= value <= max_value): msg = 'the {} value must be in the {}-{} range'.format( label, min_value, max_value) raise ValueError(msg.encode('utf-8')) elif min_value is not None: if value < min_value: msg = 'the {} value must be >= {}'.format(label, min_value) raise ValueError(msg.encode('utf-8')) elif max_value is not None: if value > max_value: msg = 'the {} value must be <= {}'.format(label, max_value) raise ValueError(msg.encode('utf-8')) class BoolField(Field): """Values associated with this field must be booleans.""" field_type = 'bool' def __init__(self, name, allow_mixed=True, **kwargs): """Add the allow_mixed keyword argument. By default allow_mixed is True, and that means the field can be in a "not set" state (None). This is relevant for validation and view code. """ super(BoolField, self).__init__(name, **kwargs) self.allow_mixed = allow_mixed def validate(self, value): """Check that the value, if set, is a boolean.""" types = (bool, type(None)) if self.allow_mixed else (bool,) if not isinstance(value, types): msg = 'the {} field requires a boolean value'.format(self.label) raise ValueError(msg.encode('utf-8')) class UnexpectedField(Field): """An unexpected field, used when the value type is unknown.""" def __init__(self, name, label=None, help=None): if help is None: help = 'this field is unrecognized and can be safely removed' super(UnexpectedField, self).__init__( name, label=label, help=help, default=None, required=False, readonly=False) def normalize(self, value): """Try to guess the value type.""" if isinstance(value, (bool, int, type(None))): # For booleans, numbers, and None values, return the value as is. return value if isinstance(value, unicode): # If the value is a string (e.g. because it is returned as a # string by view code), try to guess the underlying type. value = value.strip() # Check if the value is not set and can be discarded. if not value: return None # Check if the value is a number. if value.isdigit(): return int(value) # Check if the value is a boolean. lower_value = value.lower() if lower_value == 'true': return True if lower_value == 'false': return False # The value is a string, return it. return value # If the above did not work, always turn the value into a string. return unicode(value) def validate(self, value): """Unexpected values are always valid.""" pass class ChoiceField(StringField): """A string field whose value must be included in the given choices.""" def __init__(self, name, choices=(), **kwargs): """Initialize the choices field with the given choices.""" super(ChoiceField, self).__init__(name, **kwargs) self.choices = tuple(choices) def validate(self, value): """Check the field is set if required. If the field is set, also check it is included in self.choices. """ # The parent field ensures the value is set if required. super(ChoiceField, self).validate(value) value = self.normalize(value) choices = list(self.choices) # If the field is not required, or if a default value is set, None is # added to the list of valid choices, so that a "not set" value is # accepted. if (not self.required) or (self.default is not None): choices.append(None) if value not in choices: msg = 'the {} requires the value to be one of the following: {}' raise ValueError( msg.format(self.label, ', '.join(self.choices)).encode('utf-8') ) class SuggestionsStringField(StringField): """A string field storing possible value suggestions.""" def __init__(self, name, suggestions=(), **kwargs): """Initialize the choices field with the given choices.""" super(SuggestionsStringField, self).__init__(name, **kwargs) self.suggestions = tuple(suggestions) class AutoGeneratedStringField(StringField): """Can automatically generate string values if they are not provided. Subclasses can override the generate method to return customized values. """ def generate(self): """Generate a uuid valid value.""" return '{}-{}'.format(self.name[:3], uuid.uuid4().hex) class PasswordField(StringField): """Assume values associated with this field represent sensible data.""" def display(self, value): """Obfuscate the value.""" if value: return '*****' return 'None' class AutoGeneratedPasswordField(AutoGeneratedStringField, PasswordField): """Values are passwords which can be automatically generated.""" juju-quickstart-1.3.1/quickstart/models/charms.py0000644000175000017500000001134712251372515023564 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart charms management.""" from __future__ import unicode_literals import re # The following regular expressions are the same used in juju-core: see # http://bazaar.launchpad.net/~go-bot/juju-core/trunk/view/head:/charm/url.go. valid_user = re.compile(r'^[a-z0-9][a-zA-Z0-9+.-]+$').match valid_series = re.compile(r'^[a-z]+([a-z-]+[a-z])?$').match valid_name = re.compile(r'^[a-z][a-z0-9]*(-[a-z0-9]*[a-z][a-z0-9]*)*$').match def parse_url(url): """Parse the given charm URL. Return a tuple containing the charm URL fragments: schema, user, series, name and revision. Each fragment is a string except revision (int). Raise a ValueError with a descriptive message if the given URL is not a valid charm URL. """ # Retrieve the schema. try: schema, remaining = url.split(':', 1) except ValueError: msg = 'charm URL has no schema: {}'.format(url) raise ValueError(msg.encode('utf-8')) if schema not in ('cs', 'local'): msg = 'charm URL has invalid schema: {}'.format(schema) raise ValueError(msg.encode('utf-8')) # Retrieve the optional user, the series, name and revision. parts = remaining.split('/') parts_length = len(parts) if parts_length == 3: user, series, name_revision = parts if not user.startswith('~'): msg = 'charm URL has invalid user name form: {}'.format(user) raise ValueError(msg.encode('utf-8')) user = user[1:] if not valid_user(user): msg = 'charm URL has invalid user name: {}'.format(user) raise ValueError(msg.encode('utf-8')) if schema == 'local': msg = 'local charm URL with user name: {}'.format(url) raise ValueError(msg.encode('utf-8')) elif parts_length == 2: user = '' series, name_revision = parts else: msg = 'charm URL has invalid form: {}'.format(url) raise ValueError(msg.encode('utf-8')) # Validate the series. if not valid_series(series): msg = 'charm URL has invalid series: {}'.format(series) raise ValueError(msg.encode('utf-8')) # Validate name and revision. try: name, revision = name_revision.rsplit('-', 1) except ValueError: msg = 'charm URL has no revision: {}'.format(url) raise ValueError(msg.encode('utf-8')) if not valid_name(name): msg = 'charm URL has invalid name: {}'.format(name) raise ValueError(msg.encode('utf-8')) try: revision = int(revision) except ValueError: msg = 'charm URL has invalid revision: {}'.format(revision) raise ValueError(msg.encode('utf-8')) return schema, user, series, name, revision class Charm(object): """Represent the charm information stored in the charm URL.""" def __init__(self, schema, user, series, name, revision): """Initialize the charm. Receives the URL fragments.""" self.schema = schema self.user = user self.series = series self.name = name self.revision = int(revision) @classmethod def from_url(cls, url): """Given a charm URL, create and return a Charm instance. Raise a ValueError if the charm URL is not valid. """ return cls(*parse_url(url)) def __str__(self): """The string representation of a charm is its URL.""" return self.__unicode__().encode('utf-8') def __unicode__(self): """The unicode representation of a charm is its URL.""" return self.url() def __repr__(self): return b''.format(bytes(self)) def url(self): """Return the charm URL.""" user_part = '~{}/'.format(self.user) if self.user else '' return '{}:{}{}/{}-{}'.format( self.schema, user_part, self.series, self.name, self.revision) def is_local(self): """Return True if this is a local charm, False otherwise.""" return self.schema == 'local' juju-quickstart-1.3.1/quickstart/models/envs.py0000644000175000017500000007366712320523571023274 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart environments management. The objects included in this module help working with Juju environments. The user environments are defined in the environments.yaml file stored inside the JUJU_HOME (usually in ~/.juju/environments.yaml). The load function is used to retrieve a memory representation of the environments file. This representation is called "env_db" and it is just the Python dictionary resulting from YAML decoding the environments file contents. The env_db always includes an "environments" key and may or may not include a "default" key, which specifies the default environment name. After the environments representation is loaded in memory, it is possible to retrieve the representation of a single environment using the get_env_data function, e.g.: env_db = load('~/.juju/environments.yaml') env_data = get_env_data(env_db, 'myenv') How the env_data is different from just env_db['environments']['myenv']? The former stores two additional pieces of information: the environment name (env_data['name']) and whether or not that environment is the default one (env_data['is-default']). Retrieving an env_data also allows for easily validating and normalizing the values in the environment, e.g.: errors = validate(env_metadata, env_data) new_env_data = normalize(env_metadata, env_data) What is the "env_metadata" argument passed to the functions above? It is a dictionary storing meta information about the provider type associated with the env_data, and that can be applied to all the environments of that type. For instance, env_metadata includes a list of expected fields and a description suitable for that specific environment's type. This meta information can be retrieved as follow: env_type_db = get_env_type_db() env_metadata = get_env_metadata(env_type_db, env_data) Modifications to env_data can be easily applied to the original env_db using the "set_env_data" function, and the resulting env_db can then be saved to disk using the "save" function. In the following example an environment is retrieved, validated, normalized and then saved back to disk: env_db = load('~/.juju/environments.yaml') env_type_db = get_env_type_db() env_data = get_env_data(env_db, 'myenv') env_metadata = get_env_metadata(env_type_db, env_data) errors = validate(env_metadata, env_data) if errors: # Handle errors... new_env_data = normalize(env_metadata, env_data) if new_env_data != env_data: # The normalization process changed the data. set_env_data(env_db, 'myenv', new_env_data) save('~/.juju/environments.yaml', env_db) The set_env_data function, as seen above, needs to be passed the original name of the environment being modified. If None is passed, that means we are adding a new environment. """ from __future__ import unicode_literals import collections import copy import logging import os import re import tempfile from quickstart import ( serializers, settings, utils, ) from quickstart.models import fields # Compile the regular expression used to parse the "juju switch" output. _juju_switch_expression = re.compile(r'Current environment: "([\w-]+)"\n') def get_default_env_name(): """Return the current Juju environment name. The environment name can be set either by - setting the JUJU_ENV environment variable; - using "juju switch my-env-name"; - setting the default environment in the environments.yaml file. The former overrides the latter. Return None if a default environment is not found. """ env_name = os.getenv('JUJU_ENV', '').strip() if env_name: return env_name # The "juju switch" command parses ~/.juju/current-environment file. If the # environment name is not found there, then it tries to retrieve the name # from the "default" section of the ~/.juju/environments.yaml file. retcode, output, _ = utils.call('juju', 'switch') # Before juju-core 1.17, the "juju switch" command returns a human readable # output. Newer releases just output the environment name, or exit with an # error if no default environment is configured. if retcode: return None # Use a regular expression to check if "juju switch" returned a human # readable output. match = _juju_switch_expression.match(output) if match is not None: return match.groups()[0] # At this point we can safely assume we are using the newer "juju switch". return output.strip() def create_empty_env_db(): """Create and return an empty environments database.""" return {'environments': {}} def _load_file(env_file): """Load the given file and return the YAML-parsed contents.""" # Load the Juju environments file. try: environments_file = open(env_file.encode('utf-8')) except IOError as err: msg = b'unable to open environments file: {}'.format(err) raise ValueError(msg) # Parse the Juju environments file. try: contents = serializers.yaml_load(environments_file) except Exception as err: msg = b'unable to parse environments file {}: {}' raise ValueError(msg.format(env_file.encode('utf-8'), err)) return contents def load(env_file): """Load and parse the provided Juju environments.yaml file. Return the decoded environments YAML as a dictionary, e.g.: { 'default': 'myenv', 'environments': { 'myenv': {'type': 'ec2', ...}, ... } } The resulting dictionary always has an "environments" keys, even if there are no environments defined in the Juju environments.yaml file. The "default" key instead is only set if the YAML includes a valid default environment. Raise a ValueError if: - the environment file is not found; - the environment file contents are not parsable by YAML; - the YAML contents are not properly structured. """ contents = _load_file(env_file) if contents is None: return create_empty_env_db() # Retrieve the environment list. try: env_contents = contents.get('environments', {}).items() except AttributeError: msg = 'invalid YAML contents in {}: {}'.format(env_file, contents) raise ValueError(msg.encode('utf-8')) environments = {} for env_name, env_info in env_contents: if isinstance(env_info, collections.Mapping): environments[env_name] = env_info else: logging.warn('excluding invalid environment {}'.format(env_name)) # Build the resulting environments dict. env_db = {'environments': environments} default = contents.get('default') if default in environments: env_db['default'] = default elif default is not None: logging.warn('excluding invalid default {}'.format(default)) return env_db def load_generated(env_file, section='bootstrap-config'): """Given the path to a YAML file, load the file and return the section.""" contents = _load_file(env_file) try: section_contents = contents[section] except (KeyError, TypeError): msg = 'invalid YAML contents in {}: {}'.format(env_file, contents) raise ValueError(msg.encode('utf-8')) return section_contents def save(env_file, env_db, backup_function=None): """Save the given env_db to the provided environments.yaml file. The new environments are saved to disk in the most atomic way possible. If backup_function is not None, it is called passing the original environments file path and the backup destination path. The backup is obviously performed only if the file exists. Raise an OSError if any errors occur in the process. """ dirname = os.path.dirname(env_file) backup_file = None if os.path.exists(env_file): if backup_function is not None: # Create a backup of the original file. backup_file = env_file + '.quickstart.bak' backup_function(env_file, backup_file) else: utils.mkdir(dirname) banner = utils.get_quickstart_banner() # Save the contents in a temporary file, then rename to the real file. # Since the renaming operation may fail on some Unix flavors if the source # and destination files are on different file systems, use for the # temporary file the same directory where the env_file is stored. try: temp_file = tempfile.NamedTemporaryFile( prefix='quickstart-temp-envs-', dir=dirname, delete=False) temp_file.write(banner.encode('utf-8')) if backup_file is not None: temp_file.write( '# A backup copy of this file can be found in\n' '# {}\n\n'.format(backup_file)) serializers.yaml_dump(env_db, temp_file) except Exception as err: raise OSError(err) # Ensure that all the data is written to disk. temp_file.flush() os.fsync(temp_file.fileno()) temp_file.close() # Rename the temporary file to the real environments file. os.rename(temp_file.name, env_file) def get_env_data(env_db, env_name): """Return the environment data for the given environment name. The env_data is a Python dictionary describing an environment as an entity separated from the env_db. For example, consider an env_db like this: { 'default': 'myenv', 'environments': { 'myenv': {'type': 'local', 'admin-secret': 'Secret!'}, }, } The corresponding env_data for the "myenv" environment is the following: { # The name is now included in the data structure. 'name': 'myenv', # The "is-default" field is always included. 'is-default': True, # Remaining data is left as is. 'type': 'local', 'admin-secret': 'Secret!', } Raise a ValueError if the env_db does not include an environment with the given name. """ try: info = env_db['environments'][env_name] except KeyError: msg = 'environment {} not found'.format(env_name) raise ValueError(msg.encode('utf-8')) # Why not just use env_data.copy()? Because this way internal mutable data # structures are preserved, even if they are unlikely to be found. env_data = copy.deepcopy(info) env_data.update({ 'is-default': env_db.get('default') == env_name, 'name': env_name, }) return env_data def set_env_data(env_db, env_name, env_data): """Update the environments dictionary with the given environment data. The env_name argument is used as a reference to an existing environment in the env_db. If env_name is None, a new environment is added to the env_db. Otherwise the existing environment is updated using env_data. The env_data argument is an environment data dictionary, and must include at least the "name" and "is-default" keys. Raise a ValueError if: - env_data does not include a "name" key; - env_data does not include a "is-default" key; - env_data is a new environment and its name is already used by an existing environment; - env_data is an existing environment renamed, and its name is already used by another existing environment. If any errors occur, the env_db is left untouched. Without errors, the env_db is modified in place and None is returned. """ new_env_data = copy.deepcopy(env_data) try: new_name = new_env_data.pop('name') is_default = new_env_data.pop('is-default') except KeyError: raise ValueError(b'invalid env_data: {!r}'.format(env_data)) environments = env_db['environments'] if (new_name != env_name) and (new_name in environments): raise ValueError( b'an environment named {!r} already exists'.format(new_name)) if env_name is not None: del environments[env_name] environments[new_name] = new_env_data if is_default: env_db['default'] = new_name elif (env_db.get('default') == env_name) and (env_name is not None): del env_db['default'] def create_local_env_data(env_type_db, name, is_default=False): """Create and return an local (LXC) env_data. Local environments' fields (except for name and type) are assumed to be either optional or suitable for automatic generation of their values. For this reason local environments can be safely created given just their name. """ env_data = {'type': 'local', 'name': name, 'is-default': is_default} env_metadata = get_env_metadata(env_type_db, env_data) # Retrieve a list of missing required fields. missing_fields = [ field for field in env_metadata['fields'] if field.required and field.name not in env_data] # The following fields are not generally required but should be # automatically generated for a local environment. forced_to_be_generated = ('admin-secret', ) missing_fields.extend([field for field in env_metadata['fields'] if field.name in forced_to_be_generated]) # Assume all missing fields can be automatically generated. env_data.update((field.name, field.generate()) for field in missing_fields) return env_data def remove_env(env_db, env_name): """Remove the environment named env_name from the environments database. Raise a ValueError if the environment is not present in env_db. Without errors, the env_db is modified in place and None is returned. """ environments = env_db['environments'] try: del environments[env_name] except KeyError: raise ValueError( b'the environment named {!r} does not exist'.format(env_name)) if env_db.get('default') == env_name: del env_db['default'] # If only one environment remains, set it as default. if len(environments) == 1: env_db['default'] = environments.keys()[0] def get_env_type_db(): """Return the environments meta information based on provider types. The env_type_db describes Juju environments based on their provider type. The returned data is a dictionary mapping provider type names to meta information. A provider named "__fallback__" is also included and can be used to minimally handle environments whose type is not currently supported by quickstart. """ # Define a collection of fields shared by many environment types. provider_field = fields.StringField( 'type', label='provider type', required=True, readonly=True, help='the provider type enabled for this environment') name_field = fields.StringField( 'name', label='environment name', required=True, help='the environment name to use with Juju (arbitrary string)') admin_secret_field = fields.AutoGeneratedPasswordField( 'admin-secret', label='admin secret', required=False, help='the password used to authenticate to the environment') default_series_field = fields.ChoiceField( 'default-series', choices=settings.JUJU_DEFAULT_SERIES, label='default series', required=False, default=settings.JUJU_GUI_PREFERRED_SERIES, help='the default Ubuntu series to use for the bootstrap node') is_default_field = fields.BoolField( 'is-default', label='default', allow_mixed=False, required=True, help='make this the default environment') # Define data structures used as part of the metadata below. ec2_regions = ( 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2', 'eu-west-1', 'sa-east-1', 'us-east-1', 'us-west-1', 'us-west-2', ) hp_regions = ( 'az-1.region-a.geo-1', 'az-2.region-a.geo-1', 'az-3.region-a.geo-1') azure_locations = ( 'East US', 'West US', 'West Europe', 'North Europe', 'Southeast Asia', 'East Asia') # Define the env_type_db dictionary: this is done inside this function in # order to avoid instantiating fields at import time. # This is an ordered dict so that views can expose options to create new # environments in the order we like. env_type_db = collections.OrderedDict({ '__fallback__': { 'description': ( 'This provider type is not supported by quickstart. ' 'See https://juju.ubuntu.com/docs/getting-started.html for ' 'a description of how to get started with Juju.' ), 'fields': ( provider_field, name_field, admin_secret_field, default_series_field, is_default_field, ), }, }) env_type_db['ec2'] = { 'label': 'Amazon EC2', 'description': ( 'The ec2 provider enables you to run Juju on the EC2 cloud. ' 'This process requires you to have an Amazon Web Services (AWS) ' 'account. If you have not signed up for one yet, it can obtained ' 'at http://aws.amazon.com. ' 'See https://juju.ubuntu.com/docs/config-aws.html for more ' 'details on the ec2 provider configuration.' ), 'fields': ( provider_field, name_field, admin_secret_field, default_series_field, fields.PasswordField( 'access-key', label='access key', required=True, help='The access key to use to authenticate to AWS. ' 'You can retrieve these values easily from your AWS ' 'Management Console (http://console.aws.amazon.com). ' 'Click on your name in the top-right and then the ' '"Security Credentials" link from the drop down menu. ' 'Under the "Access Keys" heading click the ' '"Create New Root Key" button. You will be prompted to ' '"Download Key File" which by default is named ' 'rootkey.csv. Open this file to get the access-key and ' 'secret-key for the environments.yaml config file.'), fields.PasswordField( 'secret-key', label='secret key', required=True, help='The secret key to use to authenticate to AWS. ' 'See the access key help above.'), fields.AutoGeneratedStringField( 'control-bucket', label='control bucket', required=True, help='the globally unique S3 bucket name'), fields.ChoiceField( 'region', choices=ec2_regions, default='us-east-1', label='region', required=False, help='the ec2 region to use'), is_default_field, ), } env_type_db['openstack'] = { 'label': 'OpenStack (or HP Public Cloud)', 'description': ( 'The openstack provider enables you to run Juju on OpenStack ' 'private and public clouds. Use this also if you want to ' 'set up Juju on HP Public Cloud. See ' 'https://juju.ubuntu.com/docs/config-openstack.html and ' 'https://juju.ubuntu.com/docs/config-hpcloud.html for more ' 'details on the openstack provider configuration.' ), 'fields': ( provider_field, name_field, admin_secret_field, default_series_field, fields.BoolField( 'use-floating-ip', label='use floating IP', allow_mixed=False, required=True, help='Specifies whether the use of a floating IP address is ' 'required to give the nodes a public IP address. ' 'Some installations assign public IP addresses by ' 'default without requiring a floating IP address.'), fields.AutoGeneratedStringField( 'control-bucket', label='control bucket', required=True, help='the globally unique swift bucket name'), fields.SuggestionsStringField( 'auth-url', label='authentication URL', required=True, help='The Keystone URL to use in the authentication process. ' 'For HP Public Cloud, use the value suggested below:', suggestions=['https://region-a.geo-1.identity.hpcloudsvc.com' ':35357/v2.0/']), fields.StringField( 'tenant-name', label='tenant name', required=True, help='The OpenStack tenant name. For HP Public Cloud, this is ' 'listed as the project name on the ' 'https://account.hpcloud.com/projects page.'), fields.SuggestionsStringField( 'region', label='region', required=True, help='The OpenStack region to use. ' 'For HP Public Cloud, use one of the following:', suggestions=hp_regions), fields.ChoiceField( 'auth-mode', label='authentication mode', required=False, default='userpass', choices=('userpass', 'keypair'), help='The way Juju authenticates to OpenStack. The userpass ' 'authentication requires you to fill in your user name ' 'and password. The keypair mode requires access key and ' 'secret key to be properly set up. For HP Public Cloud ' 'these information can be retrieved on the ' 'https://account.hpcloud.com/account/api_keys page.'), fields.StringField( 'username', label='user name', required=False, help='the user name to use for the userpass authentication'), fields.PasswordField( 'password', label='password', required=False, help='the user name to use for the userpass authentication'), fields.StringField( 'access-key', label='access key', required=False, help='the access key to use for the keypair authentication'), fields.PasswordField( 'secret-key', label='secret key', required=False, help='the secret key to use for the keypair authentication'), is_default_field, ), } env_type_db['azure'] = { 'label': 'Windows Azure', 'description': ( 'The azure provider enables you to run Juju on Windows Azure. ' 'It requires you to have an Windows Azure account. If you have ' 'not signed up for one yet, it can obtained at ' 'http://www.windowsazure.com/. See ' 'https://juju.ubuntu.com/docs/config-azure.html for more ' 'details on the azure provider configuration.' ), 'fields': ( provider_field, name_field, admin_secret_field, default_series_field, fields.ChoiceField( 'location', choices=azure_locations, label='location', required=True, help='the region to use'), fields.StringField( 'management-subscription-id', required=True, label='management subscription ID', help='this information can be retrieved from ' 'https://manage.windowsazure.com (Settings)'), fields.StringField( 'management-certificate-path', required=True, label='management certificate path', help='the path to the pem file associated to the certificate ' 'uploaded in the Azure management console: ' 'https://manage.windowsazure.com ' '(Settings -> Management Certificates)'), fields.StringField( 'storage-account-name', required=True, label='storage account name', help='the name you used when creating a storage account in ' 'the Azure management console: ' 'https://manage.windowsazure.com (Storage). ' 'You must create the storage account in the same ' 'region/location specified by the location key value.'), is_default_field, ), } env_type_db['local'] = { 'label': 'local (LXC)', 'description': ( 'The LXC local provider enables you to run Juju on a single ' 'system like your local computer or a single server. ' 'See https://juju.ubuntu.com/docs/config-LXC.html for more ' 'details on the local provider configuration.' ), 'fields': ( provider_field, name_field, admin_secret_field, default_series_field, fields.StringField( 'root-dir', label='root dir', required=False, default='~/.juju/local', help='the directory that is used for the storage files'), fields.IntField( 'storage-port', min_value=1, max_value=65535, label='storage port', required=False, default=8040, help='override if you have multiple local providers, ' 'or if the default port is used by another program'), fields.IntField( 'shared-storage-port', min_value=1, max_value=65535, label='shared storage port', required=False, default=8041, help='override if you have multiple local providers, ' 'or if the default port is used by another program'), fields.StringField( 'network-bridge', label='network bridge', required=False, default='lxcbr0', help='the LXC bridge interface to use'), is_default_field, ), } return env_type_db def get_supported_env_types(env_type_db): """Return a list of supported (provider type, label) tuples. Each tuple represents an environment type supported by Quickstart. """ return [ (env_type, metadata['label']) for env_type, metadata in env_type_db.items() if env_type != '__fallback__' ] def get_env_metadata(env_type_db, env_data): """Return the meta information (fields, description) suitable for env_data. Use the given env_type_db to retrieve the metadata corresponding to the environment type included in env_data. The resulting metadata can be used to parse/validate environments or to enrich the user experience e.g. by providing additional information about the fields composing a specific environment type, or about the environment type itself. """ env_type = env_data.get('type', '__fallback__') return env_type_db.get(env_type, env_type_db['__fallback__']) def map_fields_to_env_data(env_metadata, env_data): """Return a list of (field, value) tuples. Each tuple stores a field (instance of fields.Field or its subclasses) describing a specific environment key in env_data, and the corresponding value in env_data. If extraneous keys (not described by env_metadata) are found in env_data, then those keys are assigned a generic default field (fields.Field). """ data = copy.deepcopy(env_data) # Start building a list of (field, value) pairs preserving field order. field_value_pairs = [ (field, data.pop(field.name, None)) for field in env_metadata['fields'] ] # Add the remaining (unexpected) pairs using the unexpected field type. field_value_pairs.extend( (fields.UnexpectedField(key), value) for key, value in data.items() ) return field_value_pairs def validate(env_metadata, env_data): """Validate values in env_data. Return a dictionary of errors mapping field names to error messages. If the environment is valid, return an empty dictionary. """ errors = {} for field, value in map_fields_to_env_data(env_metadata, env_data): try: field.validate(value) except ValueError as err: errors[field.name] = bytes(err).decode('utf-8') return errors def normalize(env_metadata, env_data): """Normalize the values in env_data. Return a new env_data containing the normalized values. This function assumes env_data contains valid values. """ normalized_env_data = {} for field, value in map_fields_to_env_data(env_metadata, env_data): value = field.normalize(value) # Only include the key/value pair if the corresponding field is # required or the value is set. if field.required or value is not None: normalized_env_data[field.name] = value return normalized_env_data def get_env_short_description(env_data): """Return a short description of the given env_data. The given env_data must include at least the "name" and "is-default" keys. The description is like the following: "aws (type: ec2, default)" # Default ec2 environment. "lxc (type: local)" # Non-default local environment. "unknown" # An environment with no type set. """ info_parts = [] env_type = env_data.get('type') if env_type is not None: info_parts.append('type: {}'.format(env_type)) if env_data['is-default']: info_parts.append('default') info = '' if info_parts: info = ' ({})'.format(', '.join(info_parts)) return env_data['name'] + info juju-quickstart-1.3.1/quickstart/models/__init__.py0000644000175000017500000000151112247664652024051 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart models.""" juju-quickstart-1.3.1/quickstart/ssh.py0000644000175000017500000001151612307347546021627 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart SSH management functions.""" from __future__ import ( print_function, unicode_literals, ) import re import os import sys import time from quickstart import utils def check_keys(): """Check whether or not ssh keys exist and are loaded by the agent. Raise an OSError if we can't load the keys because they're broken in some way. Return true if there are ssh identities loaded and available in an agent, false otherwise. """ no_keys_msg = 'The agent has no identities.\n' retcode, output, _ = utils.call('/usr/bin/ssh-add', '-l') if retcode == 0: # We have keys and an agent. return True elif retcode == 1 and output == no_keys_msg: # We have an agent, but no keys currently loaded. retcode, output, error = utils.call('/usr/bin/ssh-add') if retcode == 0: # We were able to load an identity return True elif retcode == 1: # If ssh-add is called without -l and there are no identities # available, there will be no output or error, but retcode will # still be 1. return False else: # We weren't able to load keys for some other reason, such as being # readable by group or world, or malformed. msg = 'error attempting to add ssh keys: {}'.format(error) raise OSError(msg.encode('utf-8')) return False def create_keys(): """Create SSH keys for the user. Raises an OSError if the keys were not created successfully. NB: this involves user interaction for entering the passphrase; this may have to change if creating SSH keys takes place in the urwid interface. """ key_file = os.path.join(os.path.expanduser('~'), '.ssh', 'id_rsa') print('generating new ssh key...') retcode, _, error = utils.call('/usr/bin/ssh-keygen', '-q', # silent '-b', '4096', # 4096 bytes '-t', 'rsa', # RSA type '-C', 'Generated with Juju Quickstart', '-f', key_file) if retcode: msg = 'error generating ssh key: {}'.format(error) raise OSError(msg.encode('utf-8')) print('adding key to ssh-agent...') retcode, _, error = utils.call('/usr/bin/ssh-add') if retcode: msg = 'error adding key to agent: {}'.format(error) raise OSError(msg.encode('utf-8')) print('a new ssh key was generated in {}'.format(key_file)) def start_agent(): """Start an ssh-agent and propagate its environment variables.""" retcode, output, error = utils.call('/usr/bin/ssh-agent') if retcode: raise OSError(error.encode('utf-8')) os.putenv('SSH_AUTH_SOCK', re.search('SSH_AUTH_SOCK=([^;]+);', output).group(1)) os.putenv('SSH_AGENT_PID', re.search('SSH_AGENT_PID=([^;]+);', output).group(1)) print('ssh-agent has been started.\n' 'To interact with Juju or quickstart again after quickstart\n' 'finishes, please run the following in a terminal to start ' 'ssh-agent:\n eval `ssh-agent`\n') def watch_for_keys(): """Watch for generation of ssh keys from another terminal or window. Raise an OSError if an error occurs while checking for SSH keys. This will run until keys become visible to quickstart or killed by the user. """ print('Please run this command in another terminal or window and follow\n' 'the instructions it produces; quickstart will continue when keys\n' 'are generated, or ^C to quit.\n\n' ' ssh-keygen -b 4096 -t rsa\n\nWaiting...') try: while not check_keys(): # Print and flush the buffer immediately; an empty end kwarg will # not cause the buffer to flush until after a certain number of # bytes. print('.', end='') sys.stdout.flush() time.sleep(3) except KeyboardInterrupt: sys.exit(b'\nquitting') juju-quickstart-1.3.1/quickstart/tests/0000755000175000017500000000000012320751720021602 5ustar frankbanfrankban00000000000000juju-quickstart-1.3.1/quickstart/tests/cli/0000755000175000017500000000000012320751720022351 5ustar frankbanfrankban00000000000000juju-quickstart-1.3.1/quickstart/tests/cli/test_forms.py0000644000175000017500000005057412265766334025142 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart CLI forms management.""" from __future__ import unicode_literals import collections import unittest import mock import urwid from quickstart.cli import ( forms, ui, ) from quickstart.models import fields from quickstart.tests.cli import helpers as cli_helpers class TestGenerate(unittest.TestCase): def test_generation(self): # The text of the given widget is set to the return value of the given # callable. generate_callable = lambda: 'generated' mock_edit_widget = mock.Mock() forms._generate(generate_callable, mock_edit_widget) mock_edit_widget.set_edit_text.assert_called_once_with('generated') class TestCreateGenerateWidget(unittest.TestCase): def test_widget_creation(self): # The generate widget is properly created and returned. generate_callable = lambda: 'generated' mock_edit_widget = mock.Mock() widget = forms._create_generate_widget( generate_callable, mock_edit_widget) # The generate button is the first widget in the urwid.Columns. button = widget.contents[0][0] cli_helpers.emit(button) mock_edit_widget.set_edit_text.assert_called_once_with('generated') class ChoicesTestsMixin(object): """Helpers for inspecting the choices widget.""" def get_buttons(self, choices_widget): """Return the MenuButton instances included in the choices_widget.""" # The grid widget including choices is the second widget in the pile. grid = choices_widget.contents[1][0] buttons = [ widget for widget, _ in grid.contents if isinstance(widget, ui.MenuButton) ] # Check if the unset button is present. columns_or_text = choices_widget.contents[2][0] if isinstance(columns_or_text, urwid.Columns): buttons.append(columns_or_text.contents[1][0]) return buttons def assert_choices(self, expected_choices, choices_widget): """Ensure the given choices widget is well formed. The message displayed should equal the given expected_message. """ obtained_choices = [ cli_helpers.get_button_caption(button) for button in self.get_buttons(choices_widget) ] self.assertEqual(expected_choices, obtained_choices) class TestCreateButtonsGridWidget(unittest.TestCase): def setUp(self): # Set up a mock edit widget and choices. self.mock_edit_widget = mock.Mock() self.choices = ['Kirk', 'Picard', 'Sisko'] self.widget = forms._create_buttons_grid_widget( self.choices, self.mock_edit_widget) def get_buttons(self): """Return a list of buttons included in self.widget.""" return [button for button, _ in self.widget.contents] def test_widget_structure(self): # The resulting widget is a urwid.GridFlow including all the expected # button widgets. self.assertIsInstance(self.widget, urwid.GridFlow) buttons = self.get_buttons() self.assertEqual(len(self.choices), len(buttons)) self.assertEqual( self.choices, map(cli_helpers.get_button_caption, buttons)) def test_button_click(self): # The given edit widget is updated when buttons are clicked. for button in self.get_buttons(): choice = cli_helpers.get_button_caption(button) # Click the button. cli_helpers.emit(button) # Ensure the edit widget has been updated accordingly. self.mock_edit_widget.set_edit_text.assert_called_with(choice) class TestCreateChoicesWidget(ChoicesTestsMixin, unittest.TestCase): def setUp(self): # Set up a mock edit widget and choices. self.mock_edit_widget = mock.Mock() self.choices = ['Kirk', 'Picard', 'Sisko'] def test_widget_message(self): # The resulting widget includes all the choices. widget = forms._create_choices_widget( self.choices, True, self.mock_edit_widget) # The resulting pile widget is composed by choices and help message. self.assert_choices(self.choices, widget) help_widget = widget.contents[2][0] self.assertEqual( 'click the choices to auto-fill the field with the ' 'standard options', help_widget.text) def test_widget_buttons(self): # The widget includes a button for each choice. widget = forms._create_choices_widget( self.choices, True, self.mock_edit_widget) buttons = self.get_buttons(widget) self.assertEqual(3, len(buttons)) self.assertEqual( self.choices, map(cli_helpers.get_button_caption, buttons)) def test_choice_click(self): # Clicking a choice updates the edit widget. widget = forms._create_choices_widget( self.choices, True, self.mock_edit_widget) buttons = self.get_buttons(widget) for button in buttons: choice = cli_helpers.get_button_caption(button) # Click the button. cli_helpers.emit(button) # Ensure the edit widget has been updated accordingly. self.mock_edit_widget.set_edit_text.assert_called_with(choice) def test_not_required(self): # If the field is not required, an option to unset the field is # displayed. widget = forms._create_choices_widget( self.choices, False, self.mock_edit_widget) self.assert_choices(self.choices + ['left empty'], widget) buttons = self.get_buttons(widget) # The last button is used to unset the edit widget. self.assertEqual(4, len(buttons)) cli_helpers.emit(buttons[-1]) self.mock_edit_widget.set_edit_text.assert_called_once_with('') class TestCreateStringWidget(ChoicesTestsMixin, unittest.TestCase): def inspect_widget(self, widget, field): """Return a dict of sub-widgets composing the given string widget. The dictionary includes the following keys: - wrapper: the wrapper edit widget; - edit: the base edit widget; - caption: the caption text widget; - help: the help widget (or None if not present); - error: the error text widget (or None if not present); - suggestions: the suggested values grid (or None if not present); - generate: the generate text widget (or None if not present); - choices: the choices text widget (or None if not present); - default: the default text widget (or None if not present). """ widgets = collections.defaultdict(lambda: None) # Retrieve the Pile contents ignoring the last Divider widget. contents = list(widget.contents)[:-1] first_widget = contents.pop(0)[0] if isinstance(first_widget, urwid.Text): # This is the error message. widgets.update({ 'error': first_widget, 'wrapper': contents.pop(0)[0] }) else: # The widget has no errors. widgets['wrapper'] = first_widget caption_attrs, edit_attrs = widgets['wrapper'].base_widget.contents widgets.update({ 'edit': edit_attrs[0], 'caption': caption_attrs[0], }) if field.help: widgets['help'] = contents.pop(0)[0] if hasattr(field, 'suggestions'): widgets['suggestions'] = contents.pop(0)[0] if hasattr(field, 'generate'): widgets['generate'] = contents.pop(0)[0] if hasattr(field, 'choices'): widgets['choices'] = contents.pop(0)[0] if field.default is not None: widgets['default'] = contents.pop(0)[0] return widgets def test_widget_structure(self): # The widget includes all the information about a field. field = fields.StringField( 'first-name', label='first name', help='your first name', default='Jean') widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name') widgets = self.inspect_widget(widget, field) # Since the field is not read-only, the widget is properly enabled. self.assertNotIsInstance(widgets['wrapper'], urwid.WidgetDisable) # The edit widget is set to the given value. self.assertEqual('Luc', widgets['edit'].get_edit_text()) # The caption and help are properly set. self.assertEqual('\N{BULLET} first name: ', widgets['caption'].text) self.assertEqual('your first name', widgets['help'].text) # The error is displayed. self.assertEqual('invalid name', widgets['error'].text) # The field is not able to generate a value, and there are no choices # or suggestions. self.assertIsNone(widgets['generate']) self.assertIsNone(widgets['suggestions']) self.assertIsNone(widgets['choices']) # The default value is properly displayed. self.assertEqual('default if not set: Jean', widgets['default'].text) def test_value_getter(self): # The returned value getter function returns the current widget value. field = fields.StringField('first-name') widget, value_getter = forms.create_string_widget(field, 'Luc', None) edit = self.inspect_widget(widget, field)['edit'] self.assertEqual('Luc', value_getter()) # The value getter is lazy and always returns the current value. edit.set_edit_text('Jean-Luc') self.assertEqual('Jean-Luc', value_getter()) def test_unset_value(self): # The initial value is set to an empty string if the field is unset. field = fields.StringField('first-name') _, value_getter = forms.create_string_widget(field, None, None) self.assertEqual('', value_getter()) def test_value_not_a_string(self): # Non-string values are converted to unicode strings. field = fields.StringField('first-name') _, value_getter = forms.create_string_widget(field, 42, None) self.assertEqual('42', value_getter()) def test_readonly_field(self): # The widget is disabled if the field is read-only. field = fields.StringField('first-name', readonly=True) widget, _ = forms.create_string_widget(field, 'Jean-Luc', None) wrapper = self.inspect_widget(widget, field)['wrapper'] self.assertIsInstance(wrapper, urwid.WidgetDisable) def test_suggestions(self): # Suggested values, if present, are properly displayed below the edit # widget. field = fields.SuggestionsStringField( 'captain', suggestions=('Kirk', 'Picard')) widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name') suggestions = self.inspect_widget(widget, field)['suggestions'] captions = [ cli_helpers.get_button_caption(button) for button, attrs in suggestions.contents ] self.assertEqual(['Kirk', 'Picard'], captions) def test_autogenerated_field(self): # The widgets allows for automatically generating field values. field = fields.AutoGeneratedStringField('password') with mock.patch.object(field, 'generate', lambda: 'auto-generated!'): widget, value_getter = forms.create_string_widget(field, '', None) generate = self.inspect_widget(widget, field)['generate'] # The generate button is the first widget in the urwid.Columns. generate_button = generate.contents[0][0] # Click the generate button, and ensure the value has been generated. cli_helpers.emit(generate_button) self.assertEqual('auto-generated!', value_getter()) def test_choices(self): # Possible choices are properly displayed below the edit widget. field = fields.ChoiceField('captain', choices=('Kirk', 'Picard')) widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name') choices = self.inspect_widget(widget, field)['choices'] self.assert_choices(['Kirk', 'Picard', 'left empty'], choices) def test_choices_required_field(self): # Possible choices are properly displayed below the edit widget for # required fields. field = fields.ChoiceField( 'captain', required=True, choices=('Janeway', 'Sisko')) widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name') choices = self.inspect_widget(widget, field)['choices'] self.assert_choices(['Janeway', 'Sisko'], choices) class TestCreateBoolWidget(unittest.TestCase): def inspect_widget(self, widget, field): """Return a list of sub-widgets composing the given boolean widget. The sub-widgets are: - the checkbox widget; - the help widget (or None if not present); - the error text widget (or None if not present); """ help = error = None # Retrieve the Pile contents ignoring the last Divider widget. contents = list(widget.contents)[:-1] first_widget = contents.pop(0)[0] if isinstance(first_widget, urwid.Text): # This is the error message. error = first_widget checkbox = contents.pop(0)[0] else: # The widget has no errors. checkbox = first_widget if field.help: help = contents.pop(0)[0] return checkbox, help, error def test_widget_structure(self): # The widget includes all the information about a field. field = fields.BoolField( 'enabled', label='is enabled', help='something is enabled') widget, _ = forms.create_bool_widget(field, False, 'bad wolf') checkbox, help, error = self.inspect_widget(widget, field) # Since the field is not read-only, the widget is properly enabled. self.assertNotIsInstance(checkbox, urwid.WidgetDisable) # The checkbox widget is set to the given value. self.assertFalse(checkbox.get_state()) # The help message is properly displayed. self.assertEqual('something is enabled', help.text) # The error is displayed. self.assertEqual('bad wolf', error.text) def test_value_getter(self): # The returned value getter function returns the current widget value. field = fields.BoolField('enabled') widget, value_getter = forms.create_bool_widget(field, True, None) checkbox = self.inspect_widget(widget, field)[0] self.assertTrue(value_getter()) # The value getter is lazy and always returns the current value. checkbox.set_state(False) self.assertFalse(value_getter()) def test_allow_mixed(self): # The checkbox has three possible states if allow_mixed is True. field = fields.BoolField('enabled', allow_mixed=True) widget, value_getter = forms.create_bool_widget(field, None, None) checkbox = self.inspect_widget(widget, field)[0] self.assertTrue(checkbox.has_mixed) self.assertEqual(forms.MIXED, checkbox.get_state()) # A mixed value is converted to None when the value is retrieved. self.assertIsNone(value_getter()) def test_mixed_not_allowed(self): # The checkbox can only be in a True/False state if the field's # allow_mixed is set to False. field = fields.BoolField('enabled', allow_mixed=False, default=True) widget, value_getter = forms.create_bool_widget(field, None, None) checkbox = self.inspect_widget(widget, field)[0] self.assertFalse(checkbox.has_mixed) # The default value is used if the input value is unset and mixed state # is not allowed. self.assertTrue(checkbox.get_state()) # The retrieved value reflects the checkbox internal state. self.assertTrue(value_getter()) def test_readonly_field(self): # The widget is disabled if the field is read-only. field = fields.BoolField('enabled', readonly=True) widget, _ = forms.create_bool_widget(field, False, None) checkbox = self.inspect_widget(widget, field)[0] self.assertIsInstance(checkbox, urwid.WidgetDisable) class TestCreateForm(unittest.TestCase): field_value_pairs = ( (fields.StringField('first-name'), 'Jean-Luc'), (fields.BoolField('enabled'), True), ) def test_form_creation(self): # The create_form factory correctly creates and returns the form # widgets for each field/value pair provided. widgets, _ = forms.create_form(self.field_value_pairs, {}) self.assertEqual(2, len(widgets)) string_widget, bool_widget = widgets caption = string_widget.contents[0][0].base_widget.contents[0][0].text self.assertEqual('first-name: ', caption) checkbox_label = bool_widget.contents[0][0].label self.assertEqual('enabled', checkbox_label) def test_values_getter(self): # The returned values getter function returns the current form values. widgets, values_getter = forms.create_form(self.field_value_pairs, {}) self.assertEqual( {'enabled': True, 'first-name': u'Jean-Luc'}, values_getter()) # The values getter is lazy and always returns the current values. bool_widget = widgets[1] checkbox = bool_widget.contents[0][0] checkbox.set_state(False) self.assertEqual( {'enabled': False, 'first-name': u'Jean-Luc'}, values_getter()) def test_error_message(self): # The user is asked to correct the form errors. errors = {'first-name': 'invalid name'} widgets, _ = forms.create_form(self.field_value_pairs, errors) message = widgets[0] self.assertEqual('please correct the error below', message.text) def test_multiple_error_messages(self): # The user is asked to correct multiple form errors. errors = {'first-name': 'invalid name', 'enabled': 'invalid value'} widgets, _ = forms.create_form(self.field_value_pairs, errors) message = widgets[0] self.assertEqual('please correct the 2 errors below', message.text) def test_errros(self): # Widgets are created passing the corresponding errors. errors = {'first-name': 'invalid name'} widgets, _ = forms.create_form(self.field_value_pairs, errors) # The first widget is the error message, the second is a divider. string_widget = widgets[2] error = string_widget.contents[0][0] self.assertEqual('invalid name', error.text) class TestCreateActions(unittest.TestCase): def setUp(self): # Set up actions. self.clicked = [] actions = [ ('load', ui.thunk(self.clicked.append, 'load clicked')), ('save', ui.thunk(self.clicked.append, 'save clicked')), ] # Create the controls and retrieve the menu buttons. controls = forms.create_actions(actions) columns = controls.contents[1][0].base_widget self.buttons = [widget.base_widget for widget, _ in columns.contents] def test_resulting_controls(self): # A menu button is created for each action. self.assertEqual(2, len(self.buttons)) load_button, save_button = self.buttons self.assertEqual('load', cli_helpers.get_button_caption(load_button)) self.assertEqual('save', cli_helpers.get_button_caption(save_button)) def test_callbacks(self): # The action callbacks are properly attached to each menu button. load_button, save_button = self.buttons cli_helpers.emit(load_button) self.assertEqual(['load clicked'], self.clicked) cli_helpers.emit(save_button) self.assertEqual(['load clicked', 'save clicked'], self.clicked) juju-quickstart-1.3.1/quickstart/tests/cli/test_views.py0000644000175000017500000007707712272750554025153 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart CLI application views.""" from __future__ import unicode_literals from contextlib import contextmanager import unittest import mock import urwid from quickstart import settings from quickstart.cli import ( base, forms, ui, views, ) from quickstart.models import envs from quickstart.tests import helpers from quickstart.tests.cli import helpers as cli_helpers class TestShow(unittest.TestCase): @contextmanager def patch_setup_urwid_app(self, run_side_effect=None): """Patch the base.setup_urwid_app function. The context manager returns a tuple (mock_loop, mock_app) containing the two mock objects returned by the mock call. The run_side_effect argument can be provided to specify the side effects of the mock_loop.run call. """ mock_loop = mock.Mock() mock_loop.run = mock.Mock(side_effect=run_side_effect) mock_setup_urwid_app = mock.Mock(return_value=(mock_loop, mock.Mock())) setup_urwid_app_path = 'quickstart.cli.views.base.setup_urwid_app' with mock.patch(setup_urwid_app_path, mock_setup_urwid_app): yield mock_setup_urwid_app() def test_show_view(self): # The loop and app objects are properly used by the show function: # the loop is run and the app is passed to the view. view = mock.Mock() with self.patch_setup_urwid_app() as (mock_loop, mock_app): views.show(view) view.assert_called_once_with(mock_app) mock_loop.run.assert_called_once_with() def test_view_exit(self): # An ui.AppExit correctly quits the application. The return value # encapsulated on the exception is also returned by the show function. view = mock.Mock() run_side_effect = ui.AppExit('bad wolf') with self.patch_setup_urwid_app(run_side_effect=run_side_effect): return_value = views.show(view) self.assertEqual('bad wolf', return_value) def test_view_arguments(self): # The view is invoked passing the app and all the optional show # function arguments. view = mock.Mock() with self.patch_setup_urwid_app() as (mock_loop, mock_app): views.show(view, 'arg1', 'arg2') view.assert_called_once_with(mock_app, 'arg1', 'arg2') class EnvViewTestsMixin(cli_helpers.CliAppTestsMixin): """Shared helpers for testing environment views.""" env_type_db = envs.get_env_type_db() def setUp(self): # Set up the base Urwid application. self.loop, self.app = base.setup_urwid_app() self.save_callable = mock.Mock() def get_widgets_in_contents(self, filter_function=None): """Return a list of widgets included in the app contents. Use the filter_function argument to filter the returned list. """ contents = self.app.get_contents() return filter(filter_function, list(contents.base_widget.body)) def get_control_buttons(self): """Return the list of buttons included in a control box. Control boxes are created using ui.create_controls(). """ piles = self.get_widgets_in_contents( filter_function=self.is_a(urwid.Pile)) # Assume the control box is the last Pile. controls = piles[-1] # The button columns is the second widget in the Pile. columns = controls.contents[1][0].base_widget return [content[0].base_widget for content in columns.contents] def is_a(self, cls): """Return a function returning True if the given argument is a cls. The resulting function can be used as the filter_function argument in self.get_widgets_in_contents() calls. """ return lambda arg: isinstance(arg, cls) class TestEnvIndex(EnvViewTestsMixin, unittest.TestCase): base_status = ' \N{UPWARDS ARROW LEFTWARDS OF DOWNWARDS ARROW} navigate ' def test_view_default_return_value_on_exit(self): # The view configures the app so that the return value on user exit is # a tuple including a copy of the given env_db and None, the latter # meaning no environment has been selected. env_db = helpers.make_env_db() views.env_index(self.app, self.env_type_db, env_db, self.save_callable) new_env_db, env_data = self.get_on_exit_return_value(self.loop) self.assertEqual(env_db, new_env_db) self.assertIsNot(env_db, new_env_db) self.assertIsNone(env_data) def test_view_title(self): # The application title is correctly set up. env_db = helpers.make_env_db() views.env_index(self.app, self.env_type_db, env_db, self.save_callable) self.assertEqual( 'Select an existing Juju environment or create a new one', self.app.get_title()) def test_view_title_no_environments(self): # The application title changes if the env_db has no environments. env_db = {'environments': {}} views.env_index(self.app, self.env_type_db, env_db, self.save_callable) self.assertEqual( 'No Juju environments already set up: please create one', self.app.get_title()) def test_view_contents(self): # The view displays a list of the environments in env_db, and buttons # to create new environments. env_db = helpers.make_env_db() views.env_index(self.app, self.env_type_db, env_db, self.save_callable) buttons = self.get_widgets_in_contents( filter_function=self.is_a(ui.MenuButton)) # A button is created for each existing environment (see details) and # for each environment type supported by quickstart (create). env_types = envs.get_supported_env_types(self.env_type_db) expected_buttons_number = len(env_db['environments']) + len(env_types) self.assertEqual(expected_buttons_number, len(buttons)) @mock.patch('quickstart.cli.views.env_detail') def test_environment_clicked(self, mock_env_detail): # The environment detail view is called when clicking an environment. env_db = helpers.make_env_db() views.env_index(self.app, self.env_type_db, env_db, self.save_callable) buttons = self.get_widgets_in_contents( filter_function=self.is_a(ui.MenuButton)) # The environments are listed in alphabetical order. environments = sorted(env_db['environments']) for env_name, button in zip(environments, buttons): env_data = envs.get_env_data(env_db, env_name) # The caption includes the environment description. env_description = envs.get_env_short_description(env_data) self.assertIn( env_description, cli_helpers.get_button_caption(button)) # When the button is clicked, the detail view is called passing the # corresponding environment data. cli_helpers.emit(button) mock_env_detail.assert_called_once_with( self.app, self.env_type_db, env_db, self.save_callable, env_data) # Reset the mock so that it does not include any calls on the next # loop cycle. mock_env_detail.reset_mock() @mock.patch('quickstart.cli.views.env_edit') def test_create_new_environment_clicked(self, mock_env_edit): # The environment edit view is called when clicking to create a new # environment. env_db = helpers.make_env_db() views.env_index(self.app, self.env_type_db, env_db, self.save_callable) buttons = self.get_widgets_in_contents( filter_function=self.is_a(ui.MenuButton)) env_types = envs.get_supported_env_types(self.env_type_db) for type_tuple, button in zip(env_types, buttons[-len(env_types):]): env_type, label = type_tuple # The caption includes the environment type label. expected_caption = 'new {} environment'.format(label) caption = cli_helpers.get_button_caption(button) self.assertIn(expected_caption, caption) # When the button is clicked, the edit view is called passing the # corresponding environment data. cli_helpers.emit(button) mock_env_edit.assert_called_once_with( self.app, self.env_type_db, env_db, self.save_callable, {'type': env_type, 'default-series': settings.JUJU_GUI_PREFERRED_SERIES}) # Reset the mock so that it does not include any calls on the next # loop cycle. mock_env_edit.reset_mock() def test_create_and_bootstrap_local_environment_clicked(self): # When there are no environments in the env_db the view exposes an # option to automatically create and bootstrap a new local environment. # If that option is clicked, the view quits the application returning # the newly created env_data. env_db = envs.create_empty_env_db() views.env_index(self.app, self.env_type_db, env_db, self.save_callable) buttons = self.get_widgets_in_contents( filter_function=self.is_a(ui.MenuButton)) # The "create and bootstrap" button is the first one in the contents. create_button = buttons[0] self.assertEqual( '\N{BULLET} automatically create and bootstrap a local ' 'environment', cli_helpers.get_button_caption(create_button)) # An AppExit is raised clicking the button. with self.assertRaises(ui.AppExit) as context_manager: cli_helpers.emit(create_button) new_env_db, env_data = context_manager.exception.return_value # The environments database is no longer empty. self.assertIn('local', new_env_db['environments']) self.assertEqual(envs.get_env_data(new_env_db, 'local'), env_data) def test_selected_environment(self): # The default environment is already selected in the list. env_db = helpers.make_env_db(default='lxc') views.env_index(self.app, self.env_type_db, env_db, self.save_callable) env_data = envs.get_env_data(env_db, 'lxc') env_description = envs.get_env_short_description(env_data) contents = self.app.get_contents() focused_widget = contents.get_focus()[0] self.assertIsInstance(focused_widget, ui.MenuButton) self.assertIn( env_description, cli_helpers.get_button_caption(focused_widget)) def test_status_with_errors(self): # The status message explains how errors are displayed. env_db = helpers.make_env_db() views.env_index(self.app, self.env_type_db, env_db, self.save_callable) status = self.app.get_status() self.assertEqual(self.base_status + ' \N{BULLET} has errors ', status) def test_status_with_default(self): # The status message explains how default environment is represented. env_db = helpers.make_env_db(default='lxc', exclude_invalid=True) views.env_index(self.app, self.env_type_db, env_db, self.save_callable) status = self.app.get_status() self.assertEqual(self.base_status + ' \N{CHECK MARK} default ', status) def test_status_with_default_and_errors(self): # The status message includes both default and errors explanations. env_db = helpers.make_env_db(default='lxc') views.env_index(self.app, self.env_type_db, env_db, self.save_callable) status = self.app.get_status() self.assertEqual( self.base_status + ' \N{CHECK MARK} default \N{BULLET} has errors ', status) def test_status(self): # The status only includes navigation info if there are no errors. env_db = helpers.make_env_db(exclude_invalid=True) views.env_index(self.app, self.env_type_db, env_db, self.save_callable) status = self.app.get_status() self.assertEqual(self.base_status, status) class TestEnvDetail(EnvViewTestsMixin, unittest.TestCase): base_status = ' \N{RIGHTWARDS ARROW OVER LEFTWARDS ARROW} navigate ' env_db = helpers.make_env_db(default='lxc') def call_view(self, env_name='lxc'): """Call the view passing the env_data corresponding to env_name.""" self.env_data = envs.get_env_data(self.env_db, env_name) return views.env_detail( self.app, self.env_type_db, self.env_db, self.save_callable, self.env_data) def test_view_default_return_value_on_exit(self): # The view configures the app so that the return value on user exit is # a tuple including a copy of the given env_db and None, the latter # meaning no environment has been selected (for now). self.call_view() new_env_db, env_data = self.get_on_exit_return_value(self.loop) self.assertEqual(self.env_db, new_env_db) self.assertIsNot(self.env_db, new_env_db) self.assertIsNone(env_data) def test_view_title(self): # The application title is correctly set up: it shows the description # of the current environment. self.call_view() env_description = envs.get_env_short_description(self.env_data) self.assertEqual(env_description, self.app.get_title()) def test_view_contents(self): # The view displays a list of the environment fields. self.call_view() widgets = self.get_widgets_in_contents( filter_function=self.is_a(urwid.Text)) env_metadata = envs.get_env_metadata(self.env_type_db, self.env_data) expected_texts = [ '{}: {}'.format(field.name, field.display(value)) for field, value in envs.map_fields_to_env_data(env_metadata, self.env_data) if field.required or (value is not None) ] for expected_text, widget in zip(expected_texts, widgets): self.assertEqual(expected_text, widget.text) def test_view_buttons(self): # The following buttons are displayed: "back", "use", "set default", # "edit" and "remove". self.call_view(env_name='ec2-west') buttons = self.get_control_buttons() captions = map(cli_helpers.get_button_caption, buttons) self.assertEqual( ['back', 'use', 'set default', 'edit', 'remove'], captions) def test_view_buttons_default(self): # If the environment is the default one, the "set default" button is # not displayed. The buttons we expect are "back", "use", "edit" and # "remove". self.call_view(env_name='lxc') buttons = self.get_control_buttons() captions = map(cli_helpers.get_button_caption, buttons) self.assertEqual(['back', 'use', 'edit', 'remove'], captions) def test_view_buttons_error(self): # If the environment is not valid, the "use" button is not displayed. # The buttons we expect are "back", "set default", "edit" and "remove". self.call_view(env_name='local-with-errors') buttons = self.get_control_buttons() captions = map(cli_helpers.get_button_caption, buttons) self.assertEqual(['back', 'set default', 'edit', 'remove'], captions) @mock.patch('quickstart.cli.views.env_index') def test_back_button(self, mock_env_index): # The index view is called if the "back" button is clicked. self.call_view(env_name='ec2-west') # The "back" button is the first one. back_button = self.get_control_buttons()[0] cli_helpers.emit(back_button) mock_env_index.assert_called_once_with( self.app, self.env_type_db, self.env_db, self.save_callable) def test_use_button(self): # The application exits if the "use" button is clicked. # The env_db and the current environment data are returned. self.call_view(env_name='ec2-west') # The "use" button is the second one. use_button = self.get_control_buttons()[1] with self.assertRaises(ui.AppExit) as context_manager: cli_helpers.emit(use_button) expected_return_value = (self.env_db, self.env_data) self.assertEqual( expected_return_value, context_manager.exception.return_value) @mock.patch('quickstart.cli.views.env_index') def test_set_default_button(self, mock_env_index): # The current environment is set as default if the "set default" button # is clicked. Subsequently the application switches to the index view. self.call_view(env_name='ec2-west') # The "set default" button is the third one. set_default_button = self.get_control_buttons()[2] cli_helpers.emit(set_default_button) # The index view has been called passing the modified env_db as third # argument. self.assertTrue(mock_env_index.called) new_env_db = mock_env_index.call_args[0][2] # The new env_db has a new default. self.assertEqual(new_env_db['default'], 'ec2-west') # The new env_db has been saved. self.save_callable.assert_called_once_with(new_env_db) @mock.patch('quickstart.cli.views.env_edit') def test_edit_button(self, mock_env_edit): # The edit view is called if the "edit" button is clicked. self.call_view(env_name='ec2-west') # The "edit" button is the fourth one. edit_button = self.get_control_buttons()[3] cli_helpers.emit(edit_button) mock_env_edit.assert_called_once_with( self.app, self.env_type_db, self.env_db, self.save_callable, self.env_data) def test_remove_button(self): # A confirmation dialog is displayed if the "remove" button is clicked. self.call_view(env_name='ec2-west') original_contents = self.app.get_contents() # The "remove" button is the last one. remove_button = self.get_control_buttons()[-1] cli_helpers.emit(remove_button) # The original env detail contents have been replaced. contents = self.app.get_contents() self.assertIsNot(contents, original_contents) # A "remove" confirmation dialog is displayed. title_widget, message_widget, buttons = cli_helpers.inspect_dialog( contents) self.assertEqual('Remove the ec2-west environment', title_widget.text) self.assertEqual('This action cannot be undone!', message_widget.text) # The dialog includes the "cancel" and "confirm" buttons. self.assertEqual(2, len(buttons)) captions = map(cli_helpers.get_button_caption, buttons) self.assertEqual(['cancel', 'confirm'], captions) def test_remove_cancelled(self): # The "remove" confirmation dialog can be safely dismissed. self.call_view(env_name='ec2-west') original_contents = self.app.get_contents() # The "remove" button is the last one. remove_button = self.get_control_buttons()[-1] cli_helpers.emit(remove_button) contents = self.app.get_contents() buttons = cli_helpers.inspect_dialog(contents)[2] # The "cancel" button is the first one in the dialog. cancel_button = buttons[0] cli_helpers.emit(cancel_button) # The original contents have been restored. self.assertIs(original_contents, self.app.get_contents()) @mock.patch('quickstart.cli.views.env_index') def test_remove_confirmed(self, mock_env_index): # The current environment is removed if the "remove" button is clicked # and then the deletion is confirmed. Subsequently the application # switches to the index view. self.call_view(env_name='ec2-west') # The "remove" button is the last one. remove_button = self.get_control_buttons()[-1] cli_helpers.emit(remove_button) contents = self.app.get_contents() buttons = cli_helpers.inspect_dialog(contents)[2] # The "confirm" button is the second one in the dialog. confirm_button = buttons[1] cli_helpers.emit(confirm_button) # The index view has been called passing the modified env_db as third # argument. self.assertTrue(mock_env_index.called) new_env_db = mock_env_index.call_args[0][2] # The new env_db no longer includes the "ec2-west" environment. self.assertNotIn('ec2-west', new_env_db['environments']) # The new env_db has been saved. self.save_callable.assert_called_once_with(new_env_db) def test_status_with_errors(self): # The status message explains how field errors are displayed. self.call_view(env_name='local-with-errors') status = self.app.get_status() self.assertEqual( self.base_status + ' \N{LOWER SEVEN EIGHTHS BLOCK} field error ', status) def test_status_without_errors(self): # The status only includes navigation info if there are no errors. self.call_view(env_name='lxc') status = self.app.get_status() self.assertEqual(self.base_status, status) class TestEnvEdit(EnvViewTestsMixin, unittest.TestCase): env_db = helpers.make_env_db(default='lxc') def call_view(self, env_name='lxc', env_type=None): """Call the view passing the env_data corresponding to env_name. If env_type is provided, the view is a creation form, env_name is ignored and a new env_data is passed to the view. """ if env_type is None: self.env_data = envs.get_env_data(self.env_db, env_name) else: self.env_data = {'type': env_type} return views.env_edit( self.app, self.env_type_db, self.env_db, self.save_callable, self.env_data) def get_form_contents(self): """Return the form contents included in the app page. The contents are returned as a sequence of (caption, value) tuples. """ pile_widgets = self.get_widgets_in_contents( filter_function=self.is_a(urwid.Pile)) form_contents = [] for pile_widget in pile_widgets: base_widget = pile_widget.contents[0][0].base_widget if isinstance(base_widget, urwid.CheckBox): # This is a boolean widget. form_contents.append(( base_widget.label, base_widget.get_state())) elif hasattr(base_widget, 'contents'): # This is a string widget. caption = base_widget.contents[0][0].text value = base_widget.contents[1][0].get_edit_text() form_contents.append((caption, value)) return form_contents def patch_create_form(self, changes=None): """Patch the forms.create_form function. The create_form function returns the form widgets and a callable returning the new env data. Make the latter return the current self.env_data instead, optionally updated using the given changes. """ original_create_form = forms.create_form testcase = self class MockCreateForm(object): call_count = 0 errors = None new_env_data = None def __call__(self, field_value_pairs, errors): self.call_count += 1 self.errors = errors self.new_env_data = testcase.env_data.copy() if changes is not None: self.new_env_data.update(changes) form_widgets, _ = original_create_form( field_value_pairs, errors) return form_widgets, lambda: self.new_env_data @property def called(self): return bool(self.call_count) return mock.patch('quickstart.cli.forms.create_form', MockCreateForm()) def test_view_default_return_value_on_exit(self): # The view configures the app so that the return value on user exit is # a tuple including a copy of the given env_db and None, the latter # meaning no environment has been selected (for now). self.call_view() new_env_db, env_data = self.get_on_exit_return_value(self.loop) self.assertEqual(self.env_db, new_env_db) self.assertIsNot(self.env_db, new_env_db) self.assertIsNone(env_data) def test_creation_view_title(self): # The application title is correctly set up when the view is used to # create a new environment. self.call_view(env_type='ec2') self.assertEqual('Create a new ec2 environment', self.app.get_title()) def test_modification_view_title(self): # The application title is correctly set up when the view is used to # change an existing environment. self.call_view(env_name='lxc') self.assertEqual('Edit the local environment', self.app.get_title()) def test_view_contents_description(self): # The view displays the provider description. self.call_view() text_widgets = self.get_widgets_in_contents( filter_function=self.is_a(urwid.Text)) description = text_widgets[0] expected_description = self.env_type_db['local']['description'] self.assertEqual(expected_description, description.text) def test_view_contents_form(self): # The view displays a form containing all the environment fields. self.call_view() expected_form_contents = [ ('provider type: ', 'local'), ('environment name: ', 'lxc'), ('admin secret: ', 'bones'), ('default series: ', 'raring'), ('root dir: ', ''), ('storage port: ', '8888'), ('shared storage port: ', ''), ('network bridge: ', ''), ('default', True), ] self.assertEqual(expected_form_contents, self.get_form_contents()) def test_view_buttons(self): # The following buttons are displayed: "save", "cancel" and "restore". self.call_view(env_name='ec2-west') buttons = self.get_control_buttons() captions = map(cli_helpers.get_button_caption, buttons) self.assertEqual(['save', 'cancel', 'restore'], captions) @mock.patch('quickstart.cli.views.env_detail') def test_save_button(self, mock_env_detail): # The new data is saved if the user clicks the save button. # Subsequently the user is redirected to the env detail view. changes = {'admin-secret': 'Secret!'} with self.patch_create_form(changes=changes) as mock_create_form: self.call_view(env_name='ec2-west') self.assertTrue(mock_create_form.called) # The "save" button is the first one. save_button = self.get_control_buttons()[0] cli_helpers.emit(save_button) # At this point the new env data should be normalized and saved into # the environments database. env_metadata = envs.get_env_metadata(self.env_type_db, self.env_data) new_env_data = envs.normalize( env_metadata, mock_create_form.new_env_data) envs.set_env_data(self.env_db, 'ec2-west', new_env_data) # The new data has been correctly saved. self.save_callable.assert_called_once_with(self.env_db) # A message notifies the environment has been saved. self.assertEqual( 'ec2-west successfully modified', self.app.get_message()) # The application displays the environment detail view. mock_env_detail.assert_called_once_with( self.app, self.env_type_db, self.env_db, self.save_callable, new_env_data) @mock.patch('quickstart.cli.views.env_detail') def test_save_empty_db(self, mock_env_detail): # If the env_db is empty, the new environment is set as default. self.env_db = envs.create_empty_env_db() changes = { 'name': 'lxc', 'admin-secret': 'Secret!', 'is-default': False, } with self.patch_create_form(changes=changes): self.call_view(env_type='local') save_button = self.get_control_buttons()[0] cli_helpers.emit(save_button) expected_new_env_data = changes.copy() expected_new_env_data.update({'type': 'local', 'is-default': True}) envs.set_env_data(self.env_db, None, expected_new_env_data) mock_env_detail.assert_called_once_with( self.app, self.env_type_db, self.env_db, self.save_callable, expected_new_env_data) def test_save_invalid_form_data(self): # Errors are displayed if the user tries to save invalid data. changes = {'name': ''} with self.patch_create_form(changes=changes) as mock_create_form: self.call_view(env_name='ec2-west') self.assertTrue(mock_create_form.called) # The "save" button is the first one. save_button = self.get_control_buttons()[0] cli_helpers.emit(save_button) # In case of errors, the save callable is not called. self.assertFalse(self.save_callable.called) # The form has been re-rendered passing the errors. self.assertEqual(2, mock_create_form.call_count) self.assertEqual( {'name': 'a value is required for the environment name field'}, mock_create_form.errors) def test_save_invalid_new_name(self): # It is not allowed to save an environment with an already used name. changes = {'name': 'lxc'} with self.patch_create_form(changes=changes) as mock_create_form: self.call_view(env_name='ec2-west') self.assertTrue(mock_create_form.called) # The "save" button is the first one. save_button = self.get_control_buttons()[0] cli_helpers.emit(save_button) # In case of errors, the save callable is not called. self.assertFalse(self.save_callable.called) # The form has been re-rendered passing the errors. self.assertEqual(2, mock_create_form.call_count) self.assertEqual( {'name': 'an environment with this name already exists'}, mock_create_form.errors) @mock.patch('quickstart.cli.views.env_index') def test_creation_view_cancel_button(self, mock_env_index): # The index view is called if the "cancel" button is clicked while # creating a new environment. self.call_view(env_type='ec2') # The "cancel" button is the second one. cancel_button = self.get_control_buttons()[1] cli_helpers.emit(cancel_button) mock_env_index.assert_called_once_with( self.app, self.env_type_db, self.env_db, self.save_callable) @mock.patch('quickstart.cli.views.env_detail') def test_modification_view_cancel_button(self, mock_env_detail): # The index view is called if the "cancel" button is clicked while # creating a new environment. self.call_view(env_name='ec2-west') # The "cancel" button is the second one. cancel_button = self.get_control_buttons()[1] cli_helpers.emit(cancel_button) mock_env_detail.assert_called_once_with( self.app, self.env_type_db, self.env_db, self.save_callable, self.env_data) juju-quickstart-1.3.1/quickstart/tests/cli/test_base.py0000644000175000017500000001705312261541244024704 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart Urwid application base handling.""" from __future__ import unicode_literals import unittest import urwid from quickstart.cli import base from quickstart.tests.cli import helpers as cli_helpers class TestMainLoop(unittest.TestCase): def setUp(self): # Create a loop instance. self.widget = urwid.ListBox(urwid.SimpleFocusListWalker([])) self.loop = base._MainLoop(self.widget) def test_initialization(self): # The customized loop is properly initialized, and it is an instance # of the Urwid loop. self.assertEqual(self.widget, self.loop.widget) self.assertIsInstance(self.loop, urwid.MainLoop) def test_unhandled_input(self): # The unhandled_input function can be set after the initialization. inputs = [] self.loop.set_unhandled_input(inputs.append) self.loop.unhandled_input('ctrl z') self.assertEqual(['ctrl z'], inputs) def test_alarms(self): # It is possible to retrieve the list of event loop alarms. times_called = [] self.assertEqual(0, len(self.loop.get_alarms())) callback = lambda *args: times_called.append(1) self.loop.set_alarm_in(3, callback) alarms = self.loop.get_alarms() self.assertEqual(1, len(alarms)) alarms[0][1]() self.assertEqual(1, sum(times_called)) class TestSetupUrwidApp(cli_helpers.CliAppTestsMixin, unittest.TestCase): def setUp(self): # Set up the base Urwid application. self.loop, self.app = base.setup_urwid_app() def get_title_widget(self, loop): """Return the title widget given the application main loop.""" # The frame is the main overlay's top widget. frame = loop.widget.top_w # Retrieve the header. header = frame.contents['header'][0] # The title widget is the first in the header pile. return header.contents[0][0].base_widget def get_contents_widget(self, loop): """Return the contents widget given the application main loop.""" # The frame is the main overlay's top widget. frame = loop.widget.top_w # Retrieve the body. body = frame.contents['body'][0] # The contents widget is the body's original widget. return body.original_widget def _get_footer(self, loop): # The frame is the main overlay's top widget. frame = loop.widget.top_w # Retrieve the footer. return frame.contents['footer'][0] def get_status_widget(self, loop): """Return the status widget given the application main loop.""" footer = self._get_footer(loop) # The status columns is the third widget (message, divider, status). columns = footer.contents[2][0].base_widget # The status widget is the second one (base status, status). return columns.contents[1][0] def get_message_widget(self, loop): """Return the message widget given the application main loop.""" footer = self._get_footer(loop) # The message widget is the first one (message, divider, status). return footer.contents[0][0].base_widget def test_loop(self): # The returned loop is an instance of the base customized loop. self.assertIsInstance(self.loop, base._MainLoop) def test_app(self): # The returned app is the application named tuple self.assertIsInstance(self.app, base.App) def test_set_title(self): # The set_title API sets the application title. self.app.set_title('The Inner Light') title_widget = self.get_title_widget(self.loop) self.assertEqual('\nThe Inner Light', title_widget.text) def test_get_title(self): # The get_title API retrieves the application title. title_widget = self.get_title_widget(self.loop) title_widget.set_text('The Outer Space') self.assertEqual('The Outer Space', self.app.get_title()) def test_set_contents(self): # The set_contents API changes the application main contents widget. text_widget = urwid.Text('my contents') self.app.set_contents(text_widget) contents_widget = self.get_contents_widget(self.loop) self.assertEqual('my contents', contents_widget.text) def test_get_contents(self): # The get_contents API returns the contents widget. contents_widget = self.get_contents_widget(self.loop) self.assertEqual(contents_widget, self.app.get_contents()) def test_set_status(self): # The set_status API sets the status message displayed in the footer. self.app.set_status('press play on tape') status_widget = self.get_status_widget(self.loop) self.assertEqual('press play on tape', status_widget.text) def test_get_status(self): # The get_status API returns the current status message. status_widget = self.get_status_widget(self.loop) status_widget.set_text('hit space to continue') self.assertEqual('hit space to continue', self.app.get_status()) def test_set_message(self): # The set_message API sets the message to be displayed in the # notification area. self.app.set_message('this will disappear') message_widget = self.get_message_widget(self.loop) self.assertEqual('this will disappear', message_widget.text) # An alarm is set to make this message disappear. alarms = self.loop.get_alarms() self.assertEqual(1, len(alarms)) # Calling the callback makes the message go away. _, callback = alarms[0] callback() self.assertEqual('', message_widget.text) def test_get_message(self): # The get_message API returns the current notification message. message_widget = self.get_message_widget(self.loop) message_widget.set_text('42') self.assertEqual('42', self.app.get_message()) def test_default_unhandled_input(self): # The default unhandled_input function is configured so that a # quickstart.cli.ui.AppExit exception is raised. The exception's # return value is None by default. return_value = self.get_on_exit_return_value(self.loop) self.assertIsNone(return_value) def test_set_return_value_on_exit(self): # It is possible to change the value returned by the AppExit exception # when the user quits the application using the exit shortcut. self.app.set_return_value_on_exit(42) return_value = self.get_on_exit_return_value(self.loop) self.assertEqual(42, return_value) # The value can be changed multiple times. self.app.set_return_value_on_exit([47, None]) return_value = self.get_on_exit_return_value(self.loop) self.assertEqual([47, None], return_value) juju-quickstart-1.3.1/quickstart/tests/cli/test_ui.py0000644000175000017500000002372412261546760024421 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart Urwid related utility objects.""" from __future__ import unicode_literals import unittest import mock import urwid from quickstart.cli import ( base, ui, ) from quickstart.tests.cli import helpers as cli_helpers class TestAppExit(unittest.TestCase): def test_no_return_value(self): # The exception accepts a return value argument. exception = ui.AppExit(42) self.assertEqual(42, exception.return_value) def test_return_value(self): # The exception's return value defaults to None. exception = ui.AppExit() self.assertIsNone(exception.return_value) def test_string_representation(self): # The exception is correctly represented as a byte string. exception = ui.AppExit(42) str_exception = str(exception) self.assertIsInstance(str_exception, bytes) self.assertEqual(b'AppExit: 42', str_exception) class TestExitAndReturn(unittest.TestCase): def test_app_exit(self): # The function returned raises an AppExit error if CTRL-x is passed. function = ui.exit_and_return(42) with self.assertRaises(ui.AppExit) as context_manager: function(ui.EXIT_KEY) self.assertEqual(42, context_manager.exception.return_value) def test_unhandled(self): # Passing other keys, the resulting function is a no-op. function = ui.exit_and_return(42) self.assertIsNone(function('alt z')) class TestCreateControls(unittest.TestCase): def test_resulting_pile(self): # The resulting pile is properly structured: it includes a columns # widget containing the provided widgets. widget0 = urwid.Text('w0') widget1 = urwid.Text('w1') pile = ui.create_controls(widget0, widget1) divider_contents, columns_contents = pile.contents self.assertIsInstance(divider_contents[0], urwid.Divider) columns = columns_contents[0].original_widget widgets = [content[0].base_widget for content in columns.contents] self.assertIs(widget0, widgets[0]) self.assertIs(widget1, widgets[1]) class TestMenuButton(unittest.TestCase): def test_caption(self): # The button's caption is properly set up. button = ui.MenuButton('my caption', mock.Mock()) self.assertEqual('my caption', button._w.base_widget.text) def test_signals(self): # The given callback is called when the click signal is emitted. callback = mock.Mock() button = ui.MenuButton('my caption', callback) urwid.emit_signal(button, 'click', button) callback.assert_called_once_with(button) class TestShowDialog(cli_helpers.CliAppTestsMixin, unittest.TestCase): def setUp(self): # Set up the base Urwid application. _, self.app = base.setup_urwid_app() self.original_contents = self.app.get_contents() def test_dialog_rendering(self): # The dialog is correctly displayed without additional controls. ui.show_dialog(self.app, 'my title', 'my message') contents = self.app.get_contents() self.assertIsNot(contents, self.original_contents) title_widget, message_widget, buttons = cli_helpers.inspect_dialog( contents) self.assertEqual('my title', title_widget.text) self.assertEqual('my message', message_widget.text) # The dialog only includes the "cancel" button. self.assertEqual(1, len(buttons)) caption = cli_helpers.get_button_caption(buttons[0]) self.assertEqual('cancel', caption) def test_dialog_cancel_action(self): # A dialog can be properly dismissed clicking the cancel button. ui.show_dialog(self.app, 'my title', 'my message') contents = self.app.get_contents() buttons = cli_helpers.inspect_dialog(contents)[2] cancel_button = buttons[0] cli_helpers.emit(cancel_button) # The original contents have been restored. self.assertIs(self.original_contents, self.app.get_contents()) def test_dialog_customized_actions(self): # A button is displayed for each customized action provided. performed = [] actions = [ ('push me', ui.thunk(performed.append, 'push')), ('pull me', ui.thunk(performed.append, 'pull')), ] ui.show_dialog(self.app, 'my title', 'my message', actions=actions) contents = self.app.get_contents() buttons = cli_helpers.inspect_dialog(contents)[2] # Three buttons are displayed: "cancel", "push me" and "pull me". self.assertEqual(3, len(buttons)) captions = [ cli_helpers.get_button_caption(button) for button in buttons] self.assertEqual(['cancel', 'push me', 'pull me'], captions) push, pull = buttons[1:] # Click the "push me" button. cli_helpers.emit(push) self.assertEqual(['push'], performed) # Click the "pull me" button two times. cli_helpers.emit(pull) cli_helpers.emit(pull) self.assertEqual(['push', 'pull', 'pull'], performed) def test_not_dismissable(self): # The cancel button is not present if the dialog is not dismissable. actions = [('push me', lambda *args: None)] ui.show_dialog( self.app, 'my title', 'my message', actions=actions, dismissable=False) contents = self.app.get_contents() buttons = cli_helpers.inspect_dialog(contents)[2] # Only the "push me" button is displayed. self.assertEqual(1, len(buttons)) caption = cli_helpers.get_button_caption(buttons[0]) self.assertEqual('push me', caption) class TestTabNavigationListBox(unittest.TestCase): def setUp(self): # Set up a TabNavigationListBox. self.widgets = [urwid.Edit(), urwid.Edit()] self.listbox = ui.TabNavigationListBox( urwid.SimpleFocusListWalker(self.widgets)) def test_widgets(self): # The listbox includes the provided widgets. self.assertEqual(self.widgets, list(self.listbox.body)) def test_tab_navigation(self): # The next widget is selected when tab is pressed. self.assertEqual(0, self.listbox.focus_position) cli_helpers.keypress(self.listbox, 'tab') self.assertEqual(1, self.listbox.focus_position) def test_shift_tab_navigation(self): # The previous widget is selected when shift+tab is pressed. self.listbox.set_focus(1) cli_helpers.keypress(self.listbox, 'shift tab') self.assertEqual(0, self.listbox.focus_position) class TestThunk(unittest.TestCase): widget = 'test-widget' def test_no_args(self): # A callback can be set up without arguments. function = mock.Mock() thunk_function = ui.thunk(function) thunk_function(self.widget) function.assert_called_once_with() def test_args(self): # It is possible to bind arguments to the callback function. function = mock.Mock() thunk_function = ui.thunk(function, 'arg1', 'arg2') thunk_function(self.widget) function.assert_called_once_with('arg1', 'arg2') def test_return_value(self): # The closure returns the value returned by the original callback. sqr = lambda value: value * value thunk_function = ui.thunk(sqr, 3) self.assertEqual(9, thunk_function(self.widget)) class TestTimeoutText(unittest.TestCase): def setUp(self): # Set up a timeout text widget. self.original_widget = urwid.Text('original contents') self.loop = base._MainLoop(None) self.wrapper = ui.TimeoutText( self.original_widget, 42, self.loop.set_alarm_in, self.loop.remove_alarm) def test_attributes(self): # The original widget and the timeout seconds are accessible from the # wrapper. self.assertEqual(self.original_widget, self.wrapper.original_widget) self.assertEqual(42, self.wrapper.seconds) def test_original_attributes(self): # The original widget attributes can be accessed from the wrapper. self.assertEqual('original contents', self.wrapper.text) self.assertEqual(('original contents', []), self.wrapper.get_text()) def test_set_timeout(self): # When setting text on a timeout text widget, am alarm is set up. The # alarm clears the text after the given number of seconds. self.wrapper.set_text('this will disappear') self.assertEqual('this will disappear', self.wrapper.text) alarms = self.loop.get_alarms() self.assertEqual(1, len(alarms)) # Calling the callback makes the message go away. _, callback = alarms[0] callback() self.assertEqual('', self.wrapper.text) def test_update_timeout(self): # The alarm is updated when setting text multiple time. self.wrapper.set_text('this will disappear') timeout, _ = self.loop.get_alarms()[0] self.wrapper.set_text('and this too') alarms = self.loop.get_alarms() self.assertEqual(1, len(alarms)) new_timeout, _ = alarms[0] # The new timeout is more far away in the future. self.assertGreater(new_timeout, timeout) juju-quickstart-1.3.1/quickstart/tests/cli/__init__.py0000644000175000017500000000145212254355165024475 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . juju-quickstart-1.3.1/quickstart/tests/cli/helpers.py0000644000175000017500000000530412261546760024401 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Test helpers for the Juju Quickstart CLI infrastructure.""" from __future__ import unicode_literals import urwid from quickstart.cli import ui class CliAppTestsMixin(object): """Helper methods to test Quickstart CLI applications.""" def get_on_exit_return_value(self, loop): """Return the value returned by the application when the user quits.""" with self.assertRaises(ui.AppExit) as context_manager: loop.unhandled_input(ui.EXIT_KEY) return context_manager.exception.return_value def get_button_caption(button): """Return the button caption as a string.""" return button._w.original_widget.text def emit(widget): """Emit the first signal associated withe the given widget. This is usually invoked to click buttons. """ # Retrieve the first signal name (usually is 'click'). signal_name = widget.signals[0] urwid.emit_signal(widget, signal_name, widget) def inspect_dialog(contents): """Inspect the widgets composing the dialog. Return a tuple (title_widget, messaqe_widget, buttons) where: - title_widget is the Text widget displaying the dialog title; - message_widget is the Text widget displaying the dialog message; - buttons is a list of MenuButton widgets included in the dialog. """ listbox = contents.top_w.base_widget header, _, message_widget, controls = listbox.body # The title widget is the second one in the header Pile. title_widget = header.base_widget.contents[1][0] # The button columns is the second widget in the Pile. columns = controls.contents[1][0].base_widget buttons = [content[0].base_widget for content in columns.contents] return title_widget, message_widget, buttons def keypress(widget, key): """Simulate a key press on the given widget.""" # Pass to the keypress method an arbitrary size. size = (42, 42) widget.keypress(size, key) juju-quickstart-1.3.1/quickstart/tests/test_manage.py0000644000175000017500000011335112320545241024446 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013-2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart management infrastructure.""" from __future__ import unicode_literals import argparse from contextlib import contextmanager import logging import os import shutil import StringIO as io import tempfile import unittest import mock import yaml import quickstart from quickstart import ( manage, settings, ) from quickstart.cli import views from quickstart.models import envs from quickstart.tests import helpers from quickstart import app class TestDescriptionAction(unittest.TestCase): def setUp(self): self.parser = argparse.ArgumentParser() self.parser.add_argument( '--test', action=manage._DescriptionAction, nargs=0) @mock.patch('sys.exit') @helpers.mock_print def test_action(self, mock_print, mock_exit): # The action just prints the description and exits. args = self.parser.parse_args(['--test']) self.assertIsNone(args.test) mock_print.assert_called_once_with(settings.DESCRIPTION) mock_exit.assert_called_once_with(0) class TestGetPackagingInfo(unittest.TestCase): distro_only_disable = '(enabled by default, use --ppa to disable)' ppa_disable = '(enabled by default, use --distro-only to disable)' def test_ppa_source(self): # The returned distro_only flag is set to False and the help texts are # formatted accordingly when the passed Juju source is "ppa". distro_only, distro_only_help, ppa_help = manage._get_packaging_info( 'ppa') self.assertFalse(distro_only) self.assertNotIn(self.distro_only_disable, distro_only_help) self.assertIn(self.ppa_disable, ppa_help) def test_distro_source(self): # The returned distro_only flag is set to True and the help texts are # formatted accordingly when the passed Juju source is "distro". distro_only, distro_only_help, ppa_help = manage._get_packaging_info( 'distro') self.assertTrue(distro_only) self.assertIn(self.distro_only_disable, distro_only_help) self.assertNotIn(self.ppa_disable, ppa_help) class TestValidateBundle( helpers.BundleFileTestsMixin, helpers.UrlReadTestsMixin, unittest.TestCase): def setUp(self): self.parser = mock.Mock() def make_options(self, bundle, bundle_name=None): """Return a mock options object which includes the passed arguments.""" return mock.Mock(bundle=bundle, bundle_name=bundle_name) def test_resulting_options_from_file(self): # The options object is correctly set up when a bundle file is passed. bundle_file = self.make_bundle_file() options = self.make_options(bundle_file, bundle_name='bundle1') manage._validate_bundle(options, self.parser) self.assertEqual('bundle1', options.bundle_name) self.assertEqual( ['mysql', 'wordpress'], sorted(options.bundle_services)) self.assertEqual(open(bundle_file).read(), options.bundle_yaml) def test_resulting_options_from_url(self): # The options object is correctly set up when a bundle HTTP(S) URL is # passed. bundle_file = self.make_bundle_file() url = 'http://example.com/bundle.yaml' options = self.make_options(url, bundle_name='bundle1') with self.patch_urlread(contents=self.valid_bundle) as mock_urlread: manage._validate_bundle(options, self.parser) mock_urlread.assert_called_once_with(url) self.assertEqual('bundle1', options.bundle_name) self.assertEqual( ['mysql', 'wordpress'], sorted(options.bundle_services)) self.assertEqual(open(bundle_file).read(), options.bundle_yaml) def test_resulting_options_from_bundle_url(self): # The options object is correctly set up when a "bundle:" URL is # passed. bundle_file = self.make_bundle_file() url = 'bundle:~who/my/bundle' options = self.make_options(url, bundle_name='bundle1') with self.patch_urlread(contents=self.valid_bundle) as mock_urlread: manage._validate_bundle(options, self.parser) mock_urlread.assert_called_once_with( 'https://manage.jujucharms.com/bundle/~who/my/bundle/json') self.assertEqual('bundle1', options.bundle_name) self.assertEqual( ['mysql', 'wordpress'], sorted(options.bundle_services)) self.assertEqual(open(bundle_file).read(), options.bundle_yaml) def test_resulting_options_from_jujucharms_url(self): # The options object is correctly set up when a jujucharms bundle URL # is passed. bundle_file = self.make_bundle_file() url = settings.JUJUCHARMS_BUNDLE_URL + 'my/bundle/' options = self.make_options(url, bundle_name='bundle1') with self.patch_urlread(contents=self.valid_bundle) as mock_urlread: manage._validate_bundle(options, self.parser) mock_urlread.assert_called_once_with( 'https://manage.jujucharms.com/bundle/~charmers/my/bundle/json') self.assertEqual('bundle1', options.bundle_name) self.assertEqual( ['mysql', 'wordpress'], sorted(options.bundle_services)) self.assertEqual(open(bundle_file).read(), options.bundle_yaml) def test_resulting_options_from_dir(self): # The options object is correctly set up when a bundle dir is passed. bundle_dir = self.make_bundle_dir() options = self.make_options(bundle_dir, bundle_name='bundle1') manage._validate_bundle(options, self.parser) self.assertEqual('bundle1', options.bundle_name) self.assertEqual( ['mysql', 'wordpress'], sorted(options.bundle_services)) expected = open(os.path.join(bundle_dir, 'bundles.yaml')).read() self.assertEqual(expected, options.bundle_yaml) def test_expand_user(self): # The ~ construct is correctly expanded in the validation process. bundle_file = self.make_bundle_file() # Split the full path of the bundle file, e.g. from a full # "/tmp/bundle.file" path retrieve the base path "/tmp" and the file # name "bundle.file". By doing that we can simulate that the user's # home is "/tmp" and that the bundle file is "~/bundle.file". base_path, filename = os.path.split(bundle_file) path = '~/{}'.format(filename) options = self.make_options(bundle=path, bundle_name='bundle2') with mock.patch('os.environ', {'HOME': base_path}): manage._validate_bundle(options, self.parser) self.assertEqual(self.valid_bundle, options.bundle_yaml) def test_bundle_file_not_found(self): # A parser error is invoked if the bundle file is not found. options = self.make_options('/no/such/file.yaml') manage._validate_bundle(options, self.parser) expected = ( 'unable to open bundle file: ' "[Errno 2] No such file or directory: '/no/such/file.yaml'" ) self.parser.error.assert_called_once_with(expected) def test_bundle_dir_not_valid(self): # A parser error is invoked if the bundle dir does not contain the # bundles.yaml file. bundle_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, bundle_dir) options = self.make_options(bundle_dir) manage._validate_bundle(options, self.parser) expected = ( 'unable to open bundle file: ' "[Errno 2] No such file or directory: '{}/bundles.yaml'" ).format(bundle_dir) self.parser.error.assert_called_once_with(expected) def test_url_error(self): # A parser error is invoked if the bundle cannot be fetched from the # provided URL. url = 'http://example.com/bundle.yaml' options = self.make_options(url) with self.patch_urlread(error=True) as mock_urlread: manage._validate_bundle(options, self.parser) mock_urlread.assert_called_once_with(url) self.parser.error.assert_called_once_with( 'unable to open bundle URL: bad wolf') def test_bundle_url_error(self): # A parser error is invoked if an invalid "bundle:" URL is provided. url = 'bundle:' options = self.make_options(url) manage._validate_bundle(options, self.parser) self.parser.error.assert_called_once_with( 'unable to open the bundle: invalid bundle URL: bundle:') def test_jujucharms_url_error(self): # A parser error is invoked if an invalid jujucharms URL is provided. url = settings.JUJUCHARMS_BUNDLE_URL + 'no-such' options = self.make_options(url) manage._validate_bundle(options, self.parser) self.parser.error.assert_called_once_with( 'unable to open the bundle: invalid bundle URL: {}'.format(url)) def test_error_parsing_bundle_contents(self): # A parser error is invoked if an error occurs parsing the bundle YAML. bundle_file = self.make_bundle_file() options = self.make_options(bundle_file, bundle_name='no-such') manage._validate_bundle(options, self.parser) expected = ('bundle no-such not found in the provided list of bundles ' '(bundle1, bundle2)') self.parser.error.assert_called_once_with(expected) class TestValidateCharmUrl(unittest.TestCase): def setUp(self): self.parser = mock.Mock() def make_options(self, charm_url, has_bundle=False): """Return a mock options object which includes the passed arguments.""" options = mock.Mock(charm_url=charm_url, bundle=None) if has_bundle: options.bundle = 'bundle:~who/django/42/django' return options def test_invalid_url_error(self): # A parser error is invoked if the charm URL is not valid. options = self.make_options('cs:invalid') manage._validate_charm_url(options, self.parser) expected = 'charm URL has invalid form: cs:invalid' self.parser.error.assert_called_once_with(expected) def test_local_charm_error(self): # A parser error is invoked if a local charm is provided. options = self.make_options('local:precise/juju-gui-100') manage._validate_charm_url(options, self.parser) expected = 'local charms are not allowed: local:precise/juju-gui-100' self.parser.error.assert_called_once_with(expected) def test_unsupported_series_error(self): # A parser error is invoked if the charm series is not supported. options = self.make_options('cs:nosuch/juju-gui-100') manage._validate_charm_url(options, self.parser) expected = 'unsupported charm series: nosuch' self.parser.error.assert_called_once_with(expected) def test_outdated_charm_error(self): # A parser error is invoked if a bundle deployment has been requested # but the provided charm does not support bundles. options = self.make_options('cs:precise/juju-gui-1', has_bundle=True) manage._validate_charm_url(options, self.parser) expected = ( 'bundle deployments not supported by the requested charm ' 'revision: cs:precise/juju-gui-1') self.parser.error.assert_called_once_with(expected) def test_outdated_allowed_without_bundles(self): # An outdated charm is allowed if no bundles are provided. options = self.make_options('cs:precise/juju-gui-1', has_bundle=False) manage._validate_charm_url(options, self.parser) self.assertFalse(self.parser.error.called) def test_success(self): # The functions completes without error if the charm URL is valid. good = ( 'cs:precise/juju-gui-100', 'cs:~juju-gui/precise/juju-gui-42', 'cs:~who/precise/juju-gui-42', 'cs:~who/precise/my-juju-gui-42', ) for charm_url in good: options = self.make_options(charm_url) manage._validate_charm_url(options, self.parser) self.assertFalse(self.parser.error.called, charm_url) class TestRetrieveEnvDb(helpers.EnvFileTestsMixin, unittest.TestCase): def setUp(self): self.parser = mock.Mock() def test_existing_env_file(self): # The env_db is correctly retrieved from an existing environments file. env_file = self.make_env_file() env_db = manage._retrieve_env_db(self.parser, env_file=env_file) self.assertEqual(yaml.safe_load(self.valid_contents), env_db) def test_error_parsing_env_file(self): # A parser error is invoked if an error occurs parsing the env file. env_file = self.make_env_file('so-bad') manage._retrieve_env_db(self.parser, env_file=env_file) self.parser.error.assert_called_once_with( 'invalid YAML contents in {}: so-bad'.format(env_file)) def test_missing_env_file(self): # An empty env_db is returned if the environments file does not exist. env_db = manage._retrieve_env_db(self.parser, env_file=None) self.assertEqual(envs.create_empty_env_db(), env_db) @mock.patch('quickstart.manage.envs.save') class TestCreateSaveCallable(unittest.TestCase): def setUp(self): self.parser = mock.Mock() self.env_file = '/tmp/envfile.yaml' self.env_db = helpers.make_env_db() with mock.patch('quickstart.manage.utils.run_once') as mock_run_once: self.save_callable = manage._create_save_callable( self.parser, self.env_file) self.mock_run_once = mock_run_once def test_saved(self, mock_save): # The returned function correctly saves the new environments database. self.save_callable(self.env_db) mock_save.assert_called_once_with( self.env_file, self.env_db, backup_function=self.mock_run_once()) self.assertFalse(self.parser.error.called) def test_error(self, mock_save): # The returned function uses the parser to exit the program if an error # occurs while saving the new environments database. mock_save.side_effect = OSError(b'bad wolf') self.save_callable(self.env_db) mock_save.assert_called_once_with( self.env_file, self.env_db, backup_function=self.mock_run_once()) self.parser.error.assert_called_once_with('bad wolf') def test_backup_function(self, mock_save): # The backup function is correctly created. self.save_callable(self.env_db) self.mock_run_once.assert_called_once_with(shutil.copyfile) class TestStartInteractiveSession( helpers.EnvFileTestsMixin, unittest.TestCase): def setUp(self): # Set up a parser, the environments metadata, an environments file and # a testing env_db. self.parser = mock.Mock() self.env_type_db = envs.get_env_type_db() self.env_file = self.make_env_file() self.env_db = envs.load(self.env_file) @contextmanager def patch_interactive_mode(self, env_db, return_value): """Patch the quickstart.cli.views.show function. Ensure the interactive mode is started by the code in the context block passing the given env_db. Make the view return the given return_value. """ create_save_callable_path = 'quickstart.manage._create_save_callable' mock_show = mock.Mock(return_value=return_value) with mock.patch(create_save_callable_path) as mock_save_callable: with mock.patch('quickstart.manage.views.show', mock_show): yield mock_save_callable.assert_called_once_with(self.parser, self.env_file) mock_show.assert_called_once_with( views.env_index, self.env_type_db, env_db, mock_save_callable()) def test_resulting_env_data(self): # The interactive session can be used to select an environment, in # which case the function returns the corresponding env_data. env_data = envs.get_env_data(self.env_db, 'aws') with self.patch_interactive_mode(self.env_db, [self.env_db, env_data]): obtained_env_data = manage._start_interactive_session( self.parser, self.env_type_db, self.env_db, self.env_file) self.assertEqual(env_data, obtained_env_data) @helpers.mock_print def test_modified_environments(self, mock_print): # The function informs the user that environments have been modified # during the interactive session. env_data = envs.get_env_data(self.env_db, 'aws') new_env_db = helpers.make_env_db() with self.patch_interactive_mode(self.env_db, [new_env_db, env_data]): manage._start_interactive_session( self.parser, self.env_type_db, self.env_db, self.env_file) mock_print.assert_called_once_with( 'changes to the environments file have been saved') @mock.patch('sys.exit') def test_interactive_mode_quit(self, mock_exit): # If the user explicitly quits the interactive mode, the program exits # without proceeding with the environment bootstrapping. with self.patch_interactive_mode(self.env_db, [self.env_db, None]): manage._start_interactive_session( self.parser, self.env_type_db, self.env_db, self.env_file) mock_exit.assert_called_once_with('quitting') class TestRetrieveEnvData(unittest.TestCase): def setUp(self): # Set up a parser, the environments metadata and a testing env_db. self.parser = mock.Mock() self.env_type_db = envs.get_env_type_db() self.env_db = helpers.make_env_db() def test_resulting_env_data(self): # The env_data is correctly validated and returned. expected_env_data = envs.get_env_data(self.env_db, 'lxc') env_data = manage._retrieve_env_data( self.parser, self.env_type_db, self.env_db, 'lxc') self.assertEqual(expected_env_data, env_data) def test_error_environment_not_found(self): # A parser error is invoked if the provided environment is not included # in the environments database. manage._retrieve_env_data( self.parser, self.env_type_db, self.env_db, 'no-such') self.parser.error.assert_called_once_with( 'environment no-such not found') def test_error_environment_not_valid(self): # A parser error is invoked if the selected environment is not valid. manage._retrieve_env_data( self.parser, self.env_type_db, self.env_db, 'local-with-errors') self.parser.error.assert_called_once_with( 'cannot use the local-with-errors environment:\n' 'the storage port field requires an integer value') class TestSetupEnv(helpers.EnvFileTestsMixin, unittest.TestCase): def setUp(self): self.parser = mock.Mock() def make_options(self, env_file, env_name=None, interactive=False): """Return a mock options object which includes the passed arguments.""" return mock.Mock( env_file=env_file, env_name=env_name, interactive=interactive, ) def patch_interactive_mode(self, return_value): """Patch the quickstart.manage._start_interactive_session function. Make the mocked function return the given return_value. """ mock_start_interactive_session = mock.Mock(return_value=return_value) return mock.patch( 'quickstart.manage._start_interactive_session', mock_start_interactive_session) def test_resulting_options(self): # The options object is correctly set up. env_file = self.make_env_file() options = self.make_options( env_file, env_name='aws', interactive=False) manage._setup_env(options, self.parser) self.assertEqual('Secret!', options.admin_secret) self.assertEqual(env_file, options.env_file) self.assertEqual('aws', options.env_name) self.assertEqual('ec2', options.env_type) self.assertEqual('saucy', options.default_series) self.assertFalse(options.interactive) def test_expand_user(self): # The ~ construct is correctly expanded in the validation process. env_file = self.make_env_file() # Split the full path of the env file, e.g. from a full "/tmp/env.file" # path retrieve the base path "/tmp" and the file name "env.file". # By doing that we can simulate that the user's home is "/tmp" and that # the env file is "~/env.file". base_path, filename = os.path.split(env_file) path = '~/{}'.format(filename) options = self.make_options(env_file=path, env_name='aws') with mock.patch('os.environ', {'HOME': base_path}): manage._setup_env(options, self.parser) self.assertEqual(env_file, options.env_file) def test_no_env_name(self): # A parser error is invoked if the environment name is missing and # interactive mode is disabled. options = self.make_options(self.make_env_file(), interactive=False) manage._setup_env(options, self.parser) self.assertTrue(self.parser.error.called) message = self.parser.error.call_args[0][0] self.assertIn('unable to find an environment name to use', message) def test_local_provider(self): # Local environments are correctly handled. contents = yaml.safe_dump({ 'environments': { 'lxc': {'admin-secret': 'Secret!', 'type': 'local'}, }, }) env_file = self.make_env_file(contents) options = self.make_options( env_file, env_name='lxc', interactive=False) manage._setup_env(options, self.parser) self.assertEqual('Secret!', options.admin_secret) self.assertEqual(env_file, options.env_file) self.assertEqual('lxc', options.env_name) self.assertEqual('local', options.env_type) self.assertIsNone(options.default_series) self.assertFalse(options.interactive) def test_interactive_mode(self): # The interactive mode is started properly if the corresponding option # flag is set. env_file = self.make_env_file() options = self.make_options(env_file, interactive=True) # Simulate the user did not make any changes to the env_db from the # interactive session. env_db = yaml.load(self.valid_contents) # Simulate the aws environment has been selected and started from the # interactive session. env_data = envs.get_env_data(env_db, 'aws') get_env_type_db_path = 'quickstart.models.envs.get_env_type_db' with mock.patch(get_env_type_db_path) as mock_get_env_type_db: with self.patch_interactive_mode(env_data) as mock_interactive: manage._setup_env(options, self.parser) mock_interactive.assert_called_once_with( self.parser, mock_get_env_type_db(), env_db, env_file) # The options is updated with data from the selected environment. self.assertEqual('Secret!', options.admin_secret) self.assertEqual(env_file, options.env_file) self.assertEqual('aws', options.env_name) self.assertEqual('ec2', options.env_type) self.assertEqual('saucy', options.default_series) self.assertTrue(options.interactive) @helpers.mock_print def test_missing_env_file(self, mock_print): # If the environments file does not exist, an empty env_db is created # in memory and interactive mode is forced. new_env_db = helpers.make_env_db() env_data = envs.get_env_data(new_env_db, 'lxc') options = self.make_options('__no_such_env_file__', interactive=False) # In this case, we expect the interactive mode to be started and the # env_db passed to the view to be an empty one. with self.patch_interactive_mode(env_data) as mock_interactive: manage._setup_env(options, self.parser) self.assertTrue(mock_interactive.called) self.assertTrue(options.interactive) class TestConvertOptionsToUnicode(unittest.TestCase): def test_bytes_options(self): # Byte strings are correctly converted. options = argparse.Namespace(opt1=b'val1', opt2=b'val2') manage._convert_options_to_unicode(options) self.assertEqual('val1', options.opt1) self.assertIsInstance(options.opt1, unicode) self.assertEqual('val2', options.opt2) self.assertIsInstance(options.opt2, unicode) def test_unicode_options(self): # Unicode options are left untouched. options = argparse.Namespace(myopt='myval') self.assertEqual('myval', options.myopt) self.assertIsInstance(options.myopt, unicode) def test_other_types(self): # Other non-string types are left untouched. options = argparse.Namespace(opt1=42, opt2=None) self.assertEqual(42, options.opt1) self.assertIsNone(options.opt2) @mock.patch('quickstart.manage._setup_env', mock.Mock()) class TestSetup(unittest.TestCase): def patch_get_default_env_name(self, env_name=None): """Patch the function used by setup() to retrieve the default env name. This way the test does not rely on the user's Juju environment set up, and it is also possible to simulate an arbitrary environment name. """ mock_get_default_env_name = mock.Mock(return_value=env_name) path = 'quickstart.manage.envs.get_default_env_name' return mock.patch(path, mock_get_default_env_name) def call_setup(self, args, env_name='ec2', exit_called=True): """Call the setup function simulating the given args and env name. Also ensure the program exits without errors if exit_called is True. """ with mock.patch('sys.argv', ['juju-quickstart'] + args): with mock.patch('sys.exit') as mock_exit: with self.patch_get_default_env_name(env_name): manage.setup() if exit_called: mock_exit.assert_called_once_with(0) def test_help(self): # The program help message is properly formatted. with mock.patch('sys.stdout') as mock_stdout: self.call_setup(['--help']) stdout_write = mock_stdout.write self.assertTrue(stdout_write.called) # Retrieve the output from the mock call. output = stdout_write.call_args[0][0] self.assertIn('usage: juju-quickstart', output) # NB: some shells break the docstring at different places when --help # is called so the replacements below make it agnostic. self.assertIn(quickstart.__doc__.replace('\n', ' '), output.replace('\n', ' ')) self.assertIn('--environment', output) # Without a default environment, the -e option has no default. self.assertIn('The name of the Juju environment to use (ec2)\n', output) def test_help_with_default_environment(self): # The program help message is properly formatted when a default Juju # environment is found. with mock.patch('sys.stdout') as mock_stdout: self.call_setup(['--help'], env_name='hp') stdout_write = mock_stdout.write self.assertTrue(stdout_write.called) # Retrieve the output from the mock call. output = stdout_write.call_args[0][0] self.assertIn('The name of the Juju environment to use (hp)\n', output) def test_description(self): # The program description is properly printed out as required by juju. with helpers.mock_print as mock_print: self.call_setup(['--description']) mock_print.assert_called_once_with(settings.DESCRIPTION) def test_version(self): # The program version is properly printed to stderr. with mock.patch('sys.stderr', new_callable=io.StringIO) as mock_stderr: self.call_setup(['--version']) expected = 'juju-quickstart {}\n'.format(quickstart.get_version()) self.assertEqual(expected, mock_stderr.getvalue()) @mock.patch('quickstart.manage._validate_bundle') def test_bundle(self, mock_validate_bundle): # The bundle validation process is started if a bundle is provided. self.call_setup(['/path/to/bundle.file'], exit_called=False) self.assertTrue(mock_validate_bundle.called) options, parser = mock_validate_bundle.call_args_list[0][0] self.assertIsInstance(options, argparse.Namespace) self.assertIsInstance(parser, argparse.ArgumentParser) @mock.patch('quickstart.manage._validate_charm_url') def test_charm_url(self, mock_validate_charm_url): # The charm URL validation process is started if a URL is provided. self.call_setup( ['--gui-charm-url', 'cs:precise/juju-gui-42'], exit_called=False) self.assertTrue(mock_validate_charm_url.called) options, parser = mock_validate_charm_url.call_args_list[0][0] self.assertIsInstance(options, argparse.Namespace) self.assertIsInstance(parser, argparse.ArgumentParser) def test_configure_logging(self): # Logging is properly set up at the info level. logger = logging.getLogger() self.call_setup([], 'ec2', exit_called=False) self.assertEqual(logging.INFO, logger.level) def test_configure_logging_debug(self): # Logging is properly set up at the debug level. logger = logging.getLogger() self.call_setup(['--debug'], 'ec2', exit_called=False) self.assertEqual(logging.DEBUG, logger.level) @mock.patch('webbrowser.open') @mock.patch('quickstart.manage.app') @mock.patch('__builtin__.print', mock.Mock()) class TestRun(unittest.TestCase): def make_options(self, **kwargs): """Set up the options to be passed to the run function.""" options = { 'admin_secret': 'Secret!', 'bundle': None, 'bundle_id': None, 'charm_url': None, 'debug': False, 'env_name': 'aws', 'env_type': 'ec2', 'open_browser': True, 'default_series': None, } options.update(kwargs) return mock.Mock(**options) @staticmethod def mock_get_admin_secret_success(name, home): return 'jenv secret' @staticmethod def mock_get_admin_secret_error(name, home): fn = '{}.jenv'.format(name) path = os.path.join(home, 'environments', fn) msg = 'admin-secret not found in {}'.format(path) raise ValueError(msg.encode('utf-8')) def test_no_bundle(self, mock_app, mock_open): # The application runs correctly if no bundle is provided. mock_app.ensure_dependencies.return_value = (1, 18, 0) mock_app.bootstrap.return_value = (True, 'precise') mock_app.get_admin_secret = self.mock_get_admin_secret_error mock_app.watch.return_value = '1.2.3.4' mock_app.create_auth_token.return_value = 'AUTHTOKEN' options = self.make_options() manage.run(options) mock_app.ensure_dependencies.assert_called() mock_app.ensure_ssh_keys.assert_called() mock_app.bootstrap.assert_called_once_with( options.env_name, requires_sudo=False, debug=options.debug) mock_app.get_api_url.assert_called_once_with(options.env_name) mock_app.connect.assert_has_calls([ mock.call(mock_app.get_api_url(), options.admin_secret), mock.call().close(), mock.call('wss://1.2.3.4:443/ws', options.admin_secret), mock.call().close(), ]) mock_app.deploy_gui.assert_called_once_with( mock_app.connect(), settings.JUJU_GUI_SERVICE_NAME, '0', charm_url=options.charm_url, check_preexisting=mock_app.bootstrap()[0]) mock_app.watch.assert_called_once_with( mock_app.connect(), mock_app.deploy_gui()) mock_app.create_auth_token.assert_called_once_with(mock_app.connect()) mock_open.assert_called_once_with( 'https://{}/?authtoken={}'.format(mock_app.watch(), 'AUTHTOKEN')) self.assertFalse(mock_app.deploy_bundle.called) def test_no_token(self, mock_app, mock_open): mock_app.create_auth_token.return_value = None mock_app.bootstrap.return_value = (True, 'precise') options = self.make_options() manage.run(options) mock_app.create_auth_token.assert_called_once_with(mock_app.connect()) mock_open.assert_called_once_with( 'https://{}'.format(mock_app.watch())) def test_bundle(self, mock_app, mock_open): # A bundle is correctly deployed by the application. options = self.make_options( bundle='/my/bundle/file.yaml', bundle_yaml='mybundle: contents', bundle_name='mybundle', bundle_services=['service1', 'service2']) mock_app.bootstrap.return_value = (True, 'precise') mock_app.watch.return_value = 'gui.example.com' manage.run(options) mock_app.deploy_bundle.assert_called_once_with( mock_app.connect(), 'mybundle: contents', 'mybundle', None) def test_local_provider_no_sudo(self, mock_app, mock_open): # The application correctly handles working with local providers with # new Juju versions not requiring "sudo" to bootstrap the environment. # Sudo privileges are not required if the Juju version is >= 1.17.2. options = self.make_options(env_type='local') versions = [ (1, 17, 2), (1, 17, 10), (1, 18, 0), (1, 18, 2), (2, 16, 1)] mock_app.bootstrap.return_value = (True, 'precise') for version in versions: mock_app.ensure_dependencies.return_value = version manage.run(options) mock_app.bootstrap.assert_called_once_with( options.env_name, requires_sudo=False, debug=options.debug) mock_app.bootstrap.reset_mock() def test_local_provider_requiring_sudo(self, mock_app, mock_open): # The application correctly handles working with local providers when # Juju requires an external "sudo" call to bootstrap the environment. # Sudo privileges are required if the Juju version is < 1.17.2. options = self.make_options(env_type='local') versions = [(0, 7, 9), (1, 0, 0), (1, 16, 42), (1, 17, 0), (1, 17, 1)] mock_app.bootstrap.return_value = (True, 'precise') for version in versions: mock_app.ensure_dependencies.return_value = version manage.run(options) mock_app.bootstrap.assert_called_once_with( options.env_name, requires_sudo=True, debug=options.debug) mock_app.bootstrap.reset_mock() def test_no_local_no_sudo(self, mock_app, mock_open): # Sudo privileges are never required for non-local environments. options = self.make_options(env_type='ec2') mock_app.ensure_dependencies.return_value = (1, 14, 0) mock_app.bootstrap.return_value = (True, 'precise') manage.run(options) mock_app.bootstrap.assert_called_once_with( options.env_name, requires_sudo=False, debug=options.debug) def test_no_browser(self, mock_app, mock_open): # It is possible to avoid opening the GUI in the browser. mock_app.bootstrap.return_value = (True, 'precise') options = self.make_options(open_browser=False) manage.run(options) self.assertFalse(mock_open.called) def test_admin_secret_fetched(self, mock_app, mock_open): # If an admin secret is fetched from jenv it is used, even if one is # found in environments.yaml, as set in options.admin_secret. mock_app.get_admin_secret = self.mock_get_admin_secret_success mock_app.bootstrap.return_value = (True, 'precise') options = self.make_options(admin_secret='secret in environments.yaml') manage.run(options) mock_app.connect.assert_has_calls([ mock.call(mock_app.get_api_url(), 'jenv secret'), ]) def test_admin_secret_from_environments_yaml(self, mock_app, mock_open): # If an admin secret is not fetched from jenv, then the one from # environments.yaml is used, as found in options.admin_secret. mock_app.get_admin_secret = self.mock_get_admin_secret_error mock_app.bootstrap.return_value = (True, 'precise') options = self.make_options(admin_secret='secret in environments.yaml') manage.run(options) mock_app.connect.assert_has_calls([ mock.call(mock_app.get_api_url(), 'secret in environments.yaml'), ]) def test_no_admin_secret_found(self, mock_app, mock_open): # If admin-secret cannot be found anywhere a ProgramExit is called. mock_app.ProgramExit = app.ProgramExit mock_app.get_admin_secret = self.mock_get_admin_secret_error mock_app.bootstrap.return_value = (True, 'precise') options = self.make_options( admin_secret=None, env_name='local', env_file='environments.yaml') with self.assertRaises(app.ProgramExit) as context: manage.run(options) expected = ( u'admin-secret not found in ~/.juju/environments/local.jenv ' 'or environments.yaml') self.assertEqual(expected, context.exception.message) juju-quickstart-1.3.1/quickstart/tests/test_serializers.py0000644000175000017500000000411312251372515025552 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart serialization helpers.""" from __future__ import unicode_literals import unittest import mock import yaml from quickstart import serializers class TestYamlLoad(unittest.TestCase): contents = '{myint: 42, mystring: foo}' def test_unicode_strings(self): # Strings are returned as unicode objects. decoded = serializers.yaml_load(self.contents) self.assertEqual(42, decoded['myint']) self.assertEqual('foo', decoded['mystring']) self.assertIsInstance(decoded['mystring'], unicode) for key in decoded: self.assertIsInstance(key, unicode, key) @mock.patch('quickstart.serializers.yaml.load') def test_safe(self, mock_load): # The YAML decoder uses a safe loader. serializers.yaml_load(self.contents) self.assertEqual(self.contents, mock_load.call_args[0][0]) loader_class = mock_load.call_args[1]['Loader'] self.assertTrue(issubclass(loader_class, yaml.SafeLoader)) class TestYamlDump(unittest.TestCase): data = {'myint': 42, 'mystring': 'foo'} def test_block_style(self): # Collections are serialized in the block style. contents = serializers.yaml_dump(self.data) self.assertEqual('myint: 42\nmystring: foo\n', contents) juju-quickstart-1.3.1/quickstart/tests/test_utils.py0000644000175000017500000007750012311534714024366 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013-2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart utility functions and classes.""" from __future__ import unicode_literals import datetime import httplib import json import os import shutil import socket import tempfile import unittest import urllib2 import mock import yaml from quickstart import ( get_version, settings, utils, ) from quickstart.tests import helpers @helpers.mock_print class TestAddAptRepository(helpers.CallTestsMixin, unittest.TestCase): apt_add_repository = '/usr/bin/add-apt-repository' apt_get = '/usr/bin/apt-get' repository = 'ppa:good/stuff' side_effects = ( (0, 'apt-get install', ''), # Install add-apt-repository. (0, 'add-apt-repository', ''), # Add the repository. (0, 'update', ''), # Update the global repository ) def patch_codename(self, codename): """Patch the Ubuntu codename returned by get_ubuntu_codename.""" return mock.patch( 'quickstart.utils.get_ubuntu_codename', mock.Mock(return_value=codename)) def test_precise(self, mock_print): # The repository is properly added in precise. with self.patch_codename('precise') as mock_get_ubuntu_codename: with self.patch_multiple_calls(self.side_effects) as mock_call: utils.add_apt_repository(self.repository) mock_get_ubuntu_codename.assert_called_once_with() self.assertEqual(len(self.side_effects), mock_call.call_count) mock_call.assert_has_calls([ mock.call('sudo', self.apt_get, 'install', '-y', 'python-software-properties'), mock.call('sudo', self.apt_add_repository, '-y', self.repository), mock.call('sudo', self.apt_get, 'update'), ]) def test_after_precise(self, mock_print): # The repository is correctly added in newer releases. with self.patch_codename('trusty') as mock_get_ubuntu_codename: with self.patch_multiple_calls(self.side_effects) as mock_call: utils.add_apt_repository(self.repository) mock_get_ubuntu_codename.assert_called_once_with() self.assertEqual(len(self.side_effects), mock_call.call_count) mock_call.assert_has_calls([ mock.call('sudo', self.apt_get, 'install', '-y', 'software-properties-common'), mock.call('sudo', self.apt_add_repository, '-y', self.repository), mock.call('sudo', self.apt_get, 'update'), ]) def test_output(self, mock_print): # The user is properly informed about the process. with self.patch_codename('raring'): with self.patch_multiple_calls(self.side_effects): utils.add_apt_repository(self.repository) self.assertEqual(2, mock_print.call_count) mock_print.assert_has_calls([ mock.call('adding the {} PPA repository'.format(self.repository)), mock.call('sudo privileges will be required for PPA installation'), ]) def test_command_error(self, mock_print): # An OSError is raised if a command error occurs. side_effects = [(1, '', 'apt-get install error')] with self.patch_codename('quantal') as mock_get_ubuntu_codename: with self.patch_multiple_calls(side_effects) as mock_call: with self.assertRaises(OSError) as context_manager: utils.add_apt_repository(self.repository) mock_get_ubuntu_codename.assert_called_once_with() mock_call.assert_called_once_with( 'sudo', self.apt_get, 'install', '-y', 'software-properties-common') self.assertEqual( 'apt-get install error', bytes(context_manager.exception)) class TestCall(unittest.TestCase): def test_success(self): # A zero exit code and the subprocess output are correctly returned. retcode, output, error = utils.call('echo') self.assertEqual(0, retcode) self.assertEqual('\n', output) self.assertEqual('', error) def test_multiple_arguments(self): # A zero exit code and the subprocess output are correctly returned # when executing a command passing multiple arguments. retcode, output, error = utils.call('echo', 'we are the borg!') self.assertEqual(0, retcode) self.assertEqual('we are the borg!\n', output) self.assertEqual('', error) def test_failure(self): # An error code and the error are returned if the subprocess fails. retcode, output, error = utils.call('ls', 'no-such-file') self.assertNotEqual(0, retcode) self.assertEqual('', output) self.assertEqual( 'ls: cannot access no-such-file: No such file or directory\n', error) def test_invalid_command(self): # An error code and the error are returned if the subprocess fails to # find the provided command in the PATH. retcode, output, error = utils.call('no-such-command') self.assertEqual(127, retcode) self.assertEqual('', output) self.assertEqual( 'no-such-command: [Errno 2] No such file or directory', error) def test_logging(self): # The command line call and the results are properly logged. expected_messages = ( "running the following: echo 'we are the borg!'", r"retcode: 0 | output: 'we are the borg!\n' | error: ''", ) with helpers.assert_logs(expected_messages): utils.call('echo', 'we are the borg!') @mock.patch('__builtin__.print', mock.Mock()) class TestCheckGuiCharmUrl(unittest.TestCase): def test_customized(self): # A customized charm URL is properly logged. expected = 'using a customized juju-gui charm' with helpers.assert_logs([expected], level='warn'): utils.check_gui_charm_url('cs:~juju-gui/precise/juju-gui-28') def test_outdated(self): # An outdated charm URL is properly logged. expected = 'charm is outdated and may not support bundle deployments' with helpers.assert_logs([expected], level='warn'): utils.check_gui_charm_url('cs:precise/juju-gui-1') def test_unexpected(self): # An unexpected charm URL is properly logged. expected = ( 'unexpected URL for the juju-gui charm: the service may not work ' 'as expected') with helpers.assert_logs([expected], level='warn'): utils.check_gui_charm_url('cs:precise/another-gui-42') def test_official(self): # No warnings are logged if an up to date charm is passed. with mock.patch('logging.warn') as mock_warn: utils.check_gui_charm_url('cs:precise/juju-gui-100') self.assertFalse(mock_warn.called) class TestConvertBundleUrl(helpers.ValueErrorTestsMixin, unittest.TestCase): def test_full_bundle_url(self): # The HTTPS location to the YAML contents is correctly returned. bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki' url, bundle_id = utils.convert_bundle_url(bundle_url) self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~myuser/wiki-bundle/42/wiki/json', url) self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id) def test_bundle_url_right_strip(self): # The trailing slash in the bundle URL is removed. bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki/' url, bundle_id = utils.convert_bundle_url(bundle_url) self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~myuser/wiki-bundle/42/wiki/json', url) self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id) def test_bundle_url_no_revision(self): # The bundle revision is optional. bundle_url = 'bundle:~myuser/wiki-bundle/wiki-simple' url, bundle_id = utils.convert_bundle_url(bundle_url) self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~myuser/wiki-bundle/wiki-simple/json', url) self.assertEqual('~myuser/wiki-bundle/wiki-simple', bundle_id) def test_bundle_url_no_user(self): # If the bundle user is not specified, the bundle is assumed to be # promulgated and owned by "charmers". bundle_url = 'bundle:wiki-bundle/1/wiki' url, bundle_id = utils.convert_bundle_url(bundle_url) self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~charmers/wiki-bundle/1/wiki/json', url) self.assertEqual('~charmers/wiki-bundle/1/wiki', bundle_id) def test_bundle_url_short_form(self): # A promulgated bundle URL can just include the basket and the name. bundle_url = 'bundle:wiki-bundle/wiki' url, bundle_id = utils.convert_bundle_url(bundle_url) self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~charmers/wiki-bundle/wiki/json', url) self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id) def test_full_jujucharms_url(self): # The HTTPS location to the YAML contents is correctly returned. url, bundle_id = utils.convert_bundle_url( settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki-bundle/42/wiki') self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~myuser/wiki-bundle/42/wiki/json', url) self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id) def test_jujucharms_url_right_strip(self): # The trailing slash in the jujucharms URL is removed. url, bundle_id = utils.convert_bundle_url( settings.JUJUCHARMS_BUNDLE_URL + '~charmers/mediawiki/6/scalable/') self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~charmers/mediawiki/6/scalable/json', url) self.assertEqual('~charmers/mediawiki/6/scalable', bundle_id) def test_jujucharms_url_no_revision(self): # The bundle revision is optional. url, bundle_id = utils.convert_bundle_url( settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki/wiki-simple/') self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~myuser/wiki/wiki-simple/json', url) self.assertEqual('~myuser/wiki/wiki-simple', bundle_id) def test_jujucharms_url_no_user(self): # If the bundle user is not specified, the bundle is assumed to be # promulgated and owned by "charmers". url, bundle_id = utils.convert_bundle_url( settings.JUJUCHARMS_BUNDLE_URL + 'mediawiki/42/single/') self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~charmers/mediawiki/42/single/json', url) self.assertEqual('~charmers/mediawiki/42/single', bundle_id) def test_jujucharms_url_short_form(self): # A jujucharms URL for a promulgated bundle can just include the basket # and the name. url, bundle_id = utils.convert_bundle_url( settings.JUJUCHARMS_BUNDLE_URL + 'wiki-bundle/wiki/') self.assertEqual( 'https://manage.jujucharms.com' '/bundle/~charmers/wiki-bundle/wiki/json', url) self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id) def test_error(self): # A ValueError is raised if the bundle/jujucharms URL is not valid. bad_urls = ( 'bad', 'bundle:', 'bundle:~user', 'bundle:no-such', 'bundle:~user/name', 'bundle:~user/basket/revision/name', 'bundle:basket/name//', 'bundle:basket.name/bundle.name', settings.JUJUCHARMS_BUNDLE_URL, settings.JUJUCHARMS_BUNDLE_URL + 'bad', settings.JUJUCHARMS_BUNDLE_URL + '~user/no-such', settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/revision/name/', settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/42/name/error', 'https://jujucharms.com/charms/mediawiki/simple/', ) for url in bad_urls: with self.assert_value_error('invalid bundle URL: {}'.format(url)): utils.convert_bundle_url(url) class TestGetCharmUrl(helpers.UrlReadTestsMixin, unittest.TestCase): def test_charm_url(self): # The Juju GUI charm URL is correctly returned. contents = json.dumps({'charm': {'url': 'cs:precise/juju-gui-42'}}) with self.patch_urlread(contents=contents) as mock_urlread: charm_url = utils.get_charm_url() self.assertEqual('cs:precise/juju-gui-42', charm_url) mock_urlread.assert_called_once_with(settings.CHARMWORLD_API) def test_io_error(self): # IOErrors are properly propagated. with self.patch_urlread(error=True) as mock_urlread: with self.assertRaises(IOError) as context_manager: utils.get_charm_url() mock_urlread.assert_called_once_with(settings.CHARMWORLD_API) self.assertEqual('bad wolf', bytes(context_manager.exception)) def test_value_error(self): # A ValueError is raised if the API response is not valid. contents = json.dumps({'charm': {}}) with self.patch_urlread(contents=contents) as mock_urlread: with self.assertRaises(ValueError) as context_manager: utils.get_charm_url() mock_urlread.assert_called_once_with(settings.CHARMWORLD_API) self.assertEqual( 'unable to find the charm URL', bytes(context_manager.exception)) class TestGetQuickstartBanner(unittest.TestCase): def patch_datetime(self): mock_datetime = mock.Mock() mock_datetime.utcnow.return_value = datetime.datetime( 2014, 2, 27, 7, 42, 47) return mock.patch('datetime.datetime', mock_datetime) def test_banner(self): # The banner is correctly generated. with self.patch_datetime(): obtained = utils.get_quickstart_banner() expected = ( '# This file has been generated by juju quickstart v{}\n' '# at 2014-02-27 07:42:47 UTC.\n\n' ).format(get_version()) self.assertEqual(expected, obtained) class TestGetServiceInfo(helpers.WatcherDataTestsMixin, unittest.TestCase): def test_service_and_unit(self): # The data about the given service and unit is correctly returned. service_change = self.make_service_change() unit_change = self.make_unit_change() status = [service_change, unit_change] expected = (service_change[2], unit_change[2]) self.assertEqual(expected, utils.get_service_info(status, 'my-gui')) def test_service_only(self): # The data about the given service without units is correctly returned. service_change = self.make_service_change() status = [service_change] expected = (service_change[2], None) self.assertEqual(expected, utils.get_service_info(status, 'my-gui')) def test_service_removed(self): # A tuple (None, None) is returned if the service is being removed. status = [ self.make_service_change(action='remove'), self.make_unit_change(), ] expected = (None, None) self.assertEqual(expected, utils.get_service_info(status, 'my-gui')) def test_another_service(self): # A tuple (None, None) is returned if the service is not found. status = [ self.make_service_change(data={'Name': 'another-service'}), self.make_unit_change(), ] expected = (None, None) self.assertEqual(expected, utils.get_service_info(status, 'my-gui')) def test_service_not_alive(self): # A tuple (None, None) is returned if the service is not alive. status = [ self.make_service_change(data={'Life': 'dying'}), self.make_unit_change(), ] expected = (None, None) self.assertEqual(expected, utils.get_service_info(status, 'my-gui')) def test_unit_removed(self): # The unit data is not returned if the unit is being removed. service_change = self.make_service_change() status = [service_change, self.make_unit_change(action='remove')] expected = (service_change[2], None) self.assertEqual(expected, utils.get_service_info(status, 'my-gui')) def test_another_unit(self): # The unit data is not returned if the unit belongs to another service. service_change = self.make_service_change() status = [ service_change, self.make_unit_change(data={'Service': 'another-service'}), ] expected = (service_change[2], None) self.assertEqual(expected, utils.get_service_info(status, 'my-gui')) def test_no_services(self): # A tuple (None, None) is returned no services are found. status = [self.make_unit_change()] expected = (None, None) self.assertEqual(expected, utils.get_service_info(status, 'my-gui')) def test_no_entities(self): # A tuple (None, None) is returned no entities are found. expected = (None, None) self.assertEqual(expected, utils.get_service_info([], 'my-gui')) class TestGetUbuntuCodename(helpers.CallTestsMixin, unittest.TestCase): def test_codename(self): # The distribution codename is correctly returned. with self.patch_call(0, output='trusty\n') as mock_call: codename = utils.get_ubuntu_codename() self.assertEqual('trusty', codename) mock_call.assert_called_once_with('lsb_release', '-cs') def test_error_retrieving_codename(self): # An OSError is returned if the codename cannot be retrieved. with self.patch_call(1, error='bad wolf') as mock_call: with self.assertRaises(OSError) as context_manager: utils.get_ubuntu_codename() self.assertEqual('bad wolf', bytes(context_manager.exception)) mock_call.assert_called_once_with('lsb_release', '-cs') class TestMkdir(unittest.TestCase): def setUp(self): # Set up a playground directory. self.playground = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.playground) def test_create_dir(self): # A directory is correctly created. path = os.path.join(self.playground, 'foo') utils.mkdir(path) self.assertTrue(os.path.isdir(path)) def test_intermediate_dirs(self): # All intermediate directories are created. path = os.path.join(self.playground, 'foo', 'bar', 'leaf') utils.mkdir(path) self.assertTrue(os.path.isdir(path)) def test_expand_user(self): # The ~ construction is expanded. with mock.patch('os.environ', {'HOME': self.playground}): utils.mkdir('~/in/my/home') path = os.path.join(self.playground, 'in', 'my', 'home') self.assertTrue(os.path.isdir(path)) def test_existing_dir(self): # The function exits without errors if the target directory exists. path = os.path.join(self.playground, 'foo') os.mkdir(path) utils.mkdir(path) def test_existing_file(self): # An OSError is raised if a file already exists in the target path. path = os.path.join(self.playground, 'foo') with open(path, 'w'): with self.assertRaises(OSError): utils.mkdir(path) def test_failure(self): # Errors are correctly re-raised. path = os.path.join(self.playground, 'foo') os.chmod(self.playground, 0000) self.addCleanup(os.chmod, self.playground, 0700) with self.assertRaises(OSError): utils.mkdir(os.path.join(path)) self.assertFalse(os.path.exists(path)) class TestParseBundle( helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): def assert_bundle( self, expected_name, expected_services, contents, bundle_name=None): """Ensure parsing the given contents returns the expected values.""" name, services = utils.parse_bundle(contents, bundle_name=bundle_name) self.assertEqual(expected_name, name) self.assertEqual(set(expected_services), set(services)) def test_invalid_yaml(self): # A ValueError is raised if the bundle contents are not a valid YAML. with self.assertRaises(ValueError) as context_manager: utils.parse_bundle(':') expected = 'unable to parse the bundle' self.assertIn(expected, bytes(context_manager.exception)) def test_yaml_invalid_type(self): # A ValueError is raised if the bundle contents are not well formed. with self.assert_value_error('invalid YAML contents: a-string'): utils.parse_bundle('a-string') def test_yaml_invalid_bundle_data(self): # A ValueError is raised if bundles are not well formed. contents = yaml.safe_dump({'mybundle': 'not valid'}) expected = 'invalid YAML contents: {mybundle: not valid}\n' with self.assert_value_error(expected): utils.parse_bundle(contents) def test_yaml_no_service(self): # A ValueError is raised if bundles do not include services. contents = yaml.safe_dump({'mybundle': {}}) expected = 'invalid YAML contents: mybundle: {}\n' with self.assert_value_error(expected): utils.parse_bundle(contents) def test_yaml_none_bundle_services(self): # A ValueError is raised if services are None. contents = yaml.safe_dump({'mybundle': {'services': None}}) expected = 'invalid YAML contents: mybundle: {services: null}\n' with self.assert_value_error(expected): utils.parse_bundle(contents) def test_yaml_invalid_bundle_services_type(self): # A ValueError is raised if services have an invalid type. contents = yaml.safe_dump({'mybundle': {'services': 42}}) expected = 'invalid YAML contents: mybundle: {services: 42}\n' with self.assert_value_error(expected): utils.parse_bundle(contents) def test_yaml_no_bundles(self): # A ValueError is raised if the bundle contents are empty. with self.assert_value_error('no bundles found'): utils.parse_bundle(yaml.safe_dump({})) def test_bundle_name_not_specified(self): # A ValueError is raised if the bundle name is not specified and the # contents contain more than one bundle. expected = ('multiple bundles found (bundle1, bundle2) ' 'but no bundle name specified') with self.assert_value_error(expected): utils.parse_bundle(self.valid_bundle) def test_bundle_name_not_found(self): # A ValueError is raised if the given bundle is not found in the file. expected = ('bundle no-such not found in the provided list of bundles ' '(bundle1, bundle2)') with self.assert_value_error(expected): utils.parse_bundle(self.valid_bundle, 'no-such') def test_no_services(self): # A ValueError is raised if the specified bundle does not contain # services. contents = yaml.safe_dump({'mybundle': {'services': {}}}) expected = 'bundle mybundle does not include any services' with self.assert_value_error(expected): utils.parse_bundle(contents) def test_yaml_gui_in_services(self): # A ValueError is raised if the bundle contains juju-gui. contents = yaml.safe_dump({ 'mybundle': {'services': {settings.JUJU_GUI_SERVICE_NAME: {}}}, }) expected = 'bundle mybundle contains an instance of juju-gui. ' \ 'quickstart will install the latest version of the Juju GUI ' \ 'automatically, please remove juju-gui from the bundle.' with self.assert_value_error(expected): utils.parse_bundle(contents) def test_success_no_name(self): # The function succeeds when an implicit bundle name is used. contents = yaml.safe_dump({ 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}}, }) self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents) def test_success_multiple_bundles(self): # The function succeeds with multiple bundles. self.assert_bundle( 'bundle2', ['django', 'nodejs'], self.valid_bundle, 'bundle2') def test_success_json(self): # Since JSON is a subset of YAML, the function also support JSON # encoded bundles. contents = json.dumps({ 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}}, }) self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents) class TestParseStatusOutput(helpers.ValueErrorTestsMixin, unittest.TestCase): def test_invalid_yaml(self): # A ValueError is raised if the output is not a valid YAML. with self.assertRaises(ValueError) as context_manager: utils.parse_status_output(':') expected = 'unable to parse the output' self.assertIn(expected, bytes(context_manager.exception)) def test_invalid_yaml_contents(self): # A ValueError is raised if the output is not well formed. with self.assert_value_error('invalid YAML contents: a-string'): utils.parse_status_output('a-string') def test_no_agent_state(self): # A ValueError is raised if the agent-state is not found in the YAML. data = { 'machines': { '0': {'agent-version': '1.17.0.1'}, }, } expected = 'machines:0:agent-state not found in {}'.format(bytes(data)) with self.assert_value_error(expected): utils.get_agent_state(yaml.safe_dump(data)) def test_success_agent_state(self): # The agent state is correctly returned. output = yaml.safe_dump({ 'machines': { '0': {'agent-version': '1.17.0.1', 'agent-state': 'started'}, }, }) agent_state = utils.get_agent_state(output) self.assertEqual('started', agent_state) def test_no_bootstrap_node_series(self): # A ValueError is raised if the series is not found in the YAML. data = { 'machines': { '0': {'agent-version': '1.17.0.1'}, }, } expected = 'machines:0:series not found in {}'.format(bytes(data)) with self.assert_value_error(expected): utils.get_bootstrap_node_series(yaml.safe_dump(data)) def test_success_bootstrap_node_series(self): # The bootstrap node series is correctly returned. output = yaml.safe_dump({ 'machines': { '0': {'agent-version': '1.17.0.1', 'agent-state': 'started', 'series': 'zydeco'}, }, }) bsn_series = utils.get_bootstrap_node_series(output) self.assertEqual('zydeco', bsn_series) class TestRunOnce(unittest.TestCase): def setUp(self): self.results = [] self.func = utils.run_once(self.results.append) def test_runs_once(self): # The wrapped function runs only the first time it is invoked. self.func(1) self.assertEqual([1], self.results) self.func(2) self.assertEqual([1], self.results) def test_wrapped(self): # The wrapped function looks like the original one. self.assertEqual('append', self.func.__name__) self.assertEqual(list.append.__doc__, self.func.__doc__) class TestUrlread(unittest.TestCase): def patch_urlopen(self, contents=None, error=None, content_type=None): """Patch the urllib2.urlopen function. If contents is not None, the read() method of the returned mock object returns the given contents. If content_type is provided, the response includes the content type. If an error is provided, the call raises the error. """ mock_urlopen = mock.MagicMock() if contents is not None: mock_urlopen().read.return_value = contents if content_type is not None: mock_urlopen().headers = {'content-type': content_type} if error is not None: mock_urlopen.side_effect = error mock_urlopen.reset_mock() return mock.patch('urllib2.urlopen', mock_urlopen) def test_contents(self): # The URL contents are correctly returned. with self.patch_urlopen(contents=b'URL contents') as mock_urlopen: contents = utils.urlread('http://example.com/path/') self.assertEqual('URL contents', contents) self.assertIsInstance(contents, unicode) mock_urlopen.assert_called_once_with('http://example.com/path/') def test_content_type(self): # The URL contents are decoded using the site charset. patch_urlopen = self.patch_urlopen( contents=b'URL contents: \xf8', # This is not a UTF-8 byte string. content_type='text/html; charset=ISO-8859-1') with patch_urlopen as mock_urlopen: contents = utils.urlread('http://example.com/path/') self.assertEqual('URL contents: \xf8', contents) self.assertIsInstance(contents, unicode) mock_urlopen.assert_called_once_with('http://example.com/path/') def test_no_content_type(self): # The URL contents are decoded with UTF-8 by default. patch_urlopen = self.patch_urlopen( contents=b'URL contents: \xf8', # This is not a UTF-8 byte string. content_type='text/html') with patch_urlopen as mock_urlopen: contents = utils.urlread('http://example.com/path/') self.assertEqual('URL contents: ', contents) self.assertIsInstance(contents, unicode) mock_urlopen.assert_called_once_with('http://example.com/path/') def test_errors(self): # An IOError is raised if an error occurs connecting to the API. errors = { 'httplib HTTPException': httplib.HTTPException, 'socket error': socket.error, 'urllib2 URLError': urllib2.URLError, } for message, exception_class in errors.items(): exception = exception_class(message) with self.patch_urlopen(error=exception) as mock_urlopen: with self.assertRaises(IOError) as context_manager: utils.urlread('http://example.com/path/') mock_urlopen.assert_called_once_with('http://example.com/path/') self.assertEqual(message, bytes(context_manager.exception)) class TestGetJujuVersion( helpers.CallTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): def test_return_deconstructed_version(self): # Should return a deconstructed juju version. with self.patch_call(0, '1.17.1-precise-amd64\n', ''): version = utils.get_juju_version() self.assertEqual((1, 17, 1), version) def test_juju_version_error(self): # A ValueError is raised if "juju version" exits with an error. with self.patch_call(1, 'foo', 'bad wolf'): with self.assert_value_error('bad wolf'): utils.get_juju_version() def test_invalid_version_string(self): # A ValueError is raised if "juju version" outputs an invalid version. with self.patch_call(0, '1.17-precise-amd64', ''): with self.assert_value_error('invalid version string: 1.17'): utils.get_juju_version() juju-quickstart-1.3.1/quickstart/tests/models/0000755000175000017500000000000012320751720023065 5ustar frankbanfrankban00000000000000juju-quickstart-1.3.1/quickstart/tests/models/test_charms.py0000644000175000017500000002064312251372515025764 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart charms management.""" from __future__ import unicode_literals import unittest from quickstart.models import charms from quickstart.tests import helpers class TestParseUrl(helpers.ValueErrorTestsMixin, unittest.TestCase): def test_no_schema_error(self): # A ValueError is raised if the URL schema is missing. expected = 'charm URL has no schema: precise/juju-gui' with self.assert_value_error(expected): charms.parse_url('precise/juju-gui') def test_invalid_schema_error(self): # A ValueError is raised if the URL schema is not valid. expected = 'charm URL has invalid schema: http' with self.assert_value_error(expected): charms.parse_url('http:precise/juju-gui') def test_invalid_user_form_error(self): # A ValueError is raised if the user form is not valid. expected = 'charm URL has invalid user name form: jean-luc' with self.assert_value_error(expected): charms.parse_url('cs:jean-luc/precise/juju-gui') def test_invalid_user_name_error(self): # A ValueError is raised if the user name is not valid. expected = 'charm URL has invalid user name: jean:luc' with self.assert_value_error(expected): charms.parse_url('cs:~jean:luc/precise/juju-gui') def test_local_user_name_error(self): # A ValueError is raised if a user is specified on a local charm. expected = ( 'local charm URL with user name: ' 'local:~jean-luc/precise/juju-gui') with self.assert_value_error(expected): charms.parse_url('local:~jean-luc/precise/juju-gui') def test_invalid_form_error(self): # A ValueError is raised if the URL is not valid. expected = 'charm URL has invalid form: cs:~user/series/name/what-?' with self.assert_value_error(expected): charms.parse_url('cs:~user/series/name/what-?') def test_invalid_series_error(self): # A ValueError is raised if the series is not valid. expected = 'charm URL has invalid series: boo!' with self.assert_value_error(expected): charms.parse_url('cs:boo!/juju-gui-42') def test_no_revision_error(self): # A ValueError is raised if the charm revision is missing. expected = 'charm URL has no revision: cs:series/name' with self.assert_value_error(expected): charms.parse_url('cs:series/name') def test_invalid_revision_error(self): # A ValueError is raised if the charm revision is not valid. expected = 'charm URL has invalid revision: revision' with self.assert_value_error(expected): charms.parse_url('cs:series/name-revision') def test_invalid_name_error(self): # A ValueError is raised if the charm name is not valid. expected = 'charm URL has invalid name: not:valid' with self.assert_value_error(expected): charms.parse_url('cs:precise/not:valid-42') def test_success_with_user(self): # A charm URL including the user is correctly parsed. schema, user, series, name, revision = charms.parse_url( 'cs:~who/precise/juju-gui-42') self.assertEqual('cs', schema) self.assertEqual('who', user) self.assertEqual('precise', series) self.assertEqual('juju-gui', name) self.assertEqual(42, revision) def test_success_without_user(self): # A charm URL not including the user is correctly parsed. schema, user, series, name, revision = charms.parse_url( 'cs:trusty/django-1') self.assertEqual('cs', schema) self.assertEqual('', user) self.assertEqual('trusty', series) self.assertEqual('django', name) self.assertEqual(1, revision) def test_success_local_charm(self): # A local charm URL is correctly parsed. schema, user, series, name, revision = charms.parse_url( 'local:saucy/wordpress-100') self.assertEqual('local', schema) self.assertEqual('', user) self.assertEqual('saucy', series) self.assertEqual('wordpress', name) self.assertEqual(100, revision) class TestCharm(helpers.ValueErrorTestsMixin, unittest.TestCase): def make_charm( self, schema='cs', user='myuser', series='precise', name='juju-gui', revision=42): """Create and return a Charm instance.""" return charms.Charm(schema, user, series, name, revision) def test_attributes(self): # All charm attributes are correctly stored. charm = self.make_charm() self.assertEqual('cs', charm.schema) self.assertEqual('myuser', charm.user) self.assertEqual('precise', charm.series) self.assertEqual('juju-gui', charm.name) self.assertEqual(42, charm.revision) def test_revision_as_string(self): # Revision is converted to an int. charm = self.make_charm(revision='47') self.assertEqual(47, charm.revision) def test_from_url(self): # A Charm can be instantiated from a charm URL. charm = charms.Charm.from_url('cs:~who/trusty/django-1') self.assertEqual('cs', charm.schema) self.assertEqual('who', charm.user) self.assertEqual('trusty', charm.series) self.assertEqual('django', charm.name) self.assertEqual(1, charm.revision) def test_from_url_without_user(self): # Official charm store URLs are properly handled. charm = charms.Charm.from_url('cs:saucy/django-123') self.assertEqual('cs', charm.schema) self.assertEqual('', charm.user) self.assertEqual('saucy', charm.series) self.assertEqual('django', charm.name) self.assertEqual(123, charm.revision) def test_from_url_local(self): # Local charms URLs are properly handled. charm = charms.Charm.from_url('local:precise/my-local-charm-42') self.assertEqual('local', charm.schema) self.assertEqual('', charm.user) self.assertEqual('precise', charm.series) self.assertEqual('my-local-charm', charm.name) self.assertEqual(42, charm.revision) def test_from_url_error(self): # A ValueError is raised by the from_url class method if the provided # URL is not a valid charm URL. expected = 'charm URL has invalid form: cs:not-a-charm-url' with self.assert_value_error(expected): charms.Charm.from_url('cs:not-a-charm-url') def test_string(self): # The string representation of a charm instance is its URL. charm = self.make_charm() self.assertEqual('cs:~myuser/precise/juju-gui-42', bytes(charm)) def test_repr(self): # A charm instance is correctly represented. charm = self.make_charm() self.assertEqual( '', repr(charm)) def test_charm_store_url(self): # A charm store URL is correctly returned. charm = self.make_charm(schema='cs') self.assertEqual('cs:~myuser/precise/juju-gui-42', charm.url()) def test_local_url(self): # A local charm URL is correctly returned. charm = self.make_charm(schema='local', user='') self.assertEqual('local:precise/juju-gui-42', charm.url()) def test_charm_store_charm(self): # The is_local method returns False for charm store charms. charm = self.make_charm(schema='cs') self.assertFalse(charm.is_local()) def test_local_charm(self): # The is_local method returns True for local charms. charm = self.make_charm(schema='local') self.assertTrue(charm.is_local()) juju-quickstart-1.3.1/quickstart/tests/models/test_envs.py0000644000175000017500000013077512317464767025510 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart environments management.""" from __future__ import unicode_literals import collections import copy import functools import os import shutil import tempfile import unittest import mock import yaml from quickstart.models import ( envs, fields, ) from quickstart.tests import helpers class TestGetDefaultEnvName(helpers.CallTestsMixin, unittest.TestCase): def test_environment_variable(self): # The environment name is successfully returned if JUJU_ENV is set. with mock.patch('os.environ', {'JUJU_ENV': 'ec2'}): env_name = envs.get_default_env_name() self.assertEqual('ec2', env_name) def test_empty_environment_variable(self): # The environment name is not found if JUJU_ENV is empty. with self.patch_call(1): with mock.patch('os.environ', {'JUJU_ENV': ' '}): env_name = envs.get_default_env_name() self.assertIsNone(env_name) def test_no_environment_variable(self): # The environment name is not found if JUJU_ENV is not defined. with self.patch_call(1): with mock.patch('os.environ', {}): env_name = envs.get_default_env_name() self.assertIsNone(env_name) def test_juju_switch_old_behavior(self): # The environment name is successfully returned if retrievable using # the "juju switch" command. This test exercises the old "juju switch" # returning a human readable output. output = 'Current environment: "hp"\n' with self.patch_call(0, output=output) as mock_call: with mock.patch('os.environ', {}): env_name = envs.get_default_env_name() self.assertEqual('hp', env_name) mock_call.assert_called_once_with('juju', 'switch') def test_juju_switch_new_behavior(self): # The environment name is successfully returned if retrievable using # the "juju switch" command. This test exercises the new "juju switch" # returning a machine readable output (just the name of the env). # This new behavior has been introduced in juju-core 1.17. output = 'ec2\n' with self.patch_call(0, output=output) as mock_call: with mock.patch('os.environ', {}): env_name = envs.get_default_env_name() self.assertEqual('ec2', env_name) mock_call.assert_called_once_with('juju', 'switch') def test_juju_switch_failure(self): # The environment name is not found if "juju switch" returns an error. with self.patch_call(1) as mock_call: with mock.patch('os.environ', {}): env_name = envs.get_default_env_name() self.assertIsNone(env_name) mock_call.assert_called_once_with('juju', 'switch') class TestCreateEmptyEnvDb(unittest.TestCase): def test_resulting_env_db(self): # The function surprisingly returns an empty environments database. env_db = envs.create_empty_env_db() self.assertEqual({'environments': {}}, env_db) class TestLoadFile( helpers.EnvFileTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): def test_no_file(self): # A ValueError is raised if the environments file is not found. expected = ( "unable to open environments file: " "[Errno 2] No such file or directory: '/no/such/file.yaml'" ) with self.assert_value_error(expected): envs._load_file('/no/such/file.yaml') def test_invalid_yaml(self): # A ValueError is raised if the environments file is not a valid YAML. env_file = self.make_env_file(':') with self.assertRaises(ValueError) as context_manager: envs._load_file(env_file) expected = 'unable to parse environments file {}'.format(env_file) self.assertIn(expected, bytes(context_manager.exception)) class TestLoad( helpers.EnvFileTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): def test_empty_file(self): # An empty environments database is returned if the file is empty. env_file = self.make_env_file('') env_db = envs.load(env_file) self.assertEqual({'environments': {}}, env_db) def test_invalid_yaml_contents(self): # A ValueError is raised if the environments file is not well formed. env_file = self.make_env_file('a-string') expected = 'invalid YAML contents in {}: a-string'.format(env_file) with self.assert_value_error(expected): envs.load(env_file) def test_success_with_default(self): # The YAML decoded environments dictionary (including default) is # correctly generated and returned. env_file = self.make_env_file() env_db = envs.load(env_file) self.assertEqual(yaml.safe_load(self.valid_contents), env_db) def test_success_no_default(self): # The YAML decoded environments dictionary (with no default) is # correctly generated and returned. expected = { 'environments': { 'aws': {'admin-secret': 'Secret!', 'type': 'ec2'}, 'local': {'admin-secret': 'Secret!', 'type': 'local'}, }, } env_file = self.make_env_file(yaml.safe_dump(expected)) env_db = envs.load(env_file) self.assertEqual(expected, env_db) def test_success_invalid_default(self): # The YAML decoded environments dictionary is correctly generated and # returned excluding invalid default environment values. expected = { 'environments': { 'aws': {'admin-secret': 'Secret!', 'type': 'ec2'}, }, } yaml_contents = expected.copy() yaml_contents['default'] = 'no-such-env' expected_logs = ['excluding invalid default no-such-env'] env_file = self.make_env_file(yaml.safe_dump(yaml_contents)) with helpers.assert_logs(expected_logs, 'warn'): env_db = envs.load(env_file) self.assertEqual(expected, env_db) def test_success_extraneous_fields(self): # The YAML decoded environments dictionary is correctly generated and # returned preserving extraneous fields. expected = { 'environments': { 'aws': {'polluted': True, 'type': 'ec2'}, 'local': {'answer': 42}, }, } env_file = self.make_env_file(yaml.safe_dump(expected)) env_db = envs.load(env_file) self.assertEqual(expected, env_db) def test_success_excluding_envs(self): # The YAML decoded environments dictionary is correctly generated and # returned excluding invalid environments. expected = { 'default': 'aws', 'environments': { 'aws': {'admin-secret': 'Secret!', 'type': 'ec2'}, }, } yaml_contents = copy.deepcopy(expected) yaml_contents['environments']['bad'] = 42 expected_logs = ['excluding invalid environment bad'] env_file = self.make_env_file(yaml.safe_dump(yaml_contents)) with helpers.assert_logs(expected_logs, 'warn'): env_db = envs.load(env_file) self.assertEqual(expected, env_db) class TestLoadGenerated( helpers.EnvFileTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): def test_empty_file(self): # A ValueError is raised if the environments file is empty. env_file = self.make_env_file('') expected = 'invalid YAML contents in {}: None'.format(env_file) with self.assert_value_error(expected): envs.load_generated(env_file) def test_invalid_yaml_contents(self): # A ValueError is raised if the environments file is not well formed. env_file = self.make_env_file('a-string') expected = 'invalid YAML contents in {}: a-string'.format(env_file) with self.assert_value_error(expected): envs.load_generated(env_file) def test_section_not_found(self): expected = { 'shoehorn-config': { 'admin-secret': 'Secret!', 'type': 'ec2'}, } yaml_contents = expected.copy() env_file = self.make_env_file(yaml.safe_dump(yaml_contents)) expected = 'invalid YAML contents in {}: {}'.format( env_file, yaml_contents) with self.assert_value_error(expected): envs.load_generated(env_file) def test_successful_default_section(self): expected = { 'bootstrap-config': { 'admin-secret': 'Secret!', 'type': 'ec2'}, } yaml_contents = expected.copy() env_file = self.make_env_file(yaml.safe_dump(yaml_contents)) env_db = envs.load_generated(env_file) self.assertEqual(expected['bootstrap-config'], env_db) def test_successful_specified_section(self): expected = { 'my-config': { 'admin-secret': 'Secret!', 'type': 'ec2'}, } yaml_contents = expected.copy() env_file = self.make_env_file(yaml.safe_dump(yaml_contents)) env_db = envs.load_generated(env_file, section='my-config') self.assertEqual(expected['my-config'], env_db) class TestSave(helpers.EnvFileTestsMixin, unittest.TestCase): def setUp(self): # Create an environments file. self.env_file = self.make_env_file() self.original_contents = open(self.env_file).read() def make_juju_home(self): """Create a temporary Juju home directory. Return the Juju home path and the path of the corresponding environments file. """ playground = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, playground) juju_home = os.path.join(playground, '.juju') os.mkdir(juju_home) return juju_home, os.path.join(juju_home, 'environments.yaml') def test_dump_error(self): # An OSError is raised if errors occur in the serialization process. with mock.patch('quickstart.serializers.yaml_dump') as mock_yaml_dump: mock_yaml_dump.side_effect = ValueError(b'bad wolf') with self.assertRaises(OSError) as context_manager: envs.save(self.env_file, {'new': 'contents'}) self.assertEqual(b'bad wolf', str(context_manager.exception)) # The original file contents have been left untouched. self.assertEqual(self.valid_contents, self.original_contents) def test_atomic(self): # The new contents are written in a temporary file before and then the # file is renamed to the original destination. If an error occurs, the # original contents are not influenced. def rename(source, destination): # Remove the source before actually renaming in order to simulate # some kind of disk error. os.remove(source) os.rename(source, destination) with mock.patch('os.rename', rename): with self.assertRaises(OSError) as context_manager: envs.save(self.env_file, {'new': 'contents'}) self.assertIn( 'No such file or directory', str(context_manager.exception)) # The original file contents have been left untouched. self.assertEqual(self.valid_contents, self.original_contents) def test_success(self): # The environments file is correctly updated with the new contents. envs.save(self.env_file, {'new': 'contents'}) contents = open(self.env_file).read() # The banner has been written. self.assertIn( '# This file has been generated by juju quickstart', contents) # Also the new contents have been saved. self.assertIn('new: contents', contents) def test_backup(self): # A backup function, if provided, is used to create a backup copy # of the original environments file. mock_backup = mock.Mock() envs.save( self.env_file, {'new': 'contents'}, backup_function=mock_backup) contents = open(self.env_file).read() expected_backup_path = self.env_file + '.quickstart.bak' # The backup function has been called. mock_backup.assert_called_once_with( self.env_file, expected_backup_path) # The new file indicates where to find the backup copy. self.assertIn( '# A backup copy of this file can be found in\n' '# {}\n'.format(expected_backup_path), contents) def test_missing_juju_home(self): # The environments file directory is created if not existing. juju_home, env_file = self.make_juju_home() shutil.rmtree(juju_home) envs.save(env_file, {'new': 'contents'}) self.assertTrue(os.path.isdir(juju_home)) def test_backup_missing_juju_home(self): # The backup function is not executed if there is nothing to backup. juju_home, env_file = self.make_juju_home() mock_backup = mock.Mock() envs.save(env_file, {'new': 'contents'}, backup_function=mock_backup) self.assertFalse(mock_backup.called) class EnvDataTestsMixin(object): """Set up an initial environments dictionary.""" def setUp(self): self.env_db = { 'default': 'lxc', 'environments': { 'aws': { 'admin_secret': 'Secret!', 'default-series': 'precise', 'type': 'ec2', }, 'lxc': { 'admin_secret': 'NotSoSecret!', 'mutable': [1, 2, 3], 'type': 'local', }, }, } self.original = copy.deepcopy(self.env_db) super(EnvDataTestsMixin, self).setUp() def assert_env_db_not_modified(self): """Ensure the stored env_db is not modified.""" self.assertEqual(self.original, self.env_db) class TestGetEnvData( EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): def test_env_not_found(self): # A ValueError is raised if an environment with the given name is not # found in the environments dictionary. with self.assert_value_error("environment no-such not found"): envs.get_env_data(self.env_db, 'no-such') def test_resulting_env_data(self): # The resulting env_data is correctly generated. expected = { 'admin_secret': 'Secret!', 'default-series': 'precise', 'is-default': False, 'name': 'aws', 'type': 'ec2', } obtained = envs.get_env_data(self.env_db, 'aws') self.assertEqual(expected, obtained) def test_env_data_default_environments(self): # The env_data is correctly generated for a default environment. expected = { 'admin_secret': 'NotSoSecret!', 'is-default': True, 'mutable': [1, 2, 3], 'name': 'lxc', 'type': 'local', } obtained = envs.get_env_data(self.env_db, 'lxc') self.assertEqual(expected, obtained) def test_mutate(self): # Modifications to the resulting env_data do not influence the original # environments dictionary. env_data = envs.get_env_data(self.env_db, 'lxc') env_data.update({ 'admin_secret': 'AnotherSecret!', 'is-default': False, 'new-field': 'new-value' }) # Also change a mutable internal data structure. env_data['mutable'].append(42) self.assert_env_db_not_modified() class TestSetEnvData( EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): def test_error_no_name_key(self): # A ValueError is raised if the env_data dictionary does not include # the "name" key. env_data = {'is-default': False} expected = "invalid env_data: {u'is-default': False}" with self.assert_value_error(expected): envs.set_env_data(self.env_db, 'aws', env_data) # The environments dictionary has not been modified. self.assert_env_db_not_modified() def test_error_no_default_key(self): # A ValueError is raised if the env_data dictionary does not include # the "is-default" key. env_data = {'name': 'aws'} expected = "invalid env_data: {u'name': u'aws'}" with self.assert_value_error(expected): envs.set_env_data(self.env_db, 'aws', env_data) # The environments dictionary has not been modified. self.assert_env_db_not_modified() def test_error_new_environment(self): # A ValueError is raised if the name of a new environment is # already in the environments dictionary. env_data = {'is-default': False, 'name': 'aws'} expected = "an environment named u'aws' already exists" with self.assert_value_error(expected): envs.set_env_data(self.env_db, None, env_data) # The environments dictionary has not been modified. self.assert_env_db_not_modified() def test_error_existing_environment(self): # A ValueError is raised if the new name of a renamed existing # environment is already in the environments dictionary. env_data = {'is-default': False, 'name': 'lxc'} expected = "an environment named u'lxc' already exists" with self.assert_value_error(expected): envs.set_env_data(self.env_db, 'aws', env_data) # The environments dictionary has not been modified. self.assert_env_db_not_modified() def test_new_environment_added(self): # A new environment is properly added to the environments dictionary. env_data = { 'default-series': 'edgy', 'is-default': False, 'name': 'new-one', } return_value = envs.set_env_data(self.env_db, None, env_data) self.assertIsNone(return_value) expected = { 'default': 'lxc', 'environments': { 'aws': { 'admin_secret': 'Secret!', 'default-series': 'precise', 'type': 'ec2', }, 'lxc': { 'admin_secret': 'NotSoSecret!', 'mutable': [1, 2, 3], 'type': 'local', }, 'new-one': { 'default-series': 'edgy', }, }, } self.assertEqual(expected, self.env_db) def test_new_default_environment_added(self): # A new default environment is properly added to the environments # dictionary. env_data = { 'default-series': 'edgy', 'is-default': True, 'name': 'new-one', } envs.set_env_data(self.env_db, None, env_data) self.assertIn('new-one', self.env_db['environments']) self.assertEqual( {'default-series': 'edgy'}, self.env_db['environments']['new-one']) self.assertEqual('new-one', self.env_db['default']) def test_new_environment_with_no_default(self): # A new environment is properly added in an env_db with no default. env_data = { 'default-series': 'edgy', 'is-default': False, 'name': 'new-one', } del self.env_db['default'] envs.set_env_data(self.env_db, None, env_data) self.assertEqual( {'default-series': 'edgy'}, self.env_db['environments']['new-one']) self.assertNotIn('default', self.env_db) def test_existing_environment_updated(self): # An existing environment is properly updated. env_data = { 'admin_secret': 'NewSecret!', 'is-default': True, 'name': 'lxc', 'type': 'local' } return_value = envs.set_env_data(self.env_db, 'lxc', env_data) self.assertIsNone(return_value) expected = { 'default': 'lxc', 'environments': { 'aws': { 'admin_secret': 'Secret!', 'default-series': 'precise', 'type': 'ec2', }, 'lxc': { 'admin_secret': 'NewSecret!', 'type': 'local', }, }, } self.assertEqual(expected, self.env_db) def test_existing_environment_updated_changing_name(self): # An existing environment is properly updated, including its name. env_data = { 'admin_secret': 'Hash!', 'is-default': False, 'name': 'yay-the-clouds', 'type': 'ec2' } return_value = envs.set_env_data(self.env_db, 'aws', env_data) self.assertIsNone(return_value) expected = { 'default': 'lxc', 'environments': { 'lxc': { 'admin_secret': 'NotSoSecret!', 'mutable': [1, 2, 3], 'type': 'local', }, 'yay-the-clouds': { 'admin_secret': 'Hash!', 'type': 'ec2', }, }, } self.assertEqual(expected, self.env_db) def test_existing_environment_set_as_default(self): # An existing environment is correctly promoted as the default one. env_data = { 'default-series': 'edgy', 'is-default': True, 'name': 'aws', } envs.set_env_data(self.env_db, 'aws', env_data) self.assertIn('aws', self.env_db['environments']) self.assertEqual( {'default-series': 'edgy'}, self.env_db['environments']['aws']) self.assertEqual('aws', self.env_db['default']) def test_existing_environment_no_longer_default(self): # An existing environment is correctly downgraded to non-default. env_data = { 'default-series': 'edgy', 'is-default': False, 'name': 'lxc', } envs.set_env_data(self.env_db, 'lxc', env_data) self.assertIn('lxc', self.env_db['environments']) self.assertEqual( {'default-series': 'edgy'}, self.env_db['environments']['lxc']) self.assertNotIn('default', self.env_db) class TestCreateLocalEnvData(unittest.TestCase): def setUp(self): # Store the env_type_db. self.env_type_db = envs.get_env_type_db() def test_not_default(self): # The resulting env_data is correctly structured for non default envs. env_data = envs.create_local_env_data( self.env_type_db, 'my-lxc', is_default=False) # The function is not pure: auto-generated values change each time the # function is called. For local environments, the only auto-generated # value should be the admin-secret. admin_secret = env_data.pop('admin-secret', '') self.assertNotEqual(0, len(admin_secret)) expected = {'type': 'local', 'name': 'my-lxc', 'is-default': False} self.assertEqual(expected, env_data) def test_default(self): # The resulting env_data is correctly structured for default envs. env_data = envs.create_local_env_data( self.env_type_db, 'my-default', is_default=True) # See the comment about auto-generated fields in the test method above. admin_secret = env_data.pop('admin-secret', '') self.assertNotEqual(0, len(admin_secret)) expected = {'type': 'local', 'name': 'my-default', 'is-default': True} self.assertEqual(expected, env_data) class TestRemoveEnv( EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): def test_removal(self): # An environment is successfully removed from the environments db. envs.remove_env(self.env_db, 'aws') environments = self.env_db['environments'] self.assertNotIn('aws', environments) # The other environments are not removed. self.assertIn('lxc', environments) # The default environment is not modified. self.assertEqual('lxc', self.env_db['default']) def test_default_environment_removal(self): # An environment is successfully removed even if it is the default one. environments = self.env_db['environments'] environments['a-third-one'] = {'type': 'openstack'} envs.remove_env(self.env_db, 'lxc') self.assertNotIn('lxc', environments) # The other environments are not removed. self.assertIn('aws', environments) # Since there are two remaining environments, the environments database # no longer includes a default environment. self.assertNotIn('default', self.env_db) def test_one_remaining_environment(self): # If, after a removal, only one environment remains, it is # automatically set as default. envs.remove_env(self.env_db, 'lxc') self.assertEqual(1, len(self.env_db['environments'])) self.assertEqual('aws', self.env_db['default']) def test_remove_all_environments(self): # Removing all the environments results in an empty environments db. for env_name in list(self.env_db['environments'].keys()): envs.remove_env(self.env_db, env_name) self.assertEqual(envs.create_empty_env_db(), self.env_db) def test_invalid_environment_name(self): # A ValueError is raised if the environment is not present in env_db. expected = "the environment named u'no-such' does not exist" with self.assert_value_error(expected): envs.remove_env(self.env_db, 'no-such') class TestGetEnvTypeDb(unittest.TestCase): def setUp(self): self.env_type_db = envs.get_env_type_db() def assert_fields(self, expected, env_metadata): """Ensure the expected fields are defined as part of env_metadata.""" obtained = [field.name for field in env_metadata['fields']] self.assertEqual(expected, obtained) def assert_required_fields(self, expected, env_metadata): """Ensure the expected required fields are included in env_metadata.""" obtained = [ field.name for field in env_metadata['fields'] if field.required ] self.assertEqual(expected, obtained) def test_required_metadata(self): # The returned data includes the required env_metadata for each # provider type key. self.assertNotEqual(0, len(self.env_type_db)) for provider_type, env_metadata in self.env_type_db.items(): # Check the label metadata (not present in the fallback provider). if provider_type != '__fallback__': self.assertIn('label', env_metadata, provider_type) self.assertIsInstance( env_metadata['label'], unicode, provider_type) # Check the description metadata. self.assertIn('description', env_metadata, provider_type) self.assertIsInstance( env_metadata['description'], unicode, provider_type) # Check the fields metadata. self.assertIn('fields', env_metadata) self.assertIsInstance( env_metadata['fields'], collections.Iterable, provider_type) def test_fallback(self): # A fallback provider type is included. self.assertIn('__fallback__', self.env_type_db) env_metadata = self.env_type_db['__fallback__'] expected = [ 'type', 'name', 'admin-secret', 'default-series', 'is-default'] expected_required = ['type', 'name', 'is-default'] self.assert_fields(expected, env_metadata) self.assert_required_fields(expected_required, env_metadata) def test_local_environment(self): # The local environment metadata includes the expected fields. self.assertIn('local', self.env_type_db) env_metadata = self.env_type_db['local'] expected = [ 'type', 'name', 'admin-secret', 'default-series', 'root-dir', 'storage-port', 'shared-storage-port', 'network-bridge', 'is-default'] expected_required = ['type', 'name', 'is-default'] self.assert_fields(expected, env_metadata) self.assert_required_fields(expected_required, env_metadata) def test_ec2_environment(self): # The ec2 environment metadata includes the expected fields. self.assertIn('ec2', self.env_type_db) env_metadata = self.env_type_db['ec2'] expected = [ 'type', 'name', 'admin-secret', 'default-series', 'access-key', 'secret-key', 'control-bucket', 'region', 'is-default'] expected_required = [ 'type', 'name', 'access-key', 'secret-key', 'control-bucket', 'is-default'] self.assert_fields(expected, env_metadata) self.assert_required_fields(expected_required, env_metadata) def test_openstack_environment(self): # The openstack environment metadata includes the expected fields. self.assertIn('openstack', self.env_type_db) env_metadata = self.env_type_db['openstack'] expected = [ 'type', 'name', 'admin-secret', 'default-series', 'use-floating-ip', 'control-bucket', 'auth-url', 'tenant-name', 'region', 'auth-mode', 'username', 'password', 'access-key', 'secret-key', 'is-default'] expected_required = [ 'type', 'name', 'use-floating-ip', 'control-bucket', 'auth-url', 'tenant-name', 'region', 'is-default'] self.assert_fields(expected, env_metadata) self.assert_required_fields(expected_required, env_metadata) def test_azure_environment(self): # The azure environment metadata includes the expected fields. self.assertIn('azure', self.env_type_db) env_metadata = self.env_type_db['azure'] expected = [ 'type', 'name', 'admin-secret', 'default-series', 'location', 'management-subscription-id', 'management-certificate-path', 'storage-account-name', 'is-default'] expected_required = [ 'type', 'name', 'location', 'management-subscription-id', 'management-certificate-path', 'storage-account-name', 'is-default'] self.assert_fields(expected, env_metadata) self.assert_required_fields(expected_required, env_metadata) class TestGetSupportedEnvTypes(unittest.TestCase): def test_env_types(self): # All the supported env_types but the fallback one are returned. env_type_db = envs.get_env_type_db() expected_env_types = [ ('ec2', 'Amazon EC2'), ('openstack', 'OpenStack (or HP Public Cloud)'), ('azure', 'Windows Azure'), ('local', 'local (LXC)'), ] obtained_env_types = envs.get_supported_env_types(env_type_db) self.assertEqual(expected_env_types, obtained_env_types) class TestGetEnvMetadata(unittest.TestCase): def setUp(self): self.env_type_db = envs.get_env_type_db() def test_supported_environment(self): # The metadata for a supported environment is properly returned. env_data = {'type': 'local'} env_metadata = envs.get_env_metadata(self.env_type_db, env_data) self.assertEqual(self.env_type_db['local'], env_metadata) def test_unsupported_environment(self): # The metadata for an unsupported environment is properly returned. env_data = {'type': 'no-such'} env_metadata = envs.get_env_metadata(self.env_type_db, env_data) self.assertEqual(self.env_type_db['__fallback__'], env_metadata) def test_without_type(self): # The fallback metadata is also used when the env_data does not include # the provider type. env_metadata = envs.get_env_metadata(self.env_type_db, {}) self.assertEqual(self.env_type_db['__fallback__'], env_metadata) class TestMapFieldsToEnvData(unittest.TestCase): def setUp(self): env_type_db = envs.get_env_type_db() self.get_meta = functools.partial(envs.get_env_metadata, env_type_db) def assert_name_value_pairs(self, expected, env_data): """Ensure the expected field name/value pairs are included in env_data. """ pairs = envs.map_fields_to_env_data(self.get_meta(env_data), env_data) obtained = [(field.name, value) for field, value in pairs] self.assertEqual(expected, obtained) def make_valid_pairs(self): """Create and return a list of valid (field name, value) pairs.""" return [ ('type', 'local'), ('name', 'lxc'), ('admin-secret', 'Secret!'), ('default-series', 'saucy'), ('root-dir', '/my/juju/local/'), ('storage-port', 4242), ('shared-storage-port', 4747), ('network-bridge', 'lxcbr1'), ('is-default', True), ] def test_valid_env_data(self): # The field/value pairs are correctly returned. expected = self.make_valid_pairs() env_data = dict(expected) self.assert_name_value_pairs(expected, env_data) def test_missing_pairs(self): # None values are returned if a defined field is missing in env_data. expected = [ ('type', 'local'), ('name', 'lxc'), ('admin-secret', None), ('default-series', None), ('root-dir', None), ('storage-port', None), ('shared-storage-port', None), ('network-bridge', None), ('is-default', None), ] env_data = {'type': 'local', 'name': 'lxc'} self.assert_name_value_pairs(expected, env_data) def test_unexpected_pairs(self): # Additional unexpected field/value pairs are returned as well. expected_pairs = self.make_valid_pairs() unexpected_pairs = [ ('registry', 'USS Enterprise (NCC-1701-D)'), ('class', 'Galaxy'), ('years-of-service', 8), ('crashed', True), ('cloaking-device', None), ] env_data = dict(expected_pairs + unexpected_pairs) pairs = envs.map_fields_to_env_data(self.get_meta(env_data), env_data) # The expected fields are correctly returned. mapped_pairs = [ (field.name, value) for field, value in pairs[:len(expected_pairs)] ] self.assertEqual(expected_pairs, mapped_pairs) # Pairs also include the unexpected fields. unexpected_dict = dict(unexpected_pairs) remaining_pairs = pairs[len(expected_pairs):] self.assertEqual(len(unexpected_dict), len(remaining_pairs)) help = 'this field is unrecognized and can be safely removed' for field, value in remaining_pairs: self.assertIsInstance(field, fields.UnexpectedField) self.assertEqual(unexpected_dict[field.name], value, field.name) self.assertFalse(field.required, field.name) self.assertEqual(help, field.help, field.name) class ValidateNormalizeTestsMixin(object): """Shared utilities for tests exercising "validate" and "normalize".""" def setUp(self): # Set up metadata to work with. choices = ('trick', 'treat') self.env_metadata = { 'fields': ( fields.StringField('string-required', required=True), fields.StringField('string-default', default='boo!'), fields.IntField('int-optional'), fields.IntField('int-range', min_value=42, max_value=47), fields.BoolField('bool-true', default=True), fields.ChoiceField('choice-optional', choices=choices) ) } super(ValidateNormalizeTestsMixin, self).setUp() class TestValidate(ValidateNormalizeTestsMixin, unittest.TestCase): def test_valid(self): # An empty errors dict is returned if the env_data is valid. env_data = { 'string-required': 'a string', 'string-default': 'another string', 'int-optional': -42, 'int-range': 42, 'bool-true': False, 'choice-optional': 'treat', } self.assertEqual({}, envs.validate(self.env_metadata, env_data)) def test_valid_only_required(self): # To be valid, env_data must at least include the required values. env_data = {'string-required': 'a string'} validation_errors = envs.validate(self.env_metadata, env_data) # No validation errors were found. self.assertEqual(validation_errors, {}) def test_not_valid(self): # An errors dict is returned if the env_data is not valid. env_data = { 'string-required': ' ', 'string-default': 42, 'int-optional': 'not-an-int', 'int-range': 1000, 'bool-true': [], 'choice-optional': 'toy', } expected = { 'string-required': ( 'a value is required for the string-required field'), 'string-default': ( 'the string-default field requires a string value'), 'int-optional': 'the int-optional field requires an integer value', 'int-range': 'the int-range value must be in the 42-47 range', 'bool-true': 'the bool-true field requires a boolean value', 'choice-optional': ('the choice-optional requires the value to be ' 'one of the following: trick, treat'), } self.assertEqual(expected, envs.validate(self.env_metadata, env_data)) def test_required_field_not_found(self): # An error is returned if required fields are not included in env_data. expected = { 'string-required': ( 'a value is required for the string-required field'), } self.assertEqual(expected, envs.validate(self.env_metadata, {})) def test_optional_invalid_field(self): # Even if there is just one invalid field, and even if that field is # optional, the error is still reported in the errors dict. env_data = { 'string-required': 'a string', 'int-optional': False, } expected = { 'int-optional': 'the int-optional field requires an integer value', } self.assertEqual(expected, envs.validate(self.env_metadata, env_data)) class TestNormalize(ValidateNormalizeTestsMixin, unittest.TestCase): def test_normalized_data(self): # The given env_data is properly normalized. env_data = { 'string-required': ' a string\n', 'string-default': '\t another one', 'int-optional': '-42', 'int-range': 42.2, 'bool-true': False, 'choice-optional': ' trick ', } expected = { 'string-required': 'a string', 'string-default': 'another one', 'int-optional': -42, 'int-range': 42, 'bool-true': False, 'choice-optional': 'trick', } self.assertEqual(expected, envs.normalize(self.env_metadata, env_data)) def test_already_normalized(self): # The normalization process produces the same env_data if the input # data is already normalized. env_data = { 'string-required': 'a string', 'int-optional': 42, } normalized_data = envs.normalize(self.env_metadata, env_data) # The same data is returned. self.assertEqual(env_data, normalized_data) # However, the returned data is a different object. self.assertIsNot(env_data, normalized_data) def test_multiline_values_preserved(self): # The normalization process preserves multi-line values. env_data = {'string-required': 'first line\nsecond line'} normalized_data = envs.normalize(self.env_metadata, env_data) self.assertEqual(env_data, normalized_data) def test_exclude_fields(self): # The normalization process excludes fields if they are not required # and the corresponding values are not set or not changed. env_data = { # Since this field is required, it is included even if not set. 'string-required': '', # Even if this value is the default one, it is included because # it is explicitly set by the user. 'string-default': 'boo!', # Since this field has a value, it is included even if optional. 'int-optional': 42, # Since the value is unset and the field optional, it is excluded. 'int-range': None, # False is a valid set value for boolean fields. For this reason, # it is included. 'bool-true': False, # The choice optional field is not in the input data. For this # reason the field is excluded. } expected = { 'string-required': None, 'string-default': 'boo!', 'int-optional': 42, 'bool-true': False, } normalized_data = envs.normalize(self.env_metadata, env_data) self.assertEqual(expected, normalized_data) def test_exclude_unexpected_fields(self): # The normalization process excludes unexpected fields if the # corresponding values are not set. env_data = { # Since this field is required, it is included even if not set. 'string-required': 'a string', # Since the value is set, this value is preserved even if the # field is not included in metadata. 'unexpected1': 'boo!', # False is also considered a valid value for an unexpected field. 'unexpected2': False, # Unexpected fields whose value is empty or None are excluded. 'unexpected3': '', 'unexpected4': '\t\n ', 'unexpected5': None, } expected = { 'string-required': 'a string', 'unexpected1': 'boo!', 'unexpected2': False, } normalized_data = envs.normalize(self.env_metadata, env_data) self.assertEqual(expected, normalized_data) def test_original_not_mutated(self): # The original env_data is not modified in the process. env_data = { 'string-required': ' a string\n', 'string-default': None, 'bool-true': None, 'choice-optional': ' trick ', } original = env_data.copy() expected = { 'string-required': 'a string', 'choice-optional': 'trick', } normalized_data = envs.normalize(self.env_metadata, env_data) self.assertEqual(expected, normalized_data) self.assertEqual(original, env_data) class TestGetEnvShortDescription(unittest.TestCase): def test_env(self): # The env description includes the environment name and type. env_data = {'name': 'lxc', 'type': 'local', 'is-default': False} description = envs.get_env_short_description(env_data) self.assertEqual('lxc (type: local)', description) def test_default_env(self): # A default environment is properly described. env_data = {'name': 'lxc', 'type': 'local', 'is-default': True} description = envs.get_env_short_description(env_data) self.assertEqual('lxc (type: local, default)', description) def test_env_without_type(self): # Without the type we can only show the environment name. env_data = {'name': 'lxc', 'is-default': False} description = envs.get_env_short_description(env_data) self.assertEqual('lxc', description) def test_default_env_without_type(self): # This would be embarrassing. env_data = {'name': 'lxc', 'type': None, 'is-default': True} description = envs.get_env_short_description(env_data) self.assertEqual('lxc (default)', description) juju-quickstart-1.3.1/quickstart/tests/models/test_fields.py0000644000175000017500000004770512317464767026003 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart field definitions.""" from __future__ import unicode_literals from contextlib import contextmanager import unittest from quickstart.models import fields from quickstart.tests import helpers class FieldTestsMixin(object): """Define a collection of tests shared by all fields. Subclasses must define a field_class attribute. """ @contextmanager def assert_not_raises(self, exception, message=None): """Ensure the given exception is not raised in the code block.""" try: yield except exception as err: msg = b'unexpected {}: {}'.format(err.__class__.__name__, err) if message: msg += b' ({!r})'.format(message) self.fail(msg) def test_attributes(self): # The field attributes are properly stored in the field instance. field = self.field_class( 'first-name', label='first name', help='your first name', default='default', required=True, readonly=False) self.assertEqual('first-name', field.name) self.assertEqual('first name', field.label) self.assertEqual('your first name', field.help) self.assertEqual('default', field.default) self.assertTrue(field.required) self.assertFalse(field.readonly) def test_default_attributes(self): # Only the name identifier is required when instantiating a field. field = self.field_class('last-name') self.assertEqual('last-name', field.name) self.assertEqual('last-name', field.label) self.assertEqual('', field.help) self.assertIsNone(field.default) self.assertFalse(field.required) self.assertFalse(field.readonly) def test_field_representation(self): # A field object is properly represented. field = self.field_class('email') expected = b'<{}: email>'.format(self.field_class.__name__) self.assertEqual(expected, repr(field)) def test_display(self): # A field is able to display values. field = self.field_class('phone-number') for value in (None, 42, True, 'a unicode string'): self.assertEqual(unicode(value), field.display(value), value) class TestField( FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): field_class = fields.Field def test_normalization(self): # The base field normalization is a no-op if the value is set. field = self.field_class('email') for value in (None, 42, True, 'a unicode string'): self.assertEqual(value, field.normalize(value), value) def test_validation_success(self): # The validation succeeds if the value is set. field = self.field_class('email') for value in (42, True, 'a unicode string', ' '): self.assertIsNone(field.validate(value), value) def test_validation_not_required(self): # If the field is not required, no errors are raised. field = self.field_class('email', required=False) for value in ('', False, None): with self.assert_not_raises(ValueError, value): field.validate(value) def test_validation_error_required(self): # A ValueError is raised by required fields if the value is not set. field = self.field_class('email', label='email address', required=True) expected = 'a value is required for the email address field' with self.assert_value_error(expected): field.validate(None) def test_validation_with_default(self): # The validation succeeds if the value is unset but a default one is # available. field = self.field_class('answer', default=42, required=True) with self.assert_not_raises(ValueError): field.validate(None) class TestStringField( FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): field_class = fields.StringField def test_normalization(self): # The string field normalization returns the stripped string value. field = self.field_class('email') for value in ('a value', '\t tabs and spaces ', 'newlines\n\n'): self.assertEqual(value.strip(), field.normalize(value), value) def test_none_normalization(self): # The string field normalization returns None if the value is not set. field = self.field_class('email') for value in ('', ' ', '\n', ' \t ', None): self.assertIsNone(field.normalize(value), value) def test_validation_success(self): # The validation succeeds if the value is set. field = self.field_class('email') for value in ('a value', '\t tabs and spaces ', 'newlines\n\n'): self.assertIsNone(field.validate(value), value) def test_validation_not_required(self): # If the field is not required, no errors are raised. field = self.field_class('email', required=False) for value in ('', None, ' ', '\t\n'): with self.assert_not_raises(ValueError, value): field.validate(value) def test_validation_error_required(self): # A ValueError is raised by required fields if the value is not set. field = self.field_class('email', label='email address', required=True) expected = 'a value is required for the email address field' for value in ('', None, ' ', '\t\n'): with self.assert_value_error(expected): field.validate(value) def test_validation_error_not_a_string(self): # A ValueError is raised by string fields if the value is not a string. field = self.field_class('email', label='email address') expected = 'the email address field requires a string value' for value in (42, False, []): with self.assert_value_error(expected): field.validate(value) def test_validation_with_default(self): # The validation succeeds if the value is unset but a default one is # available. field = self.field_class( 'email', default='email@example.com', required=True) with self.assert_not_raises(ValueError): field.validate(None) class TestIntField( FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): field_class = fields.IntField def test_normalization(self): # The int field normalization returns the values as integers. field = self.field_class('tcp-port') for value in (42, 42.0, '42', '\t42 ', '42\n\n'): self.assertEqual(42, field.normalize(value), value) def test_none_normalization(self): # The int field normalization returns None if the value is not set. field = self.field_class('tcp-port') for value in (None, '', ' ', '\t\n'): self.assertIsNone(field.normalize(value), value) def test_zero_normalization(self): # The zero value is not considered unset. field = self.field_class('tcp-port') self.assertEqual(0, field.normalize(0)) def test_validation_success(self): # The value as an integer number is returned if the value is valid. field = self.field_class('tcp-port') for value in (42, 42.0, '42', '\t42 ', '42\n\n'): with self.assert_not_raises(ValueError, value): field.validate(value) def test_validation_success_zero(self): # The zero value is not considered "unset". field = self.field_class('tcp-port') with self.assert_not_raises(ValueError): field.validate(0) def test_validation_success_in_range(self): # The value as an integer number is returned if the value is valid and # is in the specified range of min/max values. field = self.field_class('tcp-port', min_value=42, max_value=47) for value in (42, 42.0, '42', '\t42 ', '42\n\n'): with self.assert_not_raises(ValueError, value): field.validate(value) def test_validation_not_required(self): # If the field is not required, no errors are raised. field = self.field_class('tcp-port', required=False) for value in ('', None, ' ', '\t\n'): with self.assert_not_raises(ValueError, value): self.assertIsNone(field.validate(value), value) def test_validation_error_required(self): # A ValueError is raised by required fields if the value is not set. field = self.field_class('tcp-port', label='TCP port', required=True) expected = 'a value is required for the TCP port field' for value in ('', None, ' ', '\t\n'): with self.assert_value_error(expected): field.validate(value) def test_validation_error_not_a_number(self): # A ValueError is raised by int fields if the value is not a number. field = self.field_class('tcp-port', label='TCP port') expected = 'the TCP port field requires an integer value' for value in ('a string', False, {}, []): with self.assert_value_error(expected): field.validate(value) def test_validation_error_min_value(self): # A ValueError is raised if value < min_value. field = self.field_class('tcp-port', min_value=42, label='TCP port') with self.assert_value_error('the TCP port value must be >= 42'): field.validate(27) def test_validation_error_max_value(self): # A ValueError is raised if value > max_value. field = self.field_class('tcp-port', max_value=42, label='TCP port') with self.assert_value_error('the TCP port value must be <= 42'): field.validate(47) def test_validation_error_range(self): # A ValueError is raised if not min_value <= value <= max_value. field = self.field_class( 'tcp-port', min_value=42, max_value=47, label='TCP port') expected = 'the TCP port value must be in the 42-47 range' with self.assert_value_error(expected): field.validate(27) def test_validation_with_default(self): # The validation succeeds if the value is unset but a default one is # available. field = self.field_class('tcp-port', default=8888, required=True) with self.assert_not_raises(ValueError): field.validate(None) class TestBoolField( FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): field_class = fields.BoolField def test_default_attributes(self): # Only the name identifier is required when instantiating a field. field = self.field_class('is-public') self.assertEqual('is-public', field.name) self.assertEqual('is-public', field.label) self.assertEqual('', field.help) self.assertFalse(field.default) self.assertFalse(field.required) self.assertFalse(field.readonly) def test_normalization(self): # The bool field normalization returns the value itself. field = self.field_class('is-public') self.assertTrue(field.normalize(True)) self.assertFalse(field.normalize(False)) def test_none_normalization(self): # The string field normalization returns None if the value is not set. field = self.field_class('is-public') self.assertIsNone(field.normalize(None)) def test_validation_success(self): # The validation succeeds if the value is boolean. field = self.field_class('is-public') with self.assert_not_raises(ValueError): field.validate(True) field.validate(False) def test_validation_error_not_a_boolean(self): # A ValueError is raised by string fields if the value is not a bool. field = self.field_class('is-public', label='is public') expected = 'the is public field requires a boolean value' for value in (42, 'a string', []): with self.assert_value_error(expected): field.validate(value) def test_validation_not_required(self): # If the field is not required, no errors are raised. field = self.field_class('is-public', required=False) with self.assert_not_raises(ValueError): field.validate(None) def test_validation_error_required(self): # If the value is not set, the default value will be used. field = self.field_class( 'is-public', label='is public', default=False, required=True) with self.assert_not_raises(ValueError): field.validate(None) def test_validation_allow_mixed(self): # The validation succeed with a None value if the field allows mixed # state: True/False or None (unset). field = self.field_class('is-public', allow_mixed=True) with self.assert_not_raises(ValueError): field.validate(None) def test_validation_no_mixed_state(self): # The boolean field cannot be unset if mixed state is not allowed. field = self.field_class( 'is-public', label='is public', allow_mixed=False) expected = 'the is public field requires a boolean value' with self.assert_value_error(expected): field.validate(None) class TestUnexpectedField(FieldTestsMixin, unittest.TestCase): field_class = fields.UnexpectedField def assert_normalized(self, normalized_value, input_value): """Ensure the expected normalized value is returned.""" field = self.field_class('last-name') self.assertEqual(normalized_value, field.normalize(input_value)) def test_attributes(self): # The field attributes are properly stored in the field instance. field = self.field_class( 'first-name', label='first name', help='your first name') self.assertEqual('first-name', field.name) self.assertEqual('first name', field.label) self.assertEqual('your first name', field.help) def test_default_attributes(self): # Only the name identifier is required when instantiating a field. field = self.field_class('last-name') self.assertEqual('last-name', field.name) self.assertEqual('last-name', field.label) self.assertEqual( 'this field is unrecognized and can be safely removed', field.help) self.assertIsNone(field.default) self.assertFalse(field.required) self.assertFalse(field.readonly) def test_normalize_boolean(self): # The normalized boolean value is the value itself. self.assert_normalized(True, True) self.assert_normalized(False, False) def test_normalize_integer(self): # The normalized numeric value is the number itself. self.assert_normalized(42, 42) def test_normalize_none(self): # None normalization returns None. self.assert_normalized(None, None) def test_normalize_string_guess(self): # A string value is converted to the underlying type if possible. self.assert_normalized(42, '42') self.assert_normalized(47, ' 47 ') self.assert_normalized(True, 'true') self.assert_normalized(False, '\tFALSE\n') def test_normalize_string(self): # The normalization process returns the stripped value if the value is # a string. self.assert_normalized('a string', 'a string') self.assert_normalized('stripped', '\n stripped\t') def test_normalize_empty_string(self): # An empty string is normalized to None. self.assert_normalized(None, '') self.assert_normalized(None, ' ') def test_normalize_unknown(self): # The normalization process converts to string unrecognized values. self.assert_normalized('42.47', 42.47) self.assert_normalized('{}', {}) def test_validate(self): # Unexpected values are always valid. field = self.field_class('last-name') for value in (None, 42, 42.47, True, False, 'a string'): with self.assert_not_raises(ValueError, value): field.validate(value) class TestAutoGeneratedStringField(TestStringField): field_class = fields.AutoGeneratedStringField def test_generate(self): # The autogenerated field can generate random values. field = self.field_class('auto') value1 = field.generate() value2 = field.generate() # The generated values are unicode strings. self.assertIsInstance(value1, unicode) self.assertIsInstance(value2, unicode) # The generated values are not empty. self.assertNotEqual(0, len(value1)) self.assertNotEqual(0, len(value2)) # The generated values are different to each other. self.assertNotEqual(value1, value2) class TestChoiceField(TestStringField): field_class = fields.ChoiceField choices = ('these', 'are', 'the', 'voyages') def test_validation_success(self): # No errors are raised if the value is included in the choices. field = self.field_class('word', choices=self.choices) for value in self.choices: with self.assert_not_raises(ValueError, value): field.validate(value) def test_validation_error_not_in_choices(self): # A ValueError is raised by choice fields if the value is not included # in the specified choices. field = self.field_class( 'word', choices=self.choices, label='selected word') expected = ('the selected word requires the value to be one of the ' 'following: these, are, the, voyages') with self.assert_value_error(expected): field.validate('resistance is futile') def test_validation_with_default(self): # The validation succeeds if the value is unset but a default one is # available. field = self.field_class( 'word', choices=self.choices, default='voyages', required=True) with self.assert_not_raises(ValueError): field.validate(None) class TestSuggestionsStringField(TestStringField): field_class = fields.SuggestionsStringField def test_suggestions(self): # Suggested values are properly stored as a field attribute. suggestions = ('these', 'are', 'the', 'voyages') field = self.field_class( 'word', suggestions=suggestions, label='selected word') self.assertEqual(suggestions, field.suggestions) class TestPasswordField(TestStringField): field_class = fields.PasswordField def test_display(self): # A placeholder value is displayed. field = self.field_class('passwd') for value in (42, True, 'a unicode string'): self.assertEqual('*****', field.display(value), value) def test_display_bytes(self): # A placeholder value is still displayed. snowman = b'Here is a snowman\xc2\xa1: \xe2\x98\x83' field = self.field_class('passwd') self.assertEqual('*****', field.display(snowman)) def test_display_no_values(self): # Do not display the placeholder if the value is not set. field = self.field_class('passwd') for value in (None, False, ''): self.assertEqual('None', field.display(value), value) class TestAutoGeneratedPasswordField( TestAutoGeneratedStringField, TestPasswordField): field_class = fields.AutoGeneratedPasswordField juju-quickstart-1.3.1/quickstart/tests/models/__init__.py0000644000175000017500000000145212251372515025204 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . juju-quickstart-1.3.1/quickstart/tests/test_ssh.py0000644000175000017500000001531612307347546024032 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart SSH management functions.""" from __future__ import unicode_literals import os import unittest import mock from quickstart import ssh from quickstart.tests import helpers class TestCheckKeys(helpers.CallTestsMixin, unittest.TestCase): def test_keys_and_agent(self): with self.patch_call(retcode=0) as mock_call: have_keys = ssh.check_keys() mock_call.assert_called_once_with('/usr/bin/ssh-add', '-l') self.assertTrue(have_keys) def test_agent_no_keys_success(self): side_effects = ( (1, 'The agent has no identities.\n', ''), (0, '', ''), ) with self.patch_multiple_calls(side_effects) as mock_call: have_keys = ssh.check_keys() mock_call.assert_has_calls([ mock.call('/usr/bin/ssh-add', '-l'), mock.call('/usr/bin/ssh-add'), ]) self.assertTrue(have_keys) def test_agent_no_keys_failure(self): side_effects = ( (1, 'The agent has no identities.\n', ''), (1, 'Still no identities...', ''), ) with self.patch_multiple_calls(side_effects) as mock_call: have_keys = ssh.check_keys() mock_call.assert_has_calls([ mock.call('/usr/bin/ssh-add', '-l'), mock.call('/usr/bin/ssh-add'), ]) self.assertFalse(have_keys) def test_agent_bad_keys(self): side_effects = ( (1, 'The agent has no identities.\n', ''), (2, '', 'Oh no!'), ) with self.assertRaises(OSError) as context_manager: with self.patch_multiple_calls(side_effects) as mock_call: ssh.check_keys() self.assertEqual( b'error attempting to add ssh keys: Oh no!', bytes(context_manager.exception)) mock_call.assert_has_calls([ mock.call('/usr/bin/ssh-add', '-l'), mock.call('/usr/bin/ssh-add'), ]) def test_no_agent(self): with self.patch_call(retcode=2) as mock_call: have_keys = ssh.check_keys() mock_call.assert_called_once_with('/usr/bin/ssh-add', '-l') self.assertFalse(have_keys) @helpers.mock_print class TestCreateKeys(helpers.CallTestsMixin, unittest.TestCase): def test_success(self, mock_print): key_file = os.path.join(os.path.expanduser('~'), '.ssh', 'id_rsa') with self.patch_call(retcode=0) as mock_call: ssh.create_keys() mock_call.assert_has_calls([ mock.call('/usr/bin/ssh-keygen', '-q', '-b', '4096', '-t', 'rsa', '-C', 'Generated with Juju Quickstart', '-f', key_file), mock.call('/usr/bin/ssh-add') ]) mock_print.assert_called_with( 'a new ssh key was generated in {}'.format(key_file)) def test_failure(self, mock_print): with self.assertRaises(OSError) as context_manager: with self.patch_call(retcode=1, error='Oh no!') as mock_call: ssh.create_keys() self.assertEqual( b'error generating ssh key: Oh no!', bytes(context_manager.exception)) self.assertTrue(mock_call.called) side_effects = ((0, '', ''), (1, '', 'Oh no!')) with self.assertRaises(OSError) as context_manager: with self.patch_multiple_calls(side_effects) as mock_call: ssh.create_keys() self.assertEqual( b'error adding key to agent: Oh no!', bytes(context_manager.exception)) self.assertTrue(mock_call.called) @helpers.mock_print class TestStartAgent(helpers.CallTestsMixin, unittest.TestCase): def test_success(self, mock_print): out = 'SSH_AUTH_SOCK=/tmp/ssh-authsock/agent.21000; ' \ 'export SSH_AUTH_SOCK;\n' \ 'SSH_AGENT_PID=21001; export SSH_AGENT_PID;\n' \ 'echo Agent pid 21001;' with self.patch_call(0, out, '') as mock_call: with mock.patch('os.putenv') as mock_putenv: ssh.start_agent() mock_call.assert_called_once_with('/usr/bin/ssh-agent') mock_putenv.assert_has_calls([ mock.call('SSH_AUTH_SOCK', '/tmp/ssh-authsock/agent.21000'), mock.call('SSH_AGENT_PID', '21001'), ]) mock_print.assert_called_once_with( 'ssh-agent has been started.\nTo interact with Juju or quickstart ' 'again after quickstart\nfinishes, please run the following in a ' 'terminal to start ssh-agent:\n eval `ssh-agent`\n') def test_failure(self, mock_print): with self.patch_call(1, 'Cannot start agent!', '') as mock_call: with self.assertRaises(OSError): ssh.start_agent() self.assertTrue(mock_call.called) @mock.patch('time.sleep') @helpers.mock_print class TestWatchForKeys(helpers.CallTestsMixin, unittest.TestCase): print_message_call = mock.call( 'Please run this command in another terminal or window and follow\n' 'the instructions it produces; quickstart will continue when keys\n' 'are generated, or ^C to quit.\n\n ssh-keygen -b 4096 -t rsa\n\n' 'Waiting...' ) def test_watch(self, mock_print, mock_sleep): with mock.patch('quickstart.ssh.check_keys', mock.Mock(side_effect=(False, True))): ssh.watch_for_keys() mock_print.assert_has_calls([ self.print_message_call, mock.call('.', end='')]) mock_sleep.assert_called_once_with(3) def test_cancel(self, mock_print, mock_sleep): with mock.patch('quickstart.ssh.check_keys', mock.Mock(side_effect=KeyboardInterrupt)): with mock.patch('sys.exit') as mock_exit: ssh.watch_for_keys() mock_print.assert_has_calls([self.print_message_call]) mock_exit.assert_called_once_with('\nquitting') juju-quickstart-1.3.1/quickstart/tests/test_juju.py0000644000175000017500000003352112310052400024160 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart API client.""" from __future__ import unicode_literals import unittest import mock import websocket from quickstart import juju from quickstart.tests import helpers patch_rpc = mock.patch('quickstart.juju.Environment._rpc') class TestConnect(unittest.TestCase): api_url = 'wss://api.example.com:17070' @mock.patch('quickstart.juju.WebSocketConnection') def test_environment_connection(self, mock_conn): # A connected Environment instance is correctly returned. env = juju.connect(self.api_url) mock_conn.assert_called_once_with() conn = mock_conn() conn.assert_has_calls([ mock.call.settimeout(websocket.default_timeout), mock.call.connect(self.api_url, origin=self.api_url) ]) self.assertIsInstance(env, juju.Environment) self.assertEqual(self.api_url, env.endpoint) self.assertEqual(conn, env.conn) class TestEnvironment(unittest.TestCase): # Note that in some of the tests below, rather than exercising quickstart # code, we are actually testing the external jujuclient methods. This is so # by design, and will help us when upgrading the python-jujuclient library. api_url = 'wss://api.example.com:17070' charm_url = 'cs:precise/juju-gui-77' service_name = 'juju-gui' def setUp(self): # Set up an Environment instance. api_url = self.api_url with mock.patch('websocket.create_connection') as mock_connect: self.env = juju.Environment(api_url) mock_connect.assert_called_once_with(api_url, origin=api_url) # Keep track of watcher changes in the changesets list. self.changesets = [] def make_add_unit_request(self, **kwargs): """Create and return an "add unit" request. Use kwargs to add or override request parameters. """ params = { 'ServiceName': self.service_name, 'NumUnits': 1, } params.update(kwargs) return { 'Type': 'Client', 'Request': 'AddServiceUnits', 'Params': params, } def make_deploy_request(self, **kwargs): """Create and return a "deploy" request. Use kwargs to add or override request parameters. """ params = { 'ServiceName': self.service_name, 'CharmURL': self.charm_url, 'NumUnits': 1, 'Config': {}, 'Constraints': {}, 'ToMachineSpec': None, } params.update(kwargs) return { 'Type': 'Client', 'Request': 'ServiceDeploy', 'Params': params, } def patch_get_watcher(self, return_value): """Patch the Environment.get_watcher method. When the resulting mock is used as a context manager, the given return value is returned. """ get_watcher_path = 'quickstart.juju.Environment.get_watcher' mock_get_watcher = mock.MagicMock() mock_get_watcher().__enter__.return_value = iter(return_value) mock_get_watcher.reset_mock() return mock.patch(get_watcher_path, mock_get_watcher) def processor(self, changeset): self.changesets.append(changeset) return changeset @patch_rpc def test_add_unit(self, mock_rpc): # The AddServiceUnits API call is properly generated. self.env.add_unit(self.service_name) mock_rpc.assert_called_once_with(self.make_add_unit_request()) @patch_rpc def test_add_unit_to_machine(self, mock_rpc): # The AddServiceUnits API call is properly generated when deploying a # unit in a specific machine. self.env.add_unit(self.service_name, machine_spec='0') expected = self.make_add_unit_request(ToMachineSpec='0') mock_rpc.assert_called_once_with(expected) @patch_rpc def test_deploy(self, mock_rpc): # The deploy API call is properly generated. self.env.deploy(self.service_name, self.charm_url) mock_rpc.assert_called_once_with(self.make_deploy_request()) @patch_rpc def test_deploy_config(self, mock_rpc): # The deploy API call is properly generated when passing settings. self.env.deploy( self.service_name, self.charm_url, config={'key1': 'value1', 'key2': 42}) expected = self.make_deploy_request( Config={'key1': 'value1', 'key2': '42'}) mock_rpc.assert_called_once_with(expected) @patch_rpc def test_deploy_constraints(self, mock_rpc): # The deploy API call is properly generated when passing constraints. constraints = {'cpu-cores': 8, 'mem': 16} self.env.deploy( self.service_name, self.charm_url, constraints=constraints) expected = self.make_deploy_request(Constraints=constraints) mock_rpc.assert_called_once_with(expected) @patch_rpc def test_deploy_no_units(self, mock_rpc): # The deploy API call is properly generated when passing zero units. self.env.deploy(self.service_name, self.charm_url, num_units=0) expected = self.make_deploy_request(NumUnits=0) mock_rpc.assert_called_once_with(expected) @patch_rpc def test_deploy_bundle(self, mock_rpc): # The deploy bundle call is properly generated. self.env.deploy_bundle('name: contents') expected = { 'Type': 'Deployer', 'Request': 'Import', 'Params': {'YAML': 'name: contents'}, } mock_rpc.assert_called_once_with(expected) @patch_rpc def test_deploy_bundle_with_name(self, mock_rpc): # The deploy bundle call is properly generated when passing a name. self.env.deploy_bundle('name: contents', name='name') expected = { 'Type': 'Deployer', 'Request': 'Import', 'Params': {'Name': 'name', 'YAML': 'name: contents'}, } mock_rpc.assert_called_once_with(expected) @patch_rpc def test_deploy_bundle_with_bundle_id(self, mock_rpc): # The deploy bundle call is properly generated when passing a # bundle_id. self.env.deploy_bundle('name: contents', name='name', bundle_id='~celso/basquet/wiki') expected = { 'Type': 'Deployer', 'Request': 'Import', 'Params': {'Name': 'name', 'YAML': 'name: contents', 'BundleID': '~celso/basquet/wiki'}, } mock_rpc.assert_called_once_with(expected) @patch_rpc def test_expose(self, mock_rpc): # The expose API call is properly generated. self.env.expose(self.service_name) expected = { 'Type': 'Client', 'Request': 'ServiceExpose', 'Params': {'ServiceName': self.service_name}, } mock_rpc.assert_called_once_with(expected) @patch_rpc def test_get_watcher(self, mock_rpc): # Environment watching is correctly started. self.env.login('Secret!') # We are only interested in the calls from now on. mock_rpc.reset_mock() connect_path = 'quickstart.juju.WebSocketConnection.connect' watcher_rpc_path = 'quickstart.juju.jujuclient.Watcher._rpc' with mock.patch(connect_path) as mock_connect: with mock.patch(watcher_rpc_path) as mock_watcher_rpc: watcher = self.env.get_watcher() # The returned watcher is running. self.assertTrue(watcher.running) # The watcher uses our customized WebSocket connection with logging. self.assertIsInstance(watcher.conn, juju.WebSocketConnection) # A connection has been established with the API backend. mock_connect.assert_called_once_with(self.api_url, origin=self.api_url) # The connection used by the watcher is authenticated. expected = { 'Type': 'Admin', 'Request': 'Login', 'Params': {'AuthTag': 'user-admin', 'Password': 'Secret!'}, } mock_rpc.assert_called_once_with(expected) # The watcher sent the correct start request. mock_watcher_rpc.assert_called_with({ 'Type': 'Client', 'Request': 'WatchAll', 'Params': {}, }) def test_get_status(self): # The current status of the Juju environment is properly returned. changesets = [['change1', 'change2'], ['change3']] with self.patch_get_watcher(changesets) as mock_get_watcher: status = self.env.get_status() # The get_status call only waits for the first changeset. self.assertEqual(changesets[0], status) # The watcher is correctly closed. self.assertEqual(1, mock_get_watcher().__exit__.call_count) @patch_rpc def test_login(self, mock_rpc): # The login API call is properly generated. self.env.login('Secret!') expected = { 'Type': 'Admin', 'Request': 'Login', 'Params': {'AuthTag': 'user-admin', 'Password': 'Secret!'}, } mock_rpc.assert_called_once_with(expected) @patch_rpc def test_create_auth_token(self, mock_rpc): self.env.create_auth_token() expected = dict(Type='GUIToken', Request='Create') mock_rpc.assert_called_once_with(expected) def test_watch_changes(self): # It is possible to watch for changes using a processor callable. changesets = [['change1', 'change2'], ['change3']] with self.patch_get_watcher(changesets) as mock_get_watcher: watcher = self.env.watch_changes(self.processor) # The first set of changes is correctly returned. changeset = watcher.next() self.assertEqual(changesets[0], changeset) # The second set of changes is correctly returned. changeset = watcher.next() self.assertEqual(changesets[1], changeset) # All the changes have been processed. self.assertEqual(changesets, self.changesets) # Ensure the API has been used properly. mock_get_watcher().__enter__.assert_called_once_with() def test_watch_changes_map(self): # The processor callable can be used to modify changes. changeset1 = ['change1', 'change2'] changeset2 = ['change3'] with self.patch_get_watcher([changeset1, changeset2]): watcher = self.env.watch_changes(len) changesets = list(watcher) self.assertEqual([len(changeset1), len(changeset2)], changesets) def test_watch_changes_filter(self): # The processor callable can be used to filter changes. changeset1 = ['change1', 'change2'] changeset2 = ['change3'] processor = lambda changes: None if len(changes) == 1 else changes with self.patch_get_watcher([changeset1, changeset2]): watcher = self.env.watch_changes(processor) changesets = list(watcher) self.assertEqual([changeset1], changesets) def test_watch_closed(self): # A stop API call on the AllWatcher is performed when the watcher is # garbage collected. changeset = ['change1', 'change2'] with self.patch_get_watcher([changeset]) as mock_get_watcher: watcher = self.env.watch_changes(self.processor) # The first set of changes is correctly returned. watcher.next() del watcher # Ensure the API has been used properly. self.assertEqual(1, mock_get_watcher().__exit__.call_count) class TestWebSocketConnection(unittest.TestCase): snowman = 'Here is a snowman\u00a1: \u2603' def setUp(self): with mock.patch('socket.socket') as mock_socket: self.conn = juju.WebSocketConnection() # Patch the socket.send() function used by the send method. self.mock_send = mock_socket().send # The recv method calls the recv_data one. self.conn.recv_data = self.mock_recv = mock.Mock() def test_send(self): # Outgoing messages are properly logged. with helpers.assert_logs(['API message: --> my message'], 'debug'): self.conn.send('my message') self.assertTrue(self.mock_send.called) def test_send_unicode(self): # Outgoing unicode messages are properly logged. expected = 'API message: --> {}'.format(self.snowman) with helpers.assert_logs([expected], 'debug'): self.conn.send(self.snowman.encode('utf-8')) self.assertTrue(self.mock_send.called) def test_recv(self): # Incoming messages are properly logged. self.mock_recv.return_value = (42, 'my message') with helpers.assert_logs(['API message: <-- my message'], 'debug'): self.conn.recv() self.mock_recv.assert_called_once_with() def test_recv_unicode(self): # Incoming unicode messages are properly logged. self.mock_recv.return_value = (42, self.snowman.encode('utf-8')) expected = 'API message: <-- {}'.format(self.snowman) with helpers.assert_logs([expected], 'debug'): self.conn.recv() self.mock_recv.assert_called_once_with() juju-quickstart-1.3.1/quickstart/tests/test_app.py0000644000175000017500000017744212320523571024013 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013-2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart base application functions.""" from __future__ import unicode_literals from contextlib import contextmanager import json import unittest import jujuclient import mock import yaml from quickstart import ( app, settings, ) from quickstart.tests import helpers class TestProgramExit(unittest.TestCase): def test_string_representation(self): # The error is properly represented as a string. exception = app.ProgramExit('bad wolf') self.assertEqual('juju-quickstart: error: bad wolf', bytes(exception)) class ProgramExitTestsMixin(object): """Set up some base methods for testing functions raising ProgramExit.""" @contextmanager def assert_program_exit(self, error): """Ensure a ProgramExit is raised in the context block. Also check that the exception includes the expected error message. """ with self.assertRaises(app.ProgramExit) as context_manager: yield expected = 'juju-quickstart: error: {}'.format(error) self.assertEqual(expected, bytes(context_manager.exception)) def make_env_error(self, message): """Create and return a jujuclient.EnvError with the given message.""" return jujuclient.EnvError({'Error': message}) @helpers.mock_print class TestEnsureDependencies( helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase): add_repository = '/usr/bin/add-apt-repository' apt_get = '/usr/bin/apt-get' def call_ensure_dependencies(self, call_effects, distro_only=False): """Execute the quickstart.app.ensure_dependencies call. The call_effects argument is used to customize the values returned by quickstart.utils.call invocations. Return the mock call object and the ensure_dependencies return value. """ with self.patch_multiple_calls(call_effects) as mock_call: juju_version = app.ensure_dependencies(distro_only) return mock_call, juju_version def test_success_install(self, mock_print): # All the missing packages are installed from the PPA. side_effects = ( (127, '', 'no juju'), # Retrieve the Juju version. (0, 'saucy', ''), # Retrieve the Ubuntu release codename. (0, 'install add repo', ''), # Install add-apt-repository. (0, 'add repo', ''), # Add the juju stable repository. (0, 'update', ''), # Update the repository with new sources. (0, 'install', ''), # Install missing packages. (0, '1.18.0', ''), # Retrieve the version again. ) mock_call, juju_version = self.call_ensure_dependencies(side_effects) self.assertEqual(len(side_effects), mock_call.call_count) mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'version'), mock.call('lsb_release', '-cs'), mock.call('sudo', self.apt_get, 'install', '-y', 'software-properties-common'), mock.call('sudo', self.add_repository, '-y', 'ppa:juju/stable'), mock.call('sudo', self.apt_get, 'update'), mock.call('sudo', self.apt_get, 'install', '-y', 'juju-core', 'juju-local'), mock.call(settings.JUJU_CMD, 'version'), ]) mock_print.assert_has_calls([ mock.call('adding the ppa:juju/stable PPA repository'), mock.call('sudo privileges will be required for PPA installation'), mock.call('sudo privileges will be used for the installation of \n' 'the following packages: juju-core, juju-local\n' 'this can take a while...'), ]) self.assertEqual((1, 18, 0), juju_version) def test_distro_only_install(self, mock_print): # All the missing packages are installed from the distro repository. side_effects = ( (127, '', 'no juju'), # Retrieve the Juju version. (0, 'install', ''), # Install missing packages. (0, '1.17.42', ''), # Retrieve the version again. ) mock_call, juju_version = self.call_ensure_dependencies( side_effects, distro_only=True) self.assertEqual(len(side_effects), mock_call.call_count) mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'version'), mock.call('sudo', self.apt_get, 'install', '-y', 'juju-core', 'juju-local'), mock.call(settings.JUJU_CMD, 'version'), ]) mock_print.assert_called_once_with( 'sudo privileges will be used for the installation of \n' 'the following packages: juju-core, juju-local\n' 'this can take a while...') self.assertEqual((1, 17, 42), juju_version) def test_success_no_install(self, mock_print): # There is no need to install packages/PPAs if everything is already # set up. side_effects = ( (0, '1.16.2-amd64', ''), # Check the juju command. (0, '', ''), # Check the lxc-ls command. # The remaining call should be ignored. (1, '', 'not ignored'), ) mock_call, juju_version = self.call_ensure_dependencies(side_effects) self.assertEqual(2, mock_call.call_count) self.assertFalse(mock_print.called) self.assertEqual((1, 16, 2), juju_version) def test_success_partial_install(self, mock_print): # One missing installation is correctly handled. side_effects = ( (0, '1.16.42', ''), # Check the juju command. (127, '', 'no lxc'), # Check the lxc-ls command. (0, 'saucy', ''), # Retrieve the Ubuntu release codename. (0, 'install add repo', ''), # Install add-apt-repository. (0, 'add repo', ''), # Add the juju stable repository. (0, 'update', ''), # Update the repository with new sources. (0, 'install', ''), # Install missing packages. ) mock_call, juju_version = self.call_ensure_dependencies(side_effects) self.assertEqual(len(side_effects), mock_call.call_count) mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'version'), mock.call('/usr/bin/lxc-ls'), mock.call('lsb_release', '-cs'), mock.call('sudo', self.apt_get, 'install', '-y', 'software-properties-common'), mock.call('sudo', self.add_repository, '-y', 'ppa:juju/stable'), mock.call('sudo', self.apt_get, 'update'), mock.call('sudo', self.apt_get, 'install', '-y', 'juju-local'), ]) mock_print.assert_has_calls([ mock.call('adding the ppa:juju/stable PPA repository'), mock.call('sudo privileges will be required for PPA installation'), mock.call('sudo privileges will be used for the installation of \n' 'the following packages: juju-local\n' 'this can take a while...'), ]) self.assertEqual((1, 16, 42), juju_version) def test_distro_only_partial_install(self, mock_print): # One missing installation is correctly handled when using distro only # packages. side_effects = ( (0, '1.16.42', ''), # Check the juju command. (127, '', 'no lxc'), # Check the lxc-ls command. (0, 'install', ''), # Install missing packages. ) mock_call, juju_version = self.call_ensure_dependencies( side_effects, distro_only=True) self.assertEqual(len(side_effects), mock_call.call_count) mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'version'), mock.call('/usr/bin/lxc-ls'), mock.call('sudo', self.apt_get, 'install', '-y', 'juju-local'), ]) mock_print.assert_called_once_with( 'sudo privileges will be used for the installation of \n' 'the following packages: juju-local\n' 'this can take a while...') self.assertEqual((1, 16, 42), juju_version) def test_add_repository_failure(self, mock_print): # A ProgramExit is raised if the PPA is not successfully installed. side_effects = ( (127, '', 'no juju'), # Check the juju command. (0, 'saucy', ''), # Retrieve the Ubuntu release codename. (0, 'install add repo', ''), # Install add-apt-repository. (1, '', 'add repo error'), # Add the juju stable repository. ) with self.assert_program_exit('add repo error'): mock_call = self.call_ensure_dependencies(side_effects)[0] self.assertEqual(3, mock_call.call_count) def test_install_failure(self, mock_print): # A ProgramExit is raised if the packages installation fails. side_effects = ( (127, '', 'no juju'), # Check the juju command. (0, 'saucy', ''), # Retrieve the Ubuntu release codename. (0, 'install add repo', ''), # Install add-apt-repository. (0, 'add repo', ''), # Add the juju stable repository. (0, 'update', ''), # Update the repository with new sources. (1, '', 'install error'), # Install missing packages. ) with self.assert_program_exit('install error'): mock_call = self.call_ensure_dependencies(side_effects)[0] self.assertEqual(3, mock_call.call_count) def test_juju_version_failure(self, mock_print): # A ProgramExit is raised if an error occurs while retrieving the Juju # version after the packages installation. side_effects = ( (127, '', 'no juju'), # Check the juju command. (0, 'saucy', ''), # Retrieve the Ubuntu release codename. (0, 'install add repo', ''), # Install add-apt-repository. (0, 'add repo', ''), # Add the juju stable repository. (0, 'update', ''), # Update the repository with new sources. (0, 'install', ''), # Install missing packages. (127, '', 'no juju (again)'), # Retrieve the Juju version. ) with self.assert_program_exit('no juju (again)'): mock_call = self.call_ensure_dependencies(side_effects)[0] self.assertEqual(3, mock_call.call_count) @helpers.mock_print class TestEnsureSSHKeys( helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase): print_msg = ( 'Warning: no SSH keys were found in ~/.ssh\nTo proceed and generate ' 'keys, quickstart can\n[a] automatically create keys for you\n[m] ' 'provide commands to manually create your keys\n\nNote: ssh-keygen ' 'will prompt you for an optional\npassphrase to generate your key for ' 'you.\nQuickstart does not store it.\n' ) exit_msg = ( '\nIf you would like to create the keys yourself,\nplease run this ' 'command, follow its instructions,\nand then re-run quickstart:\n ' 'ssh-keygen -b 4096 -t rsa' ) def patch_raw_input(self, return_value='C', side_effect=None): """Patch the builtin raw_input function.""" mock_raw_input = mock.Mock( return_value=return_value, side_effect=side_effect) return mock.patch('__builtin__.raw_input', mock_raw_input) def patch_check_keys(self, return_value=False): """Patch the quickstart.ssh.check_keys function.""" mock_check_keys = mock.Mock(return_value=return_value) return mock.patch('quickstart.ssh.check_keys', mock_check_keys) def patch_start_agent(self, return_value=False, side_effect=None): """Patch the quickstart.ssh.start_agent function.""" mock_start_agent = mock.Mock( return_value=return_value, side_effect=side_effect) return mock.patch('quickstart.ssh.start_agent', mock_start_agent) def patch_create_keys(self, return_value=False, side_effect=None): """Patch the quickstart.ssh.create_keys function.""" mock_create_keys = mock.Mock( return_value=return_value, side_effect=side_effect) return mock.patch('quickstart.ssh.create_keys', mock_create_keys) def test_success(self, mock_print): # The function returns immediately if SSH keys are found. with self.patch_check_keys(return_value=True) as mock_check: with self.patch_start_agent(return_value=True): app.ensure_ssh_keys() self.assertEqual(1, mock_check.call_count) def test_error_starting_agent(self, mock_print): # The program is stopped if the SSH agent cannot be started. with self.assert_program_exit('foo'): with self.patch_check_keys(): with self.patch_start_agent(side_effect=OSError('foo')): app.ensure_ssh_keys() def test_extant_agent_returns(self, mock_print): # The SSH agent is not started if the keys are already available. with self.patch_check_keys(True): with self.patch_start_agent(side_effect=OSError('foo')) as mock_sa: app.ensure_ssh_keys() self.assertFalse(mock_sa.called) def test_successful_agent_start(self, mock_print): # The function returns if the agent is successfully started and keys # are available. mock_check_keys = mock.Mock(side_effect=(False, True)) with mock.patch('quickstart.ssh.check_keys', mock_check_keys): with self.patch_start_agent(return_value=True): app.ensure_ssh_keys() self.assertFalse(mock_print.called) self.assertEqual(2, mock_check_keys.call_count) def test_failure_no_keygen(self, mock_print): # The program is stopped if the user disallows generating SSH keys. with mock.patch('sys.exit') as mock_exit: with self.patch_check_keys() as mock_check: with self.patch_start_agent(return_value=True): with self.patch_raw_input() as mock_raw_input: app.ensure_ssh_keys() self.assertTrue(mock_check.called) mock_print.assert_has_calls([mock.call(self.print_msg)]) self.assertTrue(mock_raw_input.called) mock_exit.assert_called_once_with(self.exit_msg) def test_failure_no_keygen_interrupt(self, mock_print): # The program is stopped if the user sends a SIGTERM. with mock.patch('sys.exit') as mock_exit: with self.patch_check_keys() as mock_check: with self.patch_start_agent(return_value=True): with self.patch_raw_input(side_effect=KeyboardInterrupt) \ as mock_raw_input: app.ensure_ssh_keys() self.assertTrue(mock_check.called) mock_print.assert_has_calls([mock.call(self.print_msg)]) self.assertTrue(mock_raw_input.called) mock_exit.assert_called_once_with(self.exit_msg) def test_keygen(self, mock_print): # Keys are automatically created on user request. with self.patch_check_keys() as mock_check: with self.patch_start_agent(return_value=True): with self.patch_raw_input(return_value='A') as mock_raw_input: with self.patch_create_keys() as mock_create_keys: app.ensure_ssh_keys() self.assertTrue(mock_check.called) mock_print.assert_has_calls([mock.call(self.print_msg)]) self.assertTrue(mock_raw_input.called) self.assertTrue(mock_create_keys.called) def test_watch(self, mock_print): # The function waits for the user to generate SSH keys. with self.patch_check_keys() as mock_check: with self.patch_start_agent(return_value=True): with self.patch_raw_input(return_value='M') as mock_raw_input: with mock.patch('quickstart.ssh.watch_for_keys') \ as mock_watch_for_keys: app.ensure_ssh_keys() self.assertTrue(mock_check.called) mock_print.assert_has_calls([mock.call(self.print_msg)]) self.assertTrue(mock_raw_input.called) self.assertTrue(mock_watch_for_keys.called) def test_creation_error(self, mock_print): # Keys are automatically created on user request. error = OSError('bad wolf') with self.assert_program_exit('bad wolf'): with self.patch_check_keys(): with self.patch_start_agent(return_value=True): with self.patch_raw_input(return_value='A'): with self.patch_create_keys(side_effect=error) \ as mock_create_keys: app.ensure_ssh_keys() self.assertTrue(mock_create_keys.called) @helpers.mock_print class TestBootstrap( helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase): env_name = 'my-juju-env' status_message = 'retrieving the environment status' def make_status_output(self, agent_state, series='hoary'): """Create and return a YAML status output.""" return yaml.safe_dump({ 'machines': {'0': {'agent-state': agent_state, 'series': series}}, }) def make_status_calls(self, number): """Return a list containing the given number of status calls.""" call = mock.call( settings.JUJU_CMD, 'status', '-e', self.env_name, '--format', 'yaml') return [call for _ in range(number)] def make_side_effects(self): """Return the minimum number of side effects for a successful call.""" return [ (0, '', ''), # Add a bootstrap call. (0, self.make_status_output('started'), ''), # Add a status call. ] def assert_status_retried(self, side_effects): """Ensure the "juju status" command is retried several times. Receive the list of side effects the mock status call will return. """ with self.patch_multiple_calls(side_effects) as mock_call: app.bootstrap(self.env_name) mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'bootstrap', '-e', self.env_name), ] + self.make_status_calls(5)) def test_success(self, mock_print): # The environment is successfully bootstrapped. with self.patch_multiple_calls(self.make_side_effects()) as mock_call: already_bootstrapped, series = app.bootstrap(self.env_name) self.assertFalse(already_bootstrapped) self.assertEqual(series, 'hoary') mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'bootstrap', '-e', self.env_name), ] + self.make_status_calls(1)) mock_print.assert_called_once_with(self.status_message) def test_success_local_provider(self, mock_print): # The environment is bootstrapped with sudo using the local provider. with self.patch_multiple_calls(self.make_side_effects()) as mock_call: already_bootstrapped, series = app.bootstrap( self.env_name, requires_sudo=True) self.assertFalse(already_bootstrapped) self.assertEqual(series, 'hoary') mock_call.assert_has_calls([ mock.call( 'sudo', settings.JUJU_CMD, 'bootstrap', '-e', self.env_name), ] + self.make_status_calls(1)) mock_print.assert_called_once_with(self.status_message) def test_success_debug(self, mock_print): # The environment is successfully bootstrapped in debug mode. with self.patch_multiple_calls(self.make_side_effects()) as mock_call: already_bootstrapped, series = app.bootstrap( self.env_name, debug=True) self.assertFalse(already_bootstrapped) self.assertEqual(series, 'hoary') mock_call.assert_has_calls([ mock.call( settings.JUJU_CMD, 'bootstrap', '-e', self.env_name, '--debug'), ] + self.make_status_calls(1)) def test_already_bootstrapped(self, mock_print): # The function succeeds and returns True if the environment is already # bootstrapped. side_effects = [ (1, '', '***environment is already bootstrapped**'), (0, self.make_status_output('started', 'precise'), ''), ] with self.patch_multiple_calls(side_effects) as mock_call: already_bootstrapped, series = app.bootstrap(self.env_name) self.assertTrue(already_bootstrapped) self.assertEqual(series, 'precise') mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'bootstrap', '-e', self.env_name), ] + self.make_status_calls(1)) existing_message = 'reusing the already bootstrapped {} environment' mock_print.assert_has_calls([ mock.call(existing_message.format(self.env_name)), mock.call(self.status_message), ]) def test_bootstrap_failure(self, mock_print): # A ProgramExit is raised if an error occurs while bootstrapping. with self.patch_call(retcode=1, error='bad wolf') as mock_call: with self.assert_program_exit('bad wolf'): app.bootstrap(self.env_name) mock_call.assert_called_once_with( settings.JUJU_CMD, 'bootstrap', '-e', self.env_name), def test_status_retry_error(self, mock_print): # Before raising a ProgramExit, the functions tries to call # "juju status" multiple times if it exits with an error. side_effects = [ (0, '', ''), # Add the bootstrap call. # Add four status calls with a non-zero exit code. (1, '', 'these'), (2, '', 'are'), (3, '', 'the'), (4, '', 'voyages'), # Add a final valid status call. (0, self.make_status_output('started'), ''), ] self.assert_status_retried(side_effects) def test_status_retry_invalid_output(self, mock_print): # Before raising a ProgramExit, the functions tries to call # "juju status" multiple times if its output is not well formed or if # the agent is not started. side_effects = [ (0, '', ''), # Add the bootstrap call. (0, '', ''), # Add the first status call: no output. (0, ':', ''), # Add the second status call: not YAML. (0, 'just-a-string', ''), # Add the third status call: bad YAML. # Add the fourth status call: the agent is still pending. (0, self.make_status_output('pending'), ''), # Add a final valid status call. (0, self.make_status_output('started'), ''), ] self.assert_status_retried(side_effects) def test_status_retry_both(self, mock_print): # Before raising a ProgramExit, the functions tries to call # "juju status" multiple times in any case. side_effects = [ (0, '', ''), # Add the bootstrap call. (1, '', 'error'), # Add the first status call: error. (2, '', 'another error'), # Add the second status call: error. # Add the third status call: the agent is still pending. (0, self.make_status_output('pending'), ''), (0, 'just-a-string', ''), # Add the fourth status call: bad YAML. # Add a final valid status call. (0, self.make_status_output('started'), ''), ] self.assert_status_retried(side_effects) def test_agent_error(self, mock_print): # A ProgramExit is raised immediately if the Juju agent in the # bootstrap node is in an error state. status_output = self.make_status_output('error') side_effects = [ (0, '', ''), # Add the bootstrap call. (0, status_output, ''), # Add the status call: agent error. ] expected = 'state server failure:\n{}'.format(status_output) with self.patch_multiple_calls(side_effects) as mock_call: with self.assert_program_exit(expected): app.bootstrap(self.env_name) mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'bootstrap', '-e', self.env_name), ] + self.make_status_calls(1)) def test_status_failure(self, mock_print): # A ProgramExit is raised if "juju status" keeps failing. call_side_effects = [ (0, '', ''), # Add the bootstrap call. (1, 'output1', 'error1'), # Add the first status call: retried. (1, 'output2', 'error2'), # Add the second status call: error. ] time_side_effects = [ 0, # Start at time zero (expiration at time 600). 10, # First call before the timeout expiration. 100, # Second call before the timeout expiration. 1000, # Third call after the timeout expiration. ] mock_time = mock.Mock(side_effect=time_side_effects) expected = 'the state server is not ready:\noutput2error2' with self.patch_multiple_calls(call_side_effects) as mock_call: # Simulate the timeout expired: the first time call is used to # calculate the timeout, the second one for the first status check, # the third for the second status check, the fourth should fail. with mock.patch('time.time', mock_time): with self.assert_program_exit(expected): app.bootstrap(self.env_name) mock_call.assert_has_calls([ mock.call(settings.JUJU_CMD, 'bootstrap', '-e', self.env_name), ] + self.make_status_calls(2)) class TestGetAdminSecret(unittest.TestCase): def test_no_admin_secret(self): with mock.patch('quickstart.manage.envs.load_generated', lambda x: {}): with self.assertRaises(ValueError) as exc: app.get_admin_secret('local', '/home/bac/.juju') expected = ( u'admin-secret not found in ' '/home/bac/.juju/environments/local.jenv') self.assertIn(expected, bytes(exc.exception)) def test_success(self): expected = 'superchunk' with mock.patch('quickstart.manage.envs.load_generated', lambda x: {'admin-secret': expected}): secret = app.get_admin_secret('local', '~bac/.juju') self.assertEqual(expected, secret) class TestGetApiUrl( helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase): env_name = 'ec2' def test_success(self): # The API URL is correctly returned. api_addresses = json.dumps(['api.example.com:17070', 'not-today']) with self.patch_call(retcode=0, output=api_addresses) as mock_call: api_url = app.get_api_url(self.env_name) self.assertEqual('wss://api.example.com:17070', api_url) mock_call.assert_called_once_with( settings.JUJU_CMD, 'api-endpoints', '-e', self.env_name, '--format', 'json') def test_failure(self): # A ProgramExit is raised if an error occurs retrieving the API URL. with self.patch_call(retcode=1, error='bad wolf') as mock_call: with self.assert_program_exit('bad wolf'): app.get_api_url(self.env_name) mock_call.assert_called_once_with( settings.JUJU_CMD, 'api-endpoints', '-e', self.env_name, '--format', 'json') class TestConnect(ProgramExitTestsMixin, unittest.TestCase): admin_secret = 'Secret!' api_url = 'wss://api.example.com:17070' def test_connection_established(self): # The connection is done and the Environment instance is returned. with mock.patch('quickstart.juju.connect') as mock_connect: env = app.connect(self.api_url, self.admin_secret) mock_connect.assert_called_once_with(self.api_url) mock_env = mock_connect() mock_env.login.assert_called_once_with(self.admin_secret) self.assertEqual(mock_env, env) @mock.patch('time.sleep') @mock.patch('logging.warn') def test_connection_error(self, mock_warn, mock_sleep): # if an error occurs in the connection, it retries and then raises. mock_connect = mock.Mock(side_effect=ValueError('bad wolf')) expected = 'unable to connect to the Juju API server on {}: bad wolf' with mock.patch('quickstart.juju.connect', mock_connect): with self.assert_program_exit(expected.format(self.api_url)): app.connect(self.api_url, self.admin_secret) mock_connect.assert_called_with(self.api_url) self.assertEqual(30, mock_connect.call_count) mock_sleep.assert_called_with(1) self.assertEqual(29, mock_sleep.call_count) self.assertEqual(29, mock_warn.call_count) mock_warn.assert_called_with( 'Retrying: ' + expected.format(self.api_url)) @mock.patch('time.sleep') @mock.patch('logging.warn') def test_connection_retry(self, mock_warn, mock_sleep): # if an error occurs in the connection, it can succeed after retrying. mock_env = mock.Mock() mock_connect = mock.Mock( side_effect=[ValueError('bad wolf'), mock_env]) with mock.patch('quickstart.juju.connect', mock_connect): env = app.connect(self.api_url, self.admin_secret) mock_connect.assert_called_with(self.api_url) self.assertEqual(2, mock_connect.call_count) mock_env.login.assert_called_once_with(self.admin_secret) self.assertEqual(mock_env, env) mock_sleep.assert_called_once_with(1) expected = 'unable to connect to the Juju API server on {}: bad wolf' mock_warn.assert_called_once_with( 'Retrying: ' + expected.format(self.api_url)) def test_authentication_error(self): # A ProgramExit is raised if an error occurs in the authentication. expected = 'unable to log in to the Juju API server on {}: bad wolf' with mock.patch('quickstart.juju.connect') as mock_connect: mock_login = mock_connect().login mock_login.side_effect = self.make_env_error('bad wolf') with self.assert_program_exit(expected.format(self.api_url)): app.connect(self.api_url, self.admin_secret) mock_connect.assert_called_with(self.api_url) mock_login.assert_called_once_with(self.admin_secret) def test_other_errors(self): # Any other errors occurred during the log in process are not trapped. error = ValueError('explode!') with mock.patch('quickstart.juju.connect') as mock_connect: mock_login = mock_connect().login mock_login.side_effect = error with self.assertRaises(ValueError) as context_manager: app.connect(self.api_url, self.admin_secret) self.assertIs(error, context_manager.exception) class TestCreateAuthToken(unittest.TestCase): def test_success(self): # A successful call returns a token. env = mock.Mock() token = 'TOKEN-STRING' env.create_auth_token.return_value = { 'Token': token, 'Created': '2013-11-21T12:34:46.778866Z', 'Expires': '2013-11-21T12:36:46.778866Z' } self.assertEqual(token, app.create_auth_token(env)) def test_legacy_failure(self): # A legacy charm call returns None. env = mock.Mock() error = jujuclient.EnvError( {'Error': 'unknown object type "GUIToken"'}) env.create_auth_token.side_effect = error self.assertIsNone(app.create_auth_token(env)) def test_other_errors(self): # Any other errors are not trapped. env = mock.Mock() error = jujuclient.EnvError({ 'Error': 'tokens can only be created by authenticated users.', 'ErrorCode': 'unauthorized access' }) env.create_auth_token.side_effect = error with self.assertRaises(jujuclient.EnvError) as context_manager: app.create_auth_token(env) self.assertIs(error, context_manager.exception) @helpers.mock_print class TestDeployGui( ProgramExitTestsMixin, helpers.WatcherDataTestsMixin, unittest.TestCase): charm_url = 'cs:precise/juju-gui-100' def make_env(self, unit_name=None, service_data=None, unit_data=None): """Create and return a mock environment object. Set up the object so that a call to add_unit returns the given unit_name, and a call to status returns a status object containing the service and unit described by the given service_data and unit_data. """ env = mock.Mock() # Set up the add_unit return value. if unit_name is not None: env.add_unit.return_value = {'Units': [unit_name]} # Set up the get_status return value. status = [] if service_data is not None: status.append(self.make_service_change(data=service_data)) if unit_data is not None: status.append(self.make_unit_change(data=unit_data)) env.get_status.return_value = status return env def patch_get_charm_url(self, side_effect=None): """Patch the get_charm_url helper function.""" if side_effect is None: side_effect = [self.charm_url] mock_get_charm_url = mock.Mock(side_effect=side_effect) return mock.patch('quickstart.utils.get_charm_url', mock_get_charm_url) def check_provided_charm_url( self, charm_url, mock_print, expected_logs=None): """Ensure the service is deployed and exposed with the given charm URL. Also check the expected warnings, if they are provided, are logged. """ env = self.make_env(unit_name='my-gui/42') with helpers.assert_logs(expected_logs or [], level='warn'): app.deploy_gui(env, 'my-gui', '0', charm_url=charm_url) env.assert_has_calls([ mock.call.deploy('my-gui', charm_url, num_units=0), mock.call.expose('my-gui'), mock.call.add_unit('my-gui', machine_spec='0'), ]) mock_print.assert_has_calls([ mock.call('requesting my-gui deployment'), mock.call('charm URL: {}'.format(charm_url)), ]) def check_existing_charm_url( self, charm_url, mock_print, expected_logs=None): """Ensure the service is correctly found with the given charm URL. Also check the expected warnings, if they are provided, are logged. """ service_data = {'CharmURL': charm_url} env = self.make_env(unit_name='my-gui/42', service_data=service_data) with helpers.assert_logs(expected_logs or [], level='warn'): app.deploy_gui(env, 'my-gui', '0', check_preexisting=True) env.assert_has_calls([ mock.call.get_status(), mock.call.add_unit('my-gui', machine_spec='0'), ]) mock_print.assert_has_calls([ mock.call('service my-gui already deployed'), mock.call('charm URL: {}'.format(charm_url)), ]) def test_deployment(self, mock_print): # The function correctly deploys and exposes the service, retrieving # the charm URL from the charmworld API. env = self.make_env(unit_name='my-gui/42') with self.patch_get_charm_url(): unit_name = app.deploy_gui(env, 'my-gui', '0') self.assertEqual('my-gui/42', unit_name) env.assert_has_calls([ mock.call.deploy('my-gui', self.charm_url, num_units=0), mock.call.expose('my-gui'), mock.call.add_unit('my-gui', machine_spec='0'), ]) # There is no need to call status if the environment was just created. self.assertFalse(env.get_status.called) mock_print.assert_has_calls([ mock.call('requesting my-gui deployment'), mock.call('charm URL: {}'.format(self.charm_url)), mock.call('my-gui deployment request accepted'), mock.call('exposing service my-gui'), mock.call('requesting new unit deployment'), mock.call('my-gui/42 deployment request accepted'), ]) def test_existing_environment_without_entities(self, mock_print): # The deployment is processed in an already bootstrapped environment # with no relevant entities in it. env = self.make_env(unit_name='my-gui/42') with self.patch_get_charm_url(): unit_name = app.deploy_gui( env, 'my-gui', '0', check_preexisting=True) self.assertEqual('my-gui/42', unit_name) env.assert_has_calls([ mock.call.get_status(), mock.call.deploy('my-gui', self.charm_url, num_units=0), mock.call.expose('my-gui'), mock.call.add_unit('my-gui', machine_spec='0'), ]) def test_default_charm_url(self, mock_print): # The function correctly deploys and exposes the service, even if it is # not able to retrieve the charm URL from the charmworld API. env = self.make_env(unit_name='my-gui/42') log = 'unable to retrieve the my-gui charm URL from the API: boo!' with self.patch_get_charm_url(side_effect=IOError('boo!')): # A warning is logged which notifies we are using the default URL. with helpers.assert_logs([log], level='warn'): app.deploy_gui(env, 'my-gui', '0') env.assert_has_calls([ mock.call.deploy( 'my-gui', settings.DEFAULT_CHARM_URL, num_units=0), mock.call.expose('my-gui'), mock.call.add_unit('my-gui', machine_spec='0'), ]) mock_print.assert_has_calls([ mock.call('requesting my-gui deployment'), mock.call('charm URL: {}'.format(settings.DEFAULT_CHARM_URL)), ]) def test_existing_service(self, mock_print): # The deployment is executed reusing an already deployed service. env = self.make_env(unit_name='my-gui/42', service_data={}) unit_name = app.deploy_gui( env, 'my-gui', '0', check_preexisting=True) self.assertEqual('my-gui/42', unit_name) env.assert_has_calls([ mock.call.get_status(), mock.call.add_unit('my-gui', machine_spec='0'), ]) # The service is not re-deployed. self.assertFalse(env.deploy.called) # The service is not re-exposed. self.assertFalse(env.expose.called) mock_print.assert_has_calls([ mock.call('service my-gui already deployed'), mock.call('charm URL: cs:precise/juju-gui-47'), mock.call('requesting new unit deployment'), mock.call('my-gui/42 deployment request accepted'), ]) def test_existing_service_unexposed(self, mock_print): # The existing service is exposed if required. service_data = {'Exposed': False} env = self.make_env(unit_name='my-gui/42', service_data=service_data) unit_name = app.deploy_gui( env, 'my-gui', '1', check_preexisting=True) self.assertEqual('my-gui/42', unit_name) env.assert_has_calls([ mock.call.get_status(), mock.call.expose('my-gui'), mock.call.add_unit('my-gui', machine_spec='1'), ]) # The service is not re-deployed. self.assertFalse(env.deploy.called) mock_print.assert_has_calls([ mock.call('service my-gui already deployed'), mock.call('charm URL: cs:precise/juju-gui-47'), mock.call('exposing service my-gui'), mock.call('requesting new unit deployment'), mock.call('my-gui/42 deployment request accepted'), ]) def test_existing_service_and_unit(self, mock_print): # A unit is reused if a suitable one is already present. env = self.make_env(service_data={}, unit_data={}) unit_name = app.deploy_gui( env, 'my-gui', '0', check_preexisting=True) self.assertEqual('my-gui/47', unit_name) env.get_status.assert_called_once_with() # The service is not re-deployed. self.assertFalse(env.deploy.called) # The service is not re-exposed. self.assertFalse(env.expose.called) # The unit is not re-added. self.assertFalse(env.add_unit.called) mock_print.assert_has_calls([ mock.call('service my-gui already deployed'), mock.call('charm URL: cs:precise/juju-gui-47'), mock.call('reusing unit my-gui/47'), ]) def test_new_machine(self, mock_print): # The unit is correctly deployed in a new machine. env = self.make_env(unit_name='my-gui/42') with self.patch_get_charm_url(): unit_name = app.deploy_gui(env, 'my-gui', None) self.assertEqual('my-gui/42', unit_name) env.assert_has_calls([ mock.call.deploy('my-gui', self.charm_url, num_units=0), mock.call.expose('my-gui'), mock.call.add_unit('my-gui', machine_spec=None), ]) def test_offical_charm_url_provided(self, mock_print): # The function correctly deploys and exposes the service using a user # provided revision of the Juju GUI charm URL. self.check_provided_charm_url('cs:precise/juju-gui-4242', mock_print) def test_customized_charm_url_provided(self, mock_print): # A customized charm URL is correctly recognized and logged if provided # by the user. self.check_provided_charm_url( 'cs:~juju-gui/precise/juju-gui-42', mock_print, expected_logs=['using a customized juju-gui charm']) def test_outdated_charm_url_provided(self, mock_print): # An outdated charm URL is correctly recognized and logged if provided # by the user. self.check_provided_charm_url( 'cs:precise/juju-gui-1', mock_print, expected_logs=[ 'charm is outdated and may not support bundle deployments']) def test_unexpected_charm_url_provided(self, mock_print): # An unexpected charm URL is correctly recognized and logged if # provided by the user. self.check_provided_charm_url( 'cs:precise/exterminate-the-gui-666', mock_print, expected_logs=[ 'unexpected URL for the juju-gui charm: ' 'the service may not work as expected']) def test_offical_charm_url_existing(self, mock_print): # An existing official charm URL is correctly found. self.check_existing_charm_url('cs:precise/juju-gui-4242', mock_print) def test_customized_charm_url_existing(self, mock_print): # An existing customized charm URL is correctly found and logged. self.check_existing_charm_url( 'cs:~juju-gui/precise/juju-gui-42', mock_print, expected_logs=['using a customized juju-gui charm']) def test_outdated_charm_url_existing(self, mock_print): # An existing but outdated charm URL is correctly found and logged. self.check_existing_charm_url( 'cs:precise/juju-gui-1', mock_print, expected_logs=[ 'charm is outdated and may not support bundle deployments']) def test_unexpected_charm_url_existing(self, mock_print): # An existing but unexpected charm URL is correctly found and logged. self.check_existing_charm_url( 'cs:precise/exterminate-the-gui-666', mock_print, expected_logs=[ 'unexpected URL for the juju-gui charm: ' 'the service may not work as expected']) def test_status_error(self, mock_print): # A ProgramExit is raised if an error occurs in the status API call. env = self.make_env() env.get_status.side_effect = self.make_env_error('bad wolf') with self.assert_program_exit('bad API response: bad wolf'): app.deploy_gui( env, 'another-gui', '0', check_preexisting=True) env.get_status.assert_called_once_with() def test_deploy_error(self, mock_print): # A ProgramExit is raised if an error occurs in the deploy API call. env = self.make_env() env.deploy.side_effect = self.make_env_error('bad wolf') with self.patch_get_charm_url(): with self.assert_program_exit('bad API response: bad wolf'): app.deploy_gui(env, 'another-gui', '0') env.deploy.assert_called_once_with( 'another-gui', self.charm_url, num_units=0) def test_expose_error(self, mock_print): # A ProgramExit is raised if an error occurs in the expose API call. env = self.make_env() env.expose.side_effect = self.make_env_error('bad wolf') with self.patch_get_charm_url(): with self.assert_program_exit('bad API response: bad wolf'): app.deploy_gui(env, 'another-gui', '0') env.expose.assert_called_once_with('another-gui') def test_add_unit_error(self, mock_print): # A ProgramExit is raised if an error occurs in the add_unit API call. env = self.make_env() env.add_unit.side_effect = self.make_env_error('bad wolf') with self.patch_get_charm_url(): with self.assert_program_exit('bad API response: bad wolf'): app.deploy_gui(env, 'another-gui', '0') env.add_unit.assert_called_once_with('another-gui', machine_spec='0') def test_other_errors(self, mock_print): # Any other errors occurred during the process are not trapped. error = ValueError('explode!') env = self.make_env(unit_name='my-gui/42') env.expose.side_effect = error with self.patch_get_charm_url(): with self.assertRaises(ValueError) as context_manager: app.deploy_gui(env, 'juju-gui', '0') env.deploy.assert_called_once_with( 'juju-gui', self.charm_url, num_units=0) env.expose.assert_called_once_with('juju-gui') self.assertIs(error, context_manager.exception) @helpers.mock_print class TestWatch( ProgramExitTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase): address = 'unit.example.com' machine_pending_call = mock.call('machine 0 provisioning is pending') unit_placed_machine_call = mock.call('unit placed on unit.example.com') machine_started_call = mock.call('machine 0 is started') unit_pending_call = mock.call('django/42 deployment is pending') unit_placed_unit_call = mock.call('django/42 placed on {}'.format(address)) unit_installed_call = mock.call('django/42 is installed') unit_started_call = mock.call('django/42 is ready on machine 0') def make_env(self, changes): """Create and return a patched Environment instance. The watch_changes method of the resulting Environment object returns the provided changes. """ env = mock.Mock() env.watch_changes().next.side_effect = changes return env def make_machine_change(self, status, name='0', address=None): """Create and return a machine change. If the address argument is None, the change does not include the corresponding address field. """ data = {'Id': name, 'Status': status} if address is not None: data['Addresses'] = [{ 'NetworkName': '', 'NetworkScope': 'public', 'Type': 'hostname', 'Value': address, }] return 'change', data def make_unit_change(self, status, name='django/42', address=None): """Create and return a unit change. If the address argument is None, the change does not include the corresponding address field. """ data = {'MachineId': '0', 'Name': name, 'Status': status} if address is not None: data['PublicAddress'] = address return 'change', data # The following group of tests exercises both the function return value and # the function output, even if the output is handled by sub-functions. # This is done to simulate the different user experiences of observing the # environment evolution while the unit is deploying. def test_unit_life(self, mock_print): # The glorious moments in the unit's life are properly highlighted. # The machine achievements are also celebrated. env = self.make_env([ ([self.make_unit_change('pending', address='')], [self.make_machine_change('pending')]), ([], [self.make_machine_change('started')]), ([self.make_unit_change('pending', address=self.address)], []), ([self.make_unit_change('installed', address=self.address)], []), ([self.make_unit_change('started', address=self.address)], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(6, mock_print.call_count) mock_print.assert_has_calls([ self.unit_pending_call, self.machine_pending_call, self.machine_started_call, self.unit_placed_unit_call, self.unit_installed_call, self.unit_started_call, ]) def test_unit_life_with_machine_address(self, mock_print): # The glorious moments in the unit's life are properly highlighted. # The machine achievements are also celebrated. # This time the new mega-watcher behavior is simulated, in which # addresses are included in the machine change. env = self.make_env([ ([self.make_unit_change('pending')], [self.make_machine_change('pending', address='')]), ([], [self.make_machine_change('started', address=self.address)]), ([self.make_unit_change('pending')], []), ([self.make_unit_change('installed')], []), ([self.make_unit_change('started')], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(6, mock_print.call_count) mock_print.assert_has_calls([ self.unit_pending_call, self.machine_pending_call, self.unit_placed_machine_call, self.machine_started_call, self.unit_installed_call, self.unit_started_call, ]) def test_weird_order(self, mock_print): # Strange unit evolutions are handled. env = self.make_env([ # The unit is first reachable and then pending. The machine starts # when the unit is already installed. All of this makes no sense # and should never happen, but if it does, we deal with it. ([self.make_unit_change('pending', address=self.address)], []), ([self.make_unit_change('pending', address='')], [self.make_machine_change('pending')]), ([self.make_unit_change('installed', address=self.address)], []), ([], [self.make_machine_change('started')]), ([self.make_unit_change('started', address=self.address)], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(6, mock_print.call_count) mock_print.assert_has_calls([ self.unit_placed_unit_call, self.unit_pending_call, self.machine_pending_call, self.unit_installed_call, self.machine_started_call, self.unit_started_call, ]) def test_missing_changes(self, mock_print): # Only the unit started change is strictly required when the unit # change includes the public address. env = self.make_env([ ([self.make_unit_change('started', address=self.address)], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(2, mock_print.call_count) mock_print.assert_has_calls([ self.unit_placed_unit_call, self.unit_started_call, ]) def test_missing_changes_with_machine_address(self, mock_print): # When using the new mega-watcher, a machine change including its # public address is also required. env = self.make_env([ ([self.make_unit_change('started')], []), ([], [self.make_machine_change('started', address=self.address)]), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(3, mock_print.call_count) mock_print.assert_has_calls([ self.unit_started_call, self.unit_placed_machine_call, self.machine_started_call, ]) def test_ignored_machine_changes(self, mock_print): # All machine changes are ignored until the application knows what # machine the unit belongs to. env = self.make_env([ ([], [self.make_machine_change('pending')]), ([], [self.make_machine_change('started')]), ([self.make_unit_change('started', address=self.address)], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) # No machine related messages have been printed. self.assertEqual(2, mock_print.call_count) mock_print.assert_has_calls([ self.unit_placed_unit_call, self.unit_started_call, ]) def test_ignored_machine_changes_with_machine_address(self, mock_print): # All machine changes are ignored until the application knows what # machine the unit belongs to. When the above happens, previously # collected machine changes are still parsed in the case the address # is not yet known. env = self.make_env([ ([], [self.make_machine_change('pending')]), ([], [self.make_machine_change('installed', address=self.address)]), ([], [self.make_machine_change('started', address=self.address)]), ([self.make_unit_change('started')], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) # No machine related messages have been printed. self.assertEqual(3, mock_print.call_count) mock_print.assert_has_calls([ self.unit_started_call, self.unit_placed_machine_call, self.machine_started_call, ]) def test_unit_already_deployed(self, mock_print): # Simulate the unit we are observing has been already deployed. # This happens, e.g., when executing Quickstart a second time, and both # the unit and the machine are already started. env = self.make_env([ ([self.make_unit_change('started', address=self.address)], [self.make_machine_change('started')]), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(2, mock_print.call_count) def test_unit_already_deployed_with_machine_address(self, mock_print): # Simulate the unit we are observing has been already deployed. # This happens, e.g., when executing Quickstart a second time, and both # the unit and the machine are already started. # This time the new mega-watcher behavior is simulated, in which # addresses are included in the machine change. env = self.make_env([ ([self.make_unit_change('started')], [self.make_machine_change('started', address=self.address)]), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(3, mock_print.call_count) mock_print.assert_has_calls([ self.unit_started_call, self.unit_placed_machine_call, self.machine_started_call, ]) def test_machine_already_started(self, mock_print): # Simulate the unit is being deployed on an already started machine. # This happens, e.g., when running Quickstart on a non-local # environment type: the unit is deployed on the bootstrap node, which # is assumed to be started. env = self.make_env([ ([self.make_unit_change('pending', address='')], [self.make_machine_change('started')]), ([self.make_unit_change('pending', address=self.address)], []), ([self.make_unit_change('installed', address=self.address)], []), ([self.make_unit_change('started', address=self.address)], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(5, mock_print.call_count) mock_print.assert_has_calls([ self.unit_pending_call, self.machine_started_call, self.unit_placed_unit_call, self.unit_installed_call, self.unit_started_call, ]) def test_machine_already_started_with_machine_address(self, mock_print): # Simulate the unit is being deployed on an already started machine. # This happens, e.g., when running Quickstart on a non-local # environment type: the unit is deployed on the bootstrap node, which # is assumed to be started. # This time the new mega-watcher behavior is simulated, in which # addresses are included in the machine change. env = self.make_env([ ([self.make_unit_change('pending')], [self.make_machine_change('started', address=self.address)]), ([self.make_unit_change('pending')], []), ([self.make_unit_change('installed')], []), ([self.make_unit_change('started')], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(5, mock_print.call_count) mock_print.assert_has_calls([ self.unit_pending_call, self.unit_placed_machine_call, self.machine_started_call, self.unit_installed_call, self.unit_started_call, ]) def test_extraneous_changes(self, mock_print): # Changes to units or machines we are not observing are ignored. Also # ensure that repeated changes to a single entity are ignored, even if # they are unlikely to happen. pending_unit_change = self.make_unit_change('pending', address='') started_unit_change = self.make_unit_change( 'started', address=self.address) env = self.make_env([ # Add a repeated change. ([pending_unit_change, pending_unit_change], [self.make_machine_change('pending')]), # Add extraneous unit and machine changes. ([self.make_unit_change('pending', name='haproxy/0')], [self.make_machine_change('pending', name='42')]), # Add a change to an extraneous machine. ([], [self.make_machine_change('started', name='42'), self.make_machine_change('started')]), # Add a change to an extraneous unit. ([self.make_unit_change('started', name='haproxy/0'), self.make_unit_change('pending', address=self.address)], []), ([self.make_unit_change('installed', address=self.address)], []), # Add another repeated change. ([started_unit_change, started_unit_change], []), ]) address = app.watch(env, 'django/42') self.assertEqual(self.address, address) self.assertEqual(6, mock_print.call_count) mock_print.assert_has_calls([ self.unit_pending_call, self.machine_pending_call, self.machine_started_call, self.unit_placed_unit_call, self.unit_installed_call, self.unit_started_call, ]) def test_api_error(self, mock_print): # A ProgramExit is raised if an error occurs in one of the API calls. env = self.make_env([ ([self.make_unit_change('pending', address='')], []), self.make_env_error('next returned an error'), ]) expected = 'bad API server response: next returned an error' with self.assert_program_exit(expected): app.watch(env, 'django/42') self.assertEqual(1, mock_print.call_count) mock_print.assert_has_calls([self.unit_pending_call]) def test_other_errors(self, mock_print): # Any other errors occurred during the process are not trapped. env = self.make_env([ ([self.make_unit_change('installed', address=self.address)], []), ValueError('explode!'), ]) with self.assert_value_error('explode!'): app.watch(env, 'django/42') self.assertEqual(2, mock_print.call_count) mock_print.assert_has_calls([ self.unit_placed_unit_call, self.unit_installed_call]) def test_machine_status_error(self, mock_print): # A ProgramExit is raised if an the machine is found in an error state. change_machine_error = ('change', { 'Id': '0', 'Status': 'error', 'StatusInfo': 'oddities', }) self.make_machine_change('error') # The unit pending change is required to make the function know which # machine to observe. env = self.make_env([ ([self.make_unit_change('pending', address='')], [change_machine_error]), ]) expected = 'machine 0 is in an error state: error: oddities' with self.assert_program_exit(expected): app.watch(env, 'django/42') self.assertEqual(1, mock_print.call_count) mock_print.assert_has_calls([self.unit_pending_call]) def test_unit_status_error(self, mock_print): # A ProgramExit is raised if an the unit is found in an error state. change_unit_error = ('change', { 'MachineId': '0', 'Name': 'django/42', 'Status': 'error', 'StatusInfo': 'install failure', }) env = self.make_env([([change_unit_error], [])]) expected = 'django/42 is in an error state: error: install failure' with self.assert_program_exit(expected): app.watch(env, 'django/42') self.assertFalse(mock_print.called) class TestDeployBundle(ProgramExitTestsMixin, unittest.TestCase): name = 'mybundle' yaml = 'mybundle: contents' bundle_id = '~fake/basket/bundle' def test_bundle_deployment(self): # A bundle is successfully deployed. env = mock.Mock() app.deploy_bundle(env, self.yaml, self.name, self.bundle_id) env.deploy_bundle.assert_called_once_with( self.yaml, name=self.name, bundle_id=self.bundle_id) self.assertFalse(env.close.called) def test_api_error(self): # A ProgramExit is raised if an error occurs in one of the API calls. env = mock.Mock() env.deploy_bundle.side_effect = self.make_env_error( 'bundle deployment failure') expected = 'bad API server response: bundle deployment failure' with self.assert_program_exit(expected): app.deploy_bundle(env, self.yaml, self.name, self.bundle_id) def test_other_errors(self): # Any other errors occurred during the process are not trapped. env = mock.Mock() error = ValueError('explode!') env.deploy_bundle.side_effect = error with self.assertRaises(ValueError) as context_manager: app.deploy_bundle(env, self.yaml, self.name, None) self.assertIs(error, context_manager.exception) juju-quickstart-1.3.1/quickstart/tests/test_watchers.py0000644000175000017500000004272012320523666025046 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Tests for the Juju Quickstart environments watching utilities.""" from __future__ import unicode_literals import unittest import mock from quickstart import watchers from quickstart.tests import helpers # Define addresses to be used in tests. cloud_addresses = [ {'NetworkName': '', 'NetworkScope': 'public', 'Type': 'hostname', 'Value': 'eu-west-1.compute.example.com'}, {'NetworkName': '', 'NetworkScope': 'local-cloud', 'Type': 'hostname', 'Value': 'eu-west-1.example.internal'}, {'NetworkName': '', 'NetworkScope': 'public', 'Type': 'ipv4', 'Value': '444.222.444.222'}, {'NetworkName': '', 'NetworkScope': 'local-cloud', 'Type': 'ipv4', 'Value': '10.42.47.10'}, {'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv6', 'Value': 'fe80::92b8:d0ff:fe94:8f8c'}, ] container_addresses = [ {'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv4', 'Value': '10.0.3.42'}, {'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv6', 'Value': 'fe80::216:3eff:fefd:787e'}, ] class TestRetrievePublicAddress(unittest.TestCase): def test_empty_addresses(self): # None is returned if there are no available addresses. self.assertIsNone(watchers.retrieve_public_adddress([])) def test_cloud_address_not_found(self): # None is returned if a cloud machine public address is not available. addresses = [ {'NetworkName': '', 'NetworkScope': 'local-cloud', 'Type': 'hostname', 'Value': 'eu-west-1.example.internal'}, {'NetworkName': '', 'NetworkScope': 'local-cloud', 'Type': 'ipv4', 'Value': '10.42.47.10'}, ] self.assertIsNone(watchers.retrieve_public_adddress(addresses)) def test_container_address_not_found(self): # None is returned if an LXC public address is not available. addresses = [{ 'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv6', 'Value': 'fe80::216:3eff:fefd:787e', }] self.assertIsNone(watchers.retrieve_public_adddress(addresses)) def test_empty_public_address(self): # None is returned if the public address has no value. addresses = [ {'NetworkName': '', 'NetworkScope': 'local-cloud', 'Type': 'hostname', 'Value': 'eu-west-1.example.internal'}, {'NetworkName': '', 'NetworkScope': 'public', 'Type': 'ipv4', 'Value': ''}, ] self.assertIsNone(watchers.retrieve_public_adddress(addresses)) def test_cloud_addresses(self): # The public address of a cloud machine is properly returned. public_address = watchers.retrieve_public_adddress(cloud_addresses) self.assertEqual('eu-west-1.compute.example.com', public_address) def test_container_addresses(self): # The public address of an LXC instance is properly returned. public_address = watchers.retrieve_public_adddress(container_addresses) self.assertEqual('10.0.3.42', public_address) def test_last_unknown_address(self): # If the scope of multiple addresses is unknown, the last one is taken. addresses = [ {'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv4', 'Value': '10.0.3.42'}, {'NetworkName': '', 'NetworkScope': '', 'Type': 'ipv4', 'Value': '10.0.3.47'}, ] public_address = watchers.retrieve_public_adddress(addresses) self.assertEqual('10.0.3.47', public_address) class TestParseMachineChange(helpers.ValueErrorTestsMixin, unittest.TestCase): def test_machine_removed(self): # A ValueError is raised if the change represents a machine removal. data = {'Addresses': [], 'Id': '1', 'Status': 'started'} with self.assert_value_error('machine 1 unexpectedly removed'): watchers.parse_machine_change('remove', data, '', '') def test_machine_error(self): # A ValueError is raised if the machine is in an error state. data = { 'Addresses': [], 'Id': '1', 'Status': 'error', 'StatusInfo': 'bad wolf', } expected_error = 'machine 1 is in an error state: error: bad wolf' with self.assert_value_error(expected_error): watchers.parse_machine_change('change', data, '', '') @helpers.mock_print def test_pending_status_notified(self, mock_print): # A message is printed to stdout when the machine changes its status # to "pending". The new status is also returned by the function. data = {'Addresses': [], 'Id': '1', 'Status': 'pending'} status, address = watchers.parse_machine_change('change', data, '', '') self.assertEqual('pending', status) self.assertEqual('', address) mock_print.assert_called_once_with('machine 1 provisioning is pending') @helpers.mock_print def test_started_status_notified(self, mock_print): # A message is printed to stdout when the machine changes its status # to "started". The new status is also returned by the function. data = {'Addresses': [], 'Id': '42', 'Status': 'started'} status, address = watchers.parse_machine_change( 'change', data, 'pending', '') self.assertEqual('started', status) self.assertEqual('', address) mock_print.assert_called_once_with('machine 42 is started') @helpers.mock_print def test_status_not_changed(self, mock_print): # If the status in the machine change and the given current status are # the same value, nothing is printed and the status is returned. data = {'Addresses': [], 'Id': '47', 'Status': 'pending'} status, address = watchers.parse_machine_change( 'change', data, 'pending', '') self.assertEqual('pending', status) self.assertEqual('', address) self.assertFalse(mock_print.called) @helpers.mock_print def test_address_notified(self, mock_print): # A message is printed to stdout when the machine obtains a public # address. data = {'Addresses': cloud_addresses, 'Id': '1', 'Status': 'pending'} status, address = watchers.parse_machine_change( 'change', data, 'pending', '') self.assertEqual('pending', status) self.assertEqual('eu-west-1.compute.example.com', address) mock_print.assert_called_once_with( 'unit placed on eu-west-1.compute.example.com') @helpers.mock_print def test_both_status_and_address_notified(self, mock_print): # Both status and public address changes are notified if required. data = { 'Addresses': container_addresses, 'Id': '0', 'Status': 'started', } status, address = watchers.parse_machine_change( 'change', data, 'pending', '') self.assertEqual('started', status) self.assertEqual('10.0.3.42', address) self.assertEqual(2, mock_print.call_count) mock_print.assert_has_calls([ mock.call('unit placed on 10.0.3.42'), mock.call('machine 0 is started'), ]) @helpers.mock_print def test_address_not_available(self, mock_print): # An empty address is returned when the addresses field is not # included in the change data. data = {'Id': '47', 'Status': 'pending'} status, address = watchers.parse_machine_change( 'change', data, 'pending', '') self.assertEqual('pending', status) self.assertEqual('', address) self.assertFalse(mock_print.called) class TestParseUnitChange(helpers.ValueErrorTestsMixin, unittest.TestCase): def test_unit_removed(self): # A ValueError is raised if the change represents a unit removal. data = {'Name': 'django/42', 'Status': 'started'} with self.assert_value_error('django/42 unexpectedly removed'): # The last two arguments are the current status and address. watchers.parse_unit_change('remove', data, '', '') def test_unit_error(self): # A ValueError is raised if the unit is in an error state. data = { 'Name': 'django/0', 'Status': 'start error', 'StatusInfo': 'bad wolf', } expected_error = 'django/0 is in an error state: start error: bad wolf' with self.assert_value_error(expected_error): # The last two arguments are the current status and address. watchers.parse_unit_change('change', data, '', '') @helpers.mock_print def test_address_notified(self, mock_print): # A message is printed to stdout when the unit obtains a public # address. The function returns the status, the new address and the # machine identifier. data = { 'Name': 'haproxy/2', 'Status': 'pending', 'PublicAddress': 'haproxy2.example.com', 'MachineId': '42', } status, address, machine_id = watchers.parse_unit_change( 'change', data, 'pending', '') self.assertEqual('pending', status) self.assertEqual('haproxy2.example.com', address) self.assertEqual('42', machine_id) mock_print.assert_called_once_with( 'haproxy/2 placed on haproxy2.example.com') @helpers.mock_print def test_pending_status_notified(self, mock_print): # A message is printed to stdout when the unit changes its status to # "pending". The function returns the new status, the address and the # machine identifier. The last two values are empty strings if the unit # has not yet been assigned to a machine. data = {'Name': 'django/1', 'Status': 'pending', 'PublicAddress': ''} # The last two arguments are the current status and address. status, address, machine_id = watchers.parse_unit_change( 'change', data, '', '') self.assertEqual('pending', status) self.assertEqual('', address) self.assertEqual('', machine_id) mock_print.assert_called_once_with('django/1 deployment is pending') @helpers.mock_print def test_installed_status_notified(self, mock_print): # A message is printed to stdout when the unit changes its status to # "installed". The function returns the new status, the address and the # machine identifier. data = { 'Name': 'django/42', 'Status': 'installed', 'PublicAddress': 'django42.example.com', 'MachineId': '1', } status, address, machine_id = watchers.parse_unit_change( 'change', data, 'pending', 'django42.example.com') self.assertEqual('installed', status) self.assertEqual('django42.example.com', address) self.assertEqual('1', machine_id) mock_print.assert_called_once_with('django/42 is installed') @helpers.mock_print def test_started_status_notified(self, mock_print): # A message is printed to stdout when the unit changes its status to # "started". The function returns the new status, the address and the # machine identifier. data = { 'Name': 'wordpress/0', 'Status': 'started', 'PublicAddress': 'wordpress0.example.com', 'MachineId': '0', } status, address, machine_id = watchers.parse_unit_change( 'change', data, '', 'wordpress0.example.com') self.assertEqual('started', status) self.assertEqual('wordpress0.example.com', address) self.assertEqual('0', machine_id) mock_print.assert_called_once_with('wordpress/0 is ready on machine 0') @helpers.mock_print def test_both_status_and_address_notified(self, mock_print): # Both status and public address changes are notified if required. data = { 'Name': 'django/0', 'Status': 'started', 'PublicAddress': 'django42.example.com', 'MachineId': '0', } # The last two arguments are the current status and address. watchers.parse_unit_change('change', data, '', '') self.assertEqual(2, mock_print.call_count) mock_print.assert_has_calls([ mock.call('django/0 placed on django42.example.com'), mock.call('django/0 is ready on machine 0'), ]) @helpers.mock_print def test_status_not_changed(self, mock_print): # If the status in the unit change and the given current status are the # same value, nothing is printed and the current values are returned. data = {'Name': 'django/1', 'Status': 'pending', 'PublicAddress': ''} status, address, machine_id = watchers.parse_unit_change( 'change', data, 'pending', '') self.assertEqual('pending', status) self.assertEqual('', address) self.assertEqual('', machine_id) self.assertFalse(mock_print.called) @helpers.mock_print def test_address_not_available(self, mock_print): # An empty address is returned when the public address field is not # included in the change data. data = {'Name': 'haproxy/2', 'Status': 'pending', 'MachineId': '42'} status, address, machine_id = watchers.parse_unit_change( 'change', data, 'pending', '') self.assertEqual('pending', status) self.assertEqual('', address) self.assertEqual('42', machine_id) self.assertFalse(mock_print.called) class TestUnitMachineChanges(unittest.TestCase): def test_unit_changes_found(self): # Unit changes are correctly found and returned. data1 = {'Name': 'django/42', 'Status': 'started'} data2 = {'Name': 'django/47', 'Status': 'pending'} changeset = [('unit', 'change', data1), ('unit', 'remove', data2)] expected_unit_changes = [('change', data1), ('remove', data2)] self.assertEqual( (expected_unit_changes, []), watchers.unit_machine_changes(changeset)) def test_machine_changes_found(self): # Machine changes are correctly found and returned. data1 = {'Id': '0', 'Status': 'started'} data2 = {'Id': '1', 'Status': 'error'} changeset = [ ('machine', 'change', data1), ('machine', 'remove', data2), ] expected_machine_changes = [('change', data1), ('remove', data2)] self.assertEqual( ([], expected_machine_changes), watchers.unit_machine_changes(changeset)) def test_unit_and_machine_changes_found(self): # Changes to unit and machines are reordered, grouped and returned. machine_data1 = {'Id': '0', 'Status': 'started'} machine_data2 = {'Id': '42', 'Status': 'started'} unit_data1 = {'Name': 'django/42', 'Status': 'error'} unit_data2 = {'Name': 'haproxy/47', 'Status': 'pending'} unit_data3 = {'Name': 'wordpress/0', 'Status': 'installed'} changeset = [ ('machine', 'change', machine_data1), ('unit', 'change', unit_data1), ('machine', 'remove', machine_data2), ('unit', 'change', unit_data2), ('unit', 'remove', unit_data3), ] expected_unit_changes = [ ('change', unit_data1), ('change', unit_data2), ('remove', unit_data3), ] expected_machine_changes = [ ('change', machine_data1), ('remove', machine_data2), ] self.assertEqual( (expected_unit_changes, expected_machine_changes), watchers.unit_machine_changes(changeset)) def test_other_entities(self): # Changes to other entities (like services) are ignored. machine_data = {'Id': '0', 'Status': 'started'} unit_data = {'Name': 'django/42', 'Status': 'error'} changeset = [ ('machine', 'change', machine_data), ('service', 'change', {'Name': 'django', 'Status': 'pending'}), ('unit', 'change', unit_data), ('service', 'remove', {'Name': 'haproxy', 'Status': 'started'}), ] expected_changes = ( [('change', unit_data)], [('change', machine_data)], ) self.assertEqual( expected_changes, watchers.unit_machine_changes(changeset)) def test_empty_changeset(self): # Two empty lists are returned if the changeset is empty. # This should never occur in the real world, but it's tested here to # demonstrate this function behavior. self.assertEqual(([], []), watchers.unit_machine_changes([])) juju-quickstart-1.3.1/quickstart/tests/__init__.py0000644000175000017500000000145212247627363023732 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . juju-quickstart-1.3.1/quickstart/tests/helpers.py0000644000175000017500000002154112320026742023620 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Test helpers for the Juju Quickstart plugin.""" from __future__ import unicode_literals from contextlib import contextmanager import os import shutil import tempfile import mock import yaml @contextmanager def assert_logs(messages, level='debug'): """Ensure the given messages are logged using the given log level. Use this function as a context manager: the code executed in the context block must add the expected log entries. """ with mock.patch('logging.{}'.format(level.lower())) as mock_log: yield expected_calls = [mock.call(message) for message in messages] mock_log.assert_has_calls(expected_calls) class BundleFileTestsMixin(object): """Shared methods for testing Juju bundle files.""" valid_bundle = yaml.safe_dump({ 'bundle1': {'services': {'wordpress': {}, 'mysql': {}}}, 'bundle2': {'services': {'django': {}, 'nodejs': {}}}, }) def _write_bundle_file(self, bundle_file, contents): """Parse and write contents into the given bundle file object.""" if contents is None: contents = self.valid_bundle elif isinstance(contents, dict): contents = yaml.safe_dump(contents) bundle_file.write(contents) def make_bundle_file(self, contents=None): """Create a Juju bundle file containing the given contents. If contents is None, use the valid bundle contents defined in self.valid_bundle. Return the bundle file path. """ bundle_file = tempfile.NamedTemporaryFile(delete=False) self.addCleanup(os.remove, bundle_file.name) self._write_bundle_file(bundle_file, contents) bundle_file.close() return bundle_file.name def make_bundle_dir(self, contents=None): """Create a Juju bundle directory including a bundles.yaml file. The file will contain the given contents. If contents is None, use the valid bundle contents defined in self.valid_bundle. Return the bundle directory path. """ bundle_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, bundle_dir) bundle_path = os.path.join(bundle_dir, 'bundles.yaml') with open(bundle_path, 'w') as bundle_file: self._write_bundle_file(bundle_file, contents) return bundle_dir class CallTestsMixin(object): """Easily use the quickstart.utils.call function.""" def patch_call(self, retcode, output='', error=''): """Patch the quickstart.utils.call function.""" mock_call = mock.Mock(return_value=(retcode, output, error)) return mock.patch('quickstart.utils.call', mock_call) def patch_multiple_calls(self, side_effect): """Patch multiple subsequent quickstart.utils.call calls.""" mock_call = mock.Mock(side_effect=side_effect) return mock.patch('quickstart.utils.call', mock_call) class EnvFileTestsMixin(object): """Shared methods for testing a Juju environments file.""" valid_contents = yaml.safe_dump({ 'default': 'aws', 'environments': { 'aws': { 'admin-secret': 'Secret!', 'type': 'ec2', 'default-series': 'saucy', 'access-key': 'AccessKey', 'secret-key': 'SeceretKey', 'control-bucket': 'ControlBucket', }, }, }) def make_env_file(self, contents=None): """Create a Juju environments file containing the given contents. If contents is None, use the valid environment contents defined in self.valid_contents. Return the environments file path. """ if contents is None: contents = self.valid_contents env_file = tempfile.NamedTemporaryFile(delete=False) self.addCleanup(os.remove, env_file.name) env_file.write(contents) env_file.close() return env_file.name def make_env_db(default=None, exclude_invalid=False): """Create and return an env_db. The default argument can be used to specify a default environment. If exclude_invalid is set to True, the resulting env_db only includes valid environments. """ environments = { 'ec2-west': { 'type': 'ec2', 'admin-secret': 'adm-307c4a53bd174c1a89e933e1e8dc8131', 'control-bucket': 'con-aa2c6618b02d448ca7fd0f280ef66cba', 'region': u'us-west-1', 'access-key': 'hash', 'secret-key': 'Secret!', }, 'lxc': { 'admin-secret': 'bones', 'default-series': 'raring', 'storage-port': 8888, 'type': 'local', }, 'test-encoding': { 'access-key': '\xe0\xe8\xec\xf2\xf9', 'secret-key': '\xe0\xe8\xec\xf2\xf9', 'admin-secret': '\u2622\u2622\u2622\u2622', 'control-bucket': '\u2746 winter-bucket \u2746', 'juju-origin': '\u2606 the outer space \u2606', 'type': 'toxic \u2622 type', }, } if not exclude_invalid: environments.update({ 'local-with-errors': { 'admin-secret': '', 'storage-port': 'this-should-be-an-int', 'type': 'local', }, 'private-cloud-errors': { 'admin-secret': 'Secret!', 'auth-url': 'https://keystone.example.com:443/v2.0/', 'authorized-keys-path': '/home/frankban/.ssh/juju-rsa.pub', 'control-bucket': 'e3d48007292c9abba499d96a577ceab891d320fe', 'default-image-id': 'bb636e4f-79d7-4d6b-b13b-c7d53419fd5a', 'default-instance-type': 'm1.medium', 'default-series': 'no-such', 'type': 'openstack', }, }) env_db = {'environments': environments} if default is not None: env_db['default'] = default return env_db # Mock the builtin print function. mock_print = mock.patch('__builtin__.print') class UrlReadTestsMixin(object): """Expose a method to mock the quickstart.utils.urlread helper function.""" def patch_urlread(self, contents=None, error=False): """Patch the quickstart.utils.urlread helper function. If contents is not None, urlread() will return the provided contents. If error is set to True, an IOError will be simulated. """ mock_urlread = mock.Mock() if contents is not None: mock_urlread.return_value = contents if error: mock_urlread.side_effect = IOError('bad wolf') return mock.patch('quickstart.utils.urlread', mock_urlread) class ValueErrorTestsMixin(object): """Set up some base methods for testing functions raising ValueErrors.""" @contextmanager def assert_value_error(self, error): """Ensure a ValueError is raised in the context block. Also check that the exception includes the expected error message. """ with self.assertRaises(ValueError) as context_manager: yield self.assertEqual(error, bytes(context_manager.exception)) class WatcherDataTestsMixin(object): """Shared methods for testing Juju mega-watcher data.""" def _make_change(self, entity, action, default_data, data): if data is not None: default_data.update(data) return entity, action, default_data def make_service_change(self, action='change', data=None): """Create and return a change on a service. The passed data can be used to override default values. """ default_data = { 'CharmURL': 'cs:precise/juju-gui-47', 'Exposed': True, 'Life': 'alive', 'Name': 'my-gui', } return self._make_change('service', action, default_data, data) def make_unit_change(self, action='change', data=None): """Create and return a change on a unit. The passed data can be used to override default values. """ default_data = {'Name': 'my-gui/47', 'Service': 'my-gui'} return self._make_change('unit', action, default_data, data) juju-quickstart-1.3.1/quickstart/juju.py0000644000175000017500000001204112310052400021751 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart API client.""" from __future__ import unicode_literals import logging import jujuclient import websocket def connect(api_url): """Return an Environment instance connected to the given API URL.""" connection = WebSocketConnection() # See the websocket.create_connection function. connection.settimeout(websocket.default_timeout) connection.connect(api_url, origin=api_url) return Environment(api_url, conn=connection) class Environment(jujuclient.Environment): """A Juju bootstrapped environment. Instances of this class can be used to run API operations on a Juju environment. Specifically this subclass enables bundle support and deployments to specific machines. """ def deploy_bundle(self, yaml, name=None, bundle_id=None): """Deploy a bundle.""" params = {'YAML': yaml} if name is not None: params['Name'] = name if bundle_id is not None: params['BundleID'] = bundle_id request = { 'Type': 'Deployer', 'Request': 'Import', 'Params': params, } return self._rpc(request) def create_auth_token(self): """Make an auth token creation request. Here is an example of a successful token creation response. { 'RequestId': 42, 'Response': { 'Token': 'TOKEN-STRING', 'Created': '2013-11-21T12:34:46.778866Z', 'Expires': '2013-11-21T12:36:46.778866Z' } } """ request = dict(Type='GUIToken', Request='Create') return self._rpc(request) def get_watcher(self): """Return a connected/authenticated environment watcher. This method is similar to jujuclient.Environment.get_watch, but it enables logging on the resulting watcher requests/responses traffic. """ # Logging is enabled by the connect factory function, which uses our # customized WebSocketConnection. Note that, since jujuclient does not # track request identifiers, it is not currently possible to avoid # establishing a new connection for each watcher. env = connect(self.endpoint) # For the remaining bits, see jujuclient.Environment.get_watch. env.login(**self._creds) watcher = jujuclient.Watcher(env.conn) self._watches.append(watcher) watcher.start() return watcher def get_status(self): """Return the current status of the environment. The status is represented by a single mega-watcher changeset. Each change in the changeset is a tuple (entity, action, data) where: - entity is a string representing the changed content type (e.g. "service" or "unit"); - action is a string representing the event which generated the change (i.e. "change" or "remove"); - data is a dict containing information about the releated entity. """ with self.get_watcher() as watcher: changeset = watcher.next() return changeset def watch_changes(self, processor): """Start watching the changes occurring in the Juju environment. For each changeset, call the given processor callable, and yield the values returned by the processor. """ with self.get_watcher() as watcher: # The watcher closes when the context manager exit hook is called. for changeset in watcher: changes = processor(changeset) if changes: yield changes class WebSocketConnection(websocket.WebSocket): """A WebSocket client connection.""" def send(self, message): """Send the given WebSocket message. Overridden to add logging. """ logging.debug('API message: --> {}'.format(message.decode('utf-8'))) return super(WebSocketConnection, self).send(message) def recv(self): """Receive a message from the WebSocket server. Overridden to add logging. """ message = super(WebSocketConnection, self).recv() logging.debug('API message: <-- {}'.format(message.decode('utf-8'))) return message juju-quickstart-1.3.1/quickstart/utils.py0000644000175000017500000003316212311534714022161 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013-2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart utility functions and classes.""" from __future__ import ( print_function, unicode_literals, ) import collections import datetime import errno import functools import httplib import json import logging import os import pipes import re import socket import subprocess import urllib2 import quickstart from quickstart import ( serializers, settings, ) from quickstart.models import charms # Compile the regular expression used to parse bundle URLs. _bundle_expression = re.compile(r""" # Bundle schema or bundle URL namespace on jujucharms.com. ^(?:bundle:|{}) (?:~([-\w]+)/)? # Optional user name. ([-\w]+)/ # Basket name. (?:(\d+)/)? # Optional bundle revision number. ([-\w]+) # Bundle name. /?$ # Optional trailing slash. """.format(settings.JUJUCHARMS_BUNDLE_URL), re.VERBOSE) def add_apt_repository(repository): """Add the given APT repository to the current list of APT sources. Also take care of installing the add-apt-repository script and of updating the list of APT packages after the repository installation. Raise an OSError if any error occur in the process. """ print('adding the {} PPA repository'.format(repository)) print('sudo privileges will be required for PPA installation') # The package including add-apt-repository is python-software-properties # in precise and software-properties-common after precise. add_repository_package = 'software-properties-common' if get_ubuntu_codename() == 'precise': add_repository_package = 'python-software-properties' commands = ( ('/usr/bin/apt-get', 'install', '-y', add_repository_package), ('/usr/bin/add-apt-repository', '-y', repository), ('/usr/bin/apt-get', 'update'), ) for command in commands: retcode, _, error = call('sudo', *command) if retcode: raise OSError(error.encode('utf-8')) def call(command, *args): """Call a subprocess passing the given arguments. Take the subcommand and its parameters as args. Return a tuple containing the subprocess return code, output and error. """ pipe = subprocess.PIPE cmd = (command,) + args cmdline = ' '.join(map(pipes.quote, cmd)) logging.debug('running the following: {}'.format(cmdline)) try: process = subprocess.Popen(cmd, stdout=pipe, stderr=pipe) except OSError as err: # A return code 127 is returned by the shell when the command is not # found in the PATH. return 127, '', '{}: {}'.format(command, err) output, error = process.communicate() retcode = process.poll() logging.debug('retcode: {} | output: {!r} | error: {!r}'.format( retcode, output, error)) return retcode, output.decode('utf-8'), error.decode('utf-8') def check_gui_charm_url(charm_url): """Print (to stdout or to logs) info and warnings about the charm URL.""" print('charm URL: {}'.format(charm_url)) charm = charms.Charm.from_url(charm_url) charm_name = settings.JUJU_GUI_CHARM_NAME if charm.name == charm_name: if charm.user or charm.is_local(): # This is not the official Juju GUI charm. logging.warn('using a customized {} charm'.format(charm_name)) elif charm.revision < settings.MINIMUM_CHARM_REVISION_FOR_BUNDLES: # This is the official Juju GUI charm, but it is outdated. logging.warn( 'charm is outdated and may not support bundle deployments') else: # This does not seem to be a Juju GUI charm. logging.warn( 'unexpected URL for the {} charm: ' 'the service may not work as expected'.format(charm_name)) def convert_bundle_url(bundle_url): """Return the equivalent YAML HTTPS location for the given bundle URL. Raise a ValueError if the given URL is not a valid bundle URL. """ match = _bundle_expression.match(bundle_url) if match is None: msg = 'invalid bundle URL: {}'.format(bundle_url) raise ValueError(msg.encode('utf-8')) user, basket, revision, name = match.groups() user_part = '~charmers/' if user is None else '~{}/'.format(user) revision_part = '' if revision is None else '{}/'.format(revision) bundle_id = '{}{}/{}{}'.format(user_part, basket, revision_part, name) return ('https://manage.jujucharms.com/bundle/{}/json'.format(bundle_id), bundle_id) def get_charm_url(): """Return the charm URL of the latest Juju GUI charm revision. Raise an IOError if any problems occur connecting to the API endpoint. Raise a ValueError if the API returns invalid data. """ charm_info = json.loads(urlread(settings.CHARMWORLD_API)) charm_url = charm_info.get('charm', {}).get('url') if charm_url is None: raise ValueError(b'unable to find the charm URL') return charm_url def get_quickstart_banner(): """Return a quickstart banner suitable for being included in files. The banner is returned as a string, e.g.: # This file has been generated by juju quickstart v0.42.0 # in date 2013-12-31 23:59:00 UTC. """ now = datetime.datetime.utcnow() formatted_date = now.isoformat(sep=b' ').split('.')[0] version = quickstart.get_version() return ( '# This file has been generated by juju quickstart v{}\n' '# at {} UTC.\n\n'.format(version, formatted_date)) def get_service_info(status, service_name): """Retrieve information on the given service and on its first alive unit. Return a tuple containing two values: (service data, unit data). Each value can be: - a dictionary of data about the given entity (service or unit) as returned by the Juju watcher; - None, if the entity is not present in the Juju environment. If the service data is None, the unit data is always None. """ services = [ data for entity, action, data in status if (entity == 'service') and (action != 'remove') and (data['Name'] == service_name) and (data['Life'] == 'alive') ] if not services: return None, None units = [ data for entity, action, data in status if entity == 'unit' and action != 'remove' and data['Service'] == service_name ] return services[0], units[0] if units else None def get_ubuntu_codename(): """Return the codename of the current Ubuntu release (e.g. "trusty"). Raise an OSError if an error occurs retrieving the codename. """ retcode, output, error = call('lsb_release', '-cs') if retcode: raise OSError(error.encode('utf-8')) return output.strip() def mkdir(path): """Create a leaf directory and all intermediate ones. Also expand ~ and ~user constructions. If path exists and it's a directory, return without errors. """ path = os.path.expanduser(path) try: os.makedirs(path) except OSError as err: # Re-raise the error if the target path exists but it is not a dir. if (err.errno != errno.EEXIST) or (not os.path.isdir(path)): raise def parse_bundle(bundle_yaml, bundle_name=None): """Parse the provided bundle YAML encoded contents. Since a valid JSON is a subset of YAML this function can be used also to parse JSON encoded contents. Return a tuple containing the bundle name and the list of services included in the bundle. Raise a ValueError if: - the bundle YAML contents are not parsable by YAML; - the YAML contents are not properly structured; - the bundle name is specified but not included in the bundle file; - the bundle name is not specified and the bundle file includes more than one bundle; - the bundle does not include services. """ # Parse the bundle file. try: bundles = serializers.yaml_load(bundle_yaml) except Exception as err: msg = b'unable to parse the bundle: {}'.format(err) raise ValueError(msg) # Ensure the bundle file is well formed and contains at least one bundle. if not isinstance(bundles, collections.Mapping): msg = 'invalid YAML contents: {}'.format(bundle_yaml) raise ValueError(msg.encode('utf-8')) try: name_services_map = dict( (key, value['services'].keys()) for key, value in bundles.items() ) except (AttributeError, KeyError, TypeError): msg = 'invalid YAML contents: {}'.format(bundle_yaml) raise ValueError(msg.encode('utf-8')) if not name_services_map: raise ValueError(b'no bundles found') # Retrieve the bundle name and services. if bundle_name is None: if len(name_services_map) > 1: msg = 'multiple bundles found ({}) but no bundle name specified' bundle_names = ', '.join(sorted(name_services_map.keys())) raise ValueError(msg.format(bundle_names).encode('utf-8')) bundle_name, bundle_services = name_services_map.items()[0] else: bundle_services = name_services_map.get(bundle_name) if bundle_services is None: msg = 'bundle {} not found in the provided list of bundles ({})' bundle_names = ', '.join(sorted(name_services_map.keys())) raise ValueError( msg.format(bundle_name, bundle_names).encode('utf-8')) if not bundle_services: msg = 'bundle {} does not include any services'.format(bundle_name) raise ValueError(msg.encode('utf-8')) if settings.JUJU_GUI_SERVICE_NAME in bundle_services: msg = ('bundle {} contains an instance of juju-gui. quickstart will ' 'install the latest version of the Juju GUI automatically, ' 'please remove juju-gui from the bundle.'.format(bundle_name)) raise ValueError(msg.encode('utf-8')) return bundle_name, bundle_services def parse_status_output(output, keys=None): """Parse the output of juju status. Return selection specified by the keys array. Raise a ValueError if the selection cannot be retrieved. """ if keys is None: keys = ['dummy'] try: status = serializers.yaml_load(output) except Exception as err: raise ValueError(b'unable to parse the output: {}'.format(err)) selection = status for key in keys: try: selection = selection.get(key, {}) except AttributeError as err: msg = 'invalid YAML contents: {}'.format(status) raise ValueError(msg.encode('utf-8')) if selection == {}: msg = '{} not found in {}'.format(':'.join(keys), status) raise ValueError(msg.encode('utf-8')) return selection def get_agent_state(output): """Parse the output of juju status for the agent state. Return the agent state. Raise a ValueError if the agent state cannot be retrieved. """ return parse_status_output(output, ['machines', '0', 'agent-state']) def get_bootstrap_node_series(output): """Parse the output of juju status for the agent state. Return the agent state. Raise a ValueError if the agent state cannot be retrieved. """ return parse_status_output(output, ['machines', '0', 'series']) def get_juju_version(): """Return the current juju-core version. Return a (major:int, minor:int, patch:int) tuple, including major, minor and patch version numbers. Raise a ValueError if the "juju version" call exits with an error or the returned version is not well formed. """ retcode, output, error = call(settings.JUJU_CMD, 'version') if retcode: raise ValueError(error.encode('utf-8')) version_string = output.split('-')[0] try: major, minor, patch = version_string.split('.', 2) return int(major), int(minor), int(patch) except ValueError: msg = 'invalid version string: {}'.format(version_string) raise ValueError(msg.encode('utf-8')) def run_once(function): """Return a decorated version of the given function which runs only once. Subsequent runs are just ignored and return None. """ @functools.wraps(function) def decorated(*args, **kwargs): if not decorated.called: decorated.called = True return function(*args, **kwargs) decorated.called = False return decorated def urlread(url): """Open the given URL and return the page contents. Raise an IOError if any problems occur. """ try: response = urllib2.urlopen(url) except urllib2.URLError as err: raise IOError(err.reason) except (httplib.HTTPException, socket.error, urllib2.HTTPError) as err: raise IOError(bytes(err)) contents = response.read() content_type = response.headers['content-type'] charset = 'utf-8' if 'charset=' in content_type: sent_charset = content_type.split('charset=')[-1].strip() if sent_charset: charset = sent_charset return contents.decode(charset, 'ignore') juju-quickstart-1.3.1/quickstart/serializers.py0000644000175000017500000000342512251372515023356 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart functions for serializing data structures.""" from __future__ import unicode_literals import yaml def yaml_load(stream): """Parse the YAML document in stream. The stream argument can be a file like object or a string content. Produce and return the corresponding Python object, returning strings as unicode objects. """ # See . loader_class = type(b'UnicodeLoader', (yaml.SafeLoader,), {}) loader_class.add_constructor( 'tag:yaml.org,2002:str', lambda loader, node: loader.construct_scalar(node)) return yaml.load(stream, Loader=loader_class) def yaml_dump(data, stream=None): """Serialize a Python object into a YAML stream. If stream is None, return the produced string instead. Always serialize collections in the block style. """ return yaml.safe_dump(data, stream, default_flow_style=False) juju-quickstart-1.3.1/quickstart/app.py0000644000175000017500000004661212320523571021604 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013-2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart base application functions.""" from __future__ import ( print_function, unicode_literals, ) import json import logging import os import sys import time import jujuclient from quickstart import ( juju, settings, ssh, utils, watchers, ) from quickstart.models import envs class ProgramExit(Exception): """An error occurred while setting up the Juju environment. Raise this exception if you want the program to exit gracefully printing the error message to stderr. The error message can be either a unicode or a byte string. """ def __init__(self, message): if isinstance(message, unicode): message = message.encode('utf-8') self.message = message def __str__(self): return b'juju-quickstart: error: {}'.format(self.message) def ensure_dependencies(distro_only): """Ensure that Juju and LXC are installed. If the "juju" command is not found in the PATH, then install and setup Juju, including the packages required to bootstrap local environments. This is usually done by adding the Juju stable PPA and installing the juju-core and juju-local packages. If distro_only is True, the above PPA is not added to the apt sources, and we assume Juju packages are already available in the distro repository. Return the Juju version tuple when Juju is available. Raise a ProgramExit if an error occurs installing packages or retrieving the Juju version. """ required_packages = [] # Check if juju is installed. try: juju_version = utils.get_juju_version() except ValueError: # Juju is not installed or configured properly. To ensure everything # is set up correctly, also install packages required to run # environments using the local provider. required_packages.extend(['juju-core', 'juju-local']) juju_version = None else: # Check if LXC is installed. retcode = utils.call('/usr/bin/lxc-ls')[0] if retcode: # Some packages (e.g. lxc and mongodb-server) are required to # support bootstrapping environments using the local provider. # All those packages are installed as juju-local dependencies. required_packages.append('juju-local') if required_packages: if not distro_only: try: utils.add_apt_repository('ppa:juju/stable') except OSError as err: raise ProgramExit(bytes(err)) print('sudo privileges will be used for the installation of \n' 'the following packages: {}\n' 'this can take a while...'.format(', '.join(required_packages))) retcode, _, error = utils.call( 'sudo', '/usr/bin/apt-get', 'install', '-y', *required_packages) if retcode: raise ProgramExit(error) # Return the current Juju version. if juju_version is None: # Juju has been just installed, retrieve its version. try: juju_version = utils.get_juju_version() except ValueError as err: raise ProgramExit(bytes(err)) return juju_version def ensure_ssh_keys(): """Ensure that SSH keys are available. Allow the user to let quickstart create SSH keys, or quit by raising a ProgramExit if they would like to create the key themselves. """ try: # Test to see if we have ssh-keys loaded into the ssh-agent, or if we # can add them to the currently running ssh-agent. if ssh.check_keys(): return # No responsive agent was found. Start one up. ssh.start_agent() # And now check again. if ssh.check_keys(): return except OSError as err: raise ProgramExit(bytes(err)) # At this point we have no SSH keys. print('Warning: no SSH keys were found in ~/.ssh\n' 'To proceed and generate keys, quickstart can\n' '[a] automatically create keys for you\n' '[m] provide commands to manually create your keys\n\n' 'Note: ssh-keygen will prompt you for an optional\n' 'passphrase to generate your key for you.\n' 'Quickstart does not store it.\n') try: answer = raw_input( 'Automatically create keys [a], manually create the keys [m], ' 'or cancel [c]? ').lower() except KeyboardInterrupt: answer = '' try: if answer == 'a': ssh.create_keys() elif answer == 'm': ssh.watch_for_keys() else: sys.exit( b'\nIf you would like to create the keys yourself,\n' b'please run this command, follow its instructions,\n' b'and then re-run quickstart:\n' b' ssh-keygen -b 4096 -t rsa' ) except OSError as err: raise ProgramExit(bytes(err)) def bootstrap(env_name, requires_sudo=False, debug=False): """Bootstrap the Juju environment with the given name. Do not bootstrap the environment if already bootstrapped. Return a tuple (already_bootstrapped, series) in which: - already_bootstrapped indicates whether the environment was already bootstrapped; - series is the bootstrap node Ubuntu series. The is_local argument indicates whether the environment is configured to use the local provider. If so, sudo privileges are requested in order to bootstrap the environment. If debug is True and the environment not bootstrapped, execute the bootstrap command passing the --debug flag. Raise a ProgramExit if any error occurs in the bootstrap process. """ already_bootstrapped = False cmd = [settings.JUJU_CMD, 'bootstrap', '-e', env_name] if requires_sudo: cmd.insert(0, 'sudo') if debug: cmd.append('--debug') retcode, _, error = utils.call(*cmd) if retcode: # XXX frankban 2013-11-13 bug 1252322: the check below is weak. We are # relying on an error message in order to decide if the environment is # already bootstrapped. Other possibilities include checking if the # jenv file is present (in ~/.juju/environments/) and, if so, check the # juju status. Unfortunately this is also prone to errors, because a # jenv file can be there but the environment not really bootstrapped or # functional (e.g. sync-tools was used, or a previous bootstrap failed, # or the user terminated machines from the ec2 panel, etc.). Moreover # jenv files seems to be an internal juju-core detail. Definitely we # need to find a better way, but for now the "asking forgiveness" # approach feels like the best compromise we have. Also note that, # rather than comparing the expected error with the obtained one, we # search in the error in order to support bootstrap --debug. if 'environment is already bootstrapped' not in error: # We exit if the error is not "already bootstrapped". raise ProgramExit(error) # Juju is bootstrapped, but we don't know if it is ready yet. Fall # through to the next block for that check. already_bootstrapped = True print('reusing the already bootstrapped {} environment'.format( env_name)) # Call "juju status" multiple times until the bootstrap node is ready. # Exit with an error if the agent is not ready after ten minutes. # Note: when using the local provider, calling "juju status" is very fast, # but e.g. on ec2 the first call (right after "bootstrap") can take # several minutes, and subsequent calls are relatively fast (seconds). print('retrieving the environment status') timeout = time.time() + (60*10) while time.time() < timeout: retcode, output, error = utils.call( settings.JUJU_CMD, 'status', '-e', env_name, '--format', 'yaml') if retcode: continue # Ensure the state server is up and the agent is started. try: agent_state = utils.get_agent_state(output) except ValueError: continue if agent_state == 'started': series = utils.get_bootstrap_node_series(output) return already_bootstrapped, series # If the agent is in an error state, there is nothing we can do, and # it's not useful to keep trying. if agent_state == 'error': raise ProgramExit('state server failure:\n{}'.format(output)) details = ''.join(filter(None, [output, error])).strip() raise ProgramExit('the state server is not ready:\n{}'.format(details)) def get_admin_secret(env_name, juju_home): """Read the admin-secret from the generated environment file. At bootstrap, juju (v1.16 and later) writes the admin-secret to a generated file located in JUJU_HOME. Return the value. Raise a ValueError if: - the environment file is not found; - the environment file contents are not parsable by YAML; - the YAML contents are not properly structured; - the admin-secret is not found. """ filename = '{}.jenv'.format(env_name) juju_env_file = os.path.expanduser( os.path.join(juju_home, 'environments', filename)) jenv_db = envs.load_generated(juju_env_file) try: return jenv_db['admin-secret'] except KeyError: msg = 'admin-secret not found in {}'.format(juju_env_file) raise ValueError(msg.encode('utf-8')) def get_api_url(env_name): """Return a Juju API URL for the given environment name. Use the Juju CLI in a subprocess in order to retrieve the API addresses. Return the complete URL, e.g. "wss://api.example.com:17070". Raise a ProgramExit if any error occurs. """ retcode, output, error = utils.call( settings.JUJU_CMD, 'api-endpoints', '-e', env_name, '--format', 'json') if retcode: raise ProgramExit(error) # Assuming there is always at least one API address, grab the first one # from the JSON output. api_address = json.loads(output)[0] return 'wss://{}'.format(api_address) def connect(api_url, admin_secret): """Connect to the Juju API server and log in using the given credentials. Return a connected and authenticated Juju Environment instance. Raise a ProgramExit if any error occurs while establishing the WebSocket connection or if the API returns an error response. """ try_count = 0 while True: try: env = juju.connect(api_url) except Exception as err: try_count += 1 msg = b'unable to connect to the Juju API server on {}: {}'.format( api_url.encode('utf-8'), err) if try_count >= 30: raise ProgramExit(msg) else: logging.warn('Retrying: ' + msg) time.sleep(1) else: break try: env.login(admin_secret) except jujuclient.EnvError as err: msg = 'unable to log in to the Juju API server on {}: {}' raise ProgramExit(msg.format(api_url, err.message)) return env def create_auth_token(env): """Return a new authentication token. If the server does not support the request, return None. Raise any other error.""" try: result = env.create_auth_token() except jujuclient.EnvError as err: if err.message == 'unknown object type "GUIToken"': # This is a legacy charm. return None else: raise return result['Token'] def deploy_gui( env, service_name, machine, charm_url=None, check_preexisting=False): """Deploy and expose the given service, reusing the bootstrap node. Only deploy the service if not already present in the environment. Do not add a unit to the service if the unit is already there. Receive an authenticated Juju Environment instance, the name of the service, the machine where to deploy to (or None for a new machine), the optional Juju GUI charm URL (e.g. cs:~juju-gui/precise/juju-gui-42), and a flag (check_preexisting) that can be set to True in order to make the function check for a pre-existing service and/or unit. If the charm URL is not provided, and the service is not already deployed, the function tries to retrieve it from charmworld. In this case a default charm URL is used if charmworld is not available. Return the name of the first running unit belonging to the given service. Raise a ProgramExit if the API server returns an error response. """ service_data, unit_data = None, None if check_preexisting: # The service and/or the unit can be already in the environment. try: status = env.get_status() except jujuclient.EnvError as err: raise ProgramExit('bad API response: {}'.format(err.message)) service_data, unit_data = utils.get_service_info(status, service_name) if service_data is None: # The service does not exist in the environment. print('requesting {} deployment'.format(service_name)) if charm_url is None: try: charm_url = utils.get_charm_url() except (IOError, ValueError) as err: msg = 'unable to retrieve the {} charm URL from the API: {}' logging.warn(msg.format(service_name, err)) charm_url = settings.DEFAULT_CHARM_URL utils.check_gui_charm_url(charm_url) # Deploy the service without units. try: env.deploy(service_name, charm_url, num_units=0) except jujuclient.EnvError as err: raise ProgramExit('bad API response: {}'.format(err.message)) print('{} deployment request accepted'.format(service_name)) service_exposed = False else: # We already have the service in the environment. print('service {} already deployed'.format(service_name)) utils.check_gui_charm_url(service_data['CharmURL']) service_exposed = service_data.get('Exposed', False) # At this point the service is surely deployed in the environment: expose # it if necessary and add a unit if it is missing. if not service_exposed: print('exposing service {}'.format(service_name)) try: env.expose(service_name) except jujuclient.EnvError as err: raise ProgramExit('bad API response: {}'.format(err.message)) if unit_data is None: # Add a unit to the service. print('requesting new unit deployment') try: response = env.add_unit(service_name, machine_spec=machine) except jujuclient.EnvError as err: raise ProgramExit('bad API response: {}'.format(err.message)) unit_name = response['Units'][0] print('{} deployment request accepted'.format(unit_name)) else: # A service unit is already present in the environment. Go ahead # and try to reuse that unit. unit_name = unit_data['Name'] print('reusing unit {}'.format(unit_name)) return unit_name def watch(env, unit_name): """Start watching the given unit and the machine the unit belongs to. Output a human readable message each time a relevant change is found. The following changes are considered relevant for a healthy unit: - the machine is pending; - the unit is pending; - the machine is started; - the unit is reachable; - the unit is installed; - the unit is started. Stop watching and return the unit public address when the unit is started. Raise a ProgramExit if the API server returns an error response, or if either the service unit or the machine is removed or in error. """ address = unit_status = machine_id = machine_status = '' collected_machine_changes = [] watcher = env.watch_changes(watchers.unit_machine_changes) while True: try: unit_changes, machine_changes = watcher.next() except jujuclient.EnvError as err: raise ProgramExit( 'bad API server response: {}'.format(err.message)) # Process unit changes. for action, data in unit_changes: if data['Name'] == unit_name: try: data = watchers.parse_unit_change( action, data, unit_status, address) except ValueError as err: raise ProgramExit(bytes(err)) unit_status, address, machine_id = data if address and unit_status == 'started': # We can exit this loop. return address # The mega-watcher contains a single change for each specific # unit. For this reason, we can exit the for loop here. break if not machine_id: # No need to process machine changes: we don't know what machine # the unit belongs to. However, machine changes are collected so # that they can be parsed later. collected_machine_changes.extend(machine_changes) continue # Process machine changes. Since relevant info can also be found # in previously collected changes, add those to the current changes, # in reverse order so that more complete info comes first. all_machine_changes = machine_changes + list( reversed(collected_machine_changes)) # At this point we can discard collected changes. collected_machine_changes = [] for action, data in all_machine_changes: if data['Id'] == machine_id: try: machine_status, address = watchers.parse_machine_change( action, data, machine_status, address) except ValueError as err: raise ProgramExit(bytes(err)) if address and unit_status == 'started': # We can exit this loop. return address # The mega-watcher contains a single change for each specific # machine. For this reason, we can exit the for loop here. break def deploy_bundle(env, bundle_yaml, bundle_name, bundle_id): """Deploy a bundle. Receive an API URL to a WebSocket server supporting bundle deployments, the admin_secret to use in the authentication process, the bundle YAML encoded contents and the bundle name to be imported. Raise a ProgramExit if the API server returns an error response. """ try: env.deploy_bundle(bundle_yaml, name=bundle_name, bundle_id=bundle_id) except jujuclient.EnvError as err: raise ProgramExit('bad API server response: {}'.format(err.message)) juju-quickstart-1.3.1/quickstart/__init__.py0000644000175000017500000000422612320545241022554 0ustar frankbanfrankban00000000000000# This file is part of the Juju Quickstart Plugin, which lets users set up a # Juju environment in very few steps (https://launchpad.net/juju-quickstart). # Copyright (C) 2013-2014 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License version 3, as published by # the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Juju Quickstart is a Juju plugin which allows for easily setting up a Juju environment in very few steps. The environment is bootstrapped and set up so that it can be managed using a Web interface (the Juju GUI). """ from __future__ import unicode_literals FEATURES = """ Features include the following: * New users are guided, as needed, to install Juju, set up SSH keys, and configure it for first use. * Juju environments can be created and managed from a command line interactive session. * The Juju GUI is automatically installed, adding no additional machines (installing on an existing state server when possible). * Bundles can be deployed, from local files, HTTP(S) URLs, or the charm store, so that a complete topology of services can be set up in one simple command. * Quickstart ends by opening the browser and automatically logging the user into the Juju GUI. * Users with a running Juju environment can run the quickstart command again to simply re-open the GUI without having to find the proper URL and password. To start Juju Quickstart, run the following: juju-quickstart [-i] Once Juju has been installed, the command can also be run as a juju plugin, without the hyphen ("juju quickstart"). """ VERSION = (1, 3, 1) def get_version(): """Return the Juju Quickstart version as a string.""" return '.'.join(map(unicode, VERSION)) juju-quickstart-1.3.1/PKG-INFO0000644000175000017500000000604212320751720017345 0ustar frankbanfrankban00000000000000Metadata-Version: 1.1 Name: juju-quickstart Version: 1.3.1 Summary: Juju Quickstart is a Juju plugin which allows for easily setting up a Juju environment in very few steps. The environment is bootstrapped and set up so that it can be managed using a Web interface (the Juju GUI). Home-page: https://launchpad.net/juju-quickstart Author: The Juju GUI team Author-email: juju-gui@lists.ubuntu.com License: UNKNOWN Description: Juju Quickstart =============== Juju Quickstart is an opinionated command-line tool that quickly starts Juju and the GUI, whether you've never installed Juju or you have an existing Juju environment running. Features include the following: * New users are guided, as needed, to install Juju, set up SSH keys, and configure it for first use. * Juju environments can be created and managed from a command line interactive session. * The Juju GUI is automatically installed, adding no additional machines (installing on an existing state server when possible). * Bundles can be deployed, from local files, HTTP(S) URLs, or the charm store, so that a complete topology of services can be set up in one simple command. * Quickstart ends by opening the browser and automatically logging the user into the GUI, to observe and manage the environment visually. * Users with a running Juju environment can run the quickstart command again to simply re-open the GUI without having to find the proper URL and password. To start Juju Quickstart, run the following:: juju-quickstart [-i] Run ``juju-quickstart -h`` for a list of all the available options. Once Juju has been installed, the command can also be run as a juju plugin, without the hyphen (``juju quickstart``). Supported Versions ------------------ Juju Quickstart is available on Ubuntu releases 12.04 LTS (precise), 13.10 (saucy), and 14.04 LTS (trusty). For installation on precise and saucy, you'll need to enable the Juju PPA by first executing:: sudo add-apt-repository ppa:juju/stable sudo apt-get update sudo apt-get install juju-quickstart For trusty the PPA is not required and you simply need to install it with:: sudo apt-get install juju-quickstart Alternatively you may install Juju Quickstart via pip with:: pip install juju-quickstart Keywords: juju quickstart plugin Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Web Environment Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: GNU Affero General Public License v3 Classifier: Operating System :: POSIX Classifier: Programming Language :: Python Classifier: Topic :: System :: Installation/Setup