From d5490d27948b37f156b01c7da4208c078b6ee703 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 30 Jun 2015 10:04:27 -0500 Subject: [PATCH 001/569] New project --- README.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..ab5e61ea --- /dev/null +++ b/README.rst @@ -0,0 +1,11 @@ +Snappy Device Agents +#################### + +Device agents scripts for provisioning and running tests on Snappy +devices + +Only BeagleBone Black is supported for the moment, and for provisioning +to work properly, things have to be set up in a very specific way. Once +we have devices in the lab, this will be made a little more generic, but +will probably always require specialized hardware to be fully automated. + From 8c6bf4f5edc6383d0a7053c9d04e33adc8cba07d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 30 Jun 2015 11:19:40 -0500 Subject: [PATCH 002/569] Basic provisioning and test running code for BBB For now, this just supports adt-run, but can easily be extended to support other test runners. --- bbb/constants.py | 25 ++++++++ bbb/provision | 144 +++++++++++++++++++++++++++++++++++++++++++++++ bbb/runtest | 56 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 bbb/constants.py create mode 100755 bbb/provision create mode 100755 bbb/runtest diff --git a/bbb/constants.py b/bbb/constants.py new file mode 100644 index 00000000..35be28e5 --- /dev/null +++ b/bbb/constants.py @@ -0,0 +1,25 @@ +# Local constants used for device-specific information +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# USER@IP where we can ssh to control power and boot relays +CONTROL_HOST = 'pi@192.168.1.135' + +# USER@IP of the test device, this should almost always use ubuntu@ and +# ssh keys need to be established ahead of time +TEST_USER = 'ubuntu' +TEST_HOST = '192.168.1.147' +TEST_USER_HOST = '{}@{}'.format(TEST_USER, TEST_HOST) diff --git a/bbb/provision b/bbb/provision new file mode 100755 index 00000000..88d82517 --- /dev/null +++ b/bbb/provision @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import argparse +import subprocess +import time + +import constants + + +def setboot(mode): + if mode not in ('master', 'test'): + raise KeyError + cmd = ['ssh', constants.CONTROL_HOST, 'bin/setboot', mode] + print("DEBUG: running {}".format(cmd)) + try: + subprocess.check_call(cmd, timeout=10) + except: + raise RuntimeError("timeout reaching control host!") + + +def hardreset(): + cmd = ['ssh', constants.CONTROL_HOST, 'bin/hardreset'] + print("DEBUG: running {}".format(cmd)) + try: + subprocess.check_call(cmd, timeout=20) + except: + raise RuntimeError("timeout reaching control host!") + + +def ensure_test_image(): + # FIXME: I don't have a great way to ensure we're in the test image + # yet, so just check that we're *not* in the emmc image + print("DEBUG: Booting the test image") + cmd = ['ssh', constants.TEST_USER_HOST, 'sudo /sbin/halt'] + try: + subprocess.check_call(cmd) + except: + pass + time.sleep(60) + setboot('test') + hardreset() + + emmc_booted = False + started = time.time() + while time.time() - started < 300: + try: + emmc_booted = is_emmc_image_booted() + except: + continue + break + # Check again if we are in the emmc image + if emmc_booted: + # XXX: This should *never* happen since we set the boot mode! + raise RuntimeError("Still booting to emmc after flashing image!") + + +def is_emmc_image_booted(): + cmd = ['ssh', constants.TEST_USER_HOST, 'cat /etc/issue'] + # FIXME: come up with a better way of checking this + output = subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=10) + if 'BeagleBoardUbuntu' in str(output): + return True + return False + + +def ensure_emmc_image(): + """Make sure we are running the emmc image + """ + emmc_booted = False + print("DEBUG: Making sure the emmc image is booted") + try: + emmc_booted = is_emmc_image_booted() + except: + # don't worry if this doesn't work, we'll hard reset later + pass + + if not emmc_booted: + # We are not in the emmc image, so just hard reset + setboot('master') + hardreset() + + started = time.time() + while time.time() - started < 300: + try: + emmc_booted = is_emmc_image_booted() + except: + continue + break + # Check again if we are in the emmc image + if not emmc_booted: + raise RuntimeError("Could not reboot to emmc!") + + +def flash_sd(image_url): + """Flash the image at :image_url to the sd card + """ + cmd = ['ssh', constants.TEST_USER_HOST, + 'curl {} | gunzip| sudo dd of=/dev/mmcblk0 bs=32M'.format( + image_url)] + print("DEBUG: running {}".format(cmd)) + try: + # XXX: I hope 30 min is enough? but maybe not! + subprocess.check_call(cmd, timeout=1800) + except: + raise RuntimeError("timeout reached while flashing image!") + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument('-i', '--image-url', required=True, + help='URL of the image to install') + args = parser.parse_args() + return args + + +def main(): + args = parse_args() + + print("DEBUG: ensure_emmc_image") + ensure_emmc_image() + print("DEBUG: flash_sd") + flash_sd(args.image_url) + print("DEBUG: ensure_test_image") + ensure_test_image() + +if __name__ == "__main__": + main() diff --git a/bbb/runtest b/bbb/runtest new file mode 100755 index 00000000..f4302e2a --- /dev/null +++ b/bbb/runtest @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import argparse +import os +import subprocess +import tempfile + +import constants + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument('-u', '--test-url', required=True, + help='URL of the test tarball') + args = parser.parse_args() + return args + + +def main(): + args = parse_args() + with tempfile.TemporaryDirectory() as tmpdir: + filename = os.path.split(args.test_url)[1] + testdir = os.path.join(tmpdir, 'test') + # XXX: Agent should pass us the output location + outputdir = '/tmp/output' + + subprocess.check_output(['wget', args.test_url], cwd=tmpdir) + os.makedirs(testdir) + # Extraction directory does not get renamed, and test_cmd + # gets run from one level above + subprocess.check_output(['tar', '-xf', filename, '-C', testdir, + '--strip-components=1'], cwd=tmpdir) + test_cmd = ['adt-run', '--built-tree', testdir, '--output-dir', + outputdir, '---', 'ssh', '-d', '-l', 'ubuntu', '-P', + 'ubuntu', '-H', constants.TEST_HOST] + subprocess.check_output(test_cmd, cwd=tmpdir) + + +if __name__ == '__main__': + main() From db05835d7fc162253443db2b88d5b714f5242a57 Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Tue, 30 Jun 2015 18:21:16 +0200 Subject: [PATCH 003/569] Add AUTHORS Signed-off-by: Zygmunt Krynicki --- AUTHORS | 1 + 1 file changed, 1 insertion(+) create mode 100644 AUTHORS diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 00000000..19e3ab33 --- /dev/null +++ b/AUTHORS @@ -0,0 +1 @@ +Paul Larson From 22fd2a5ef606ba187f45a82ac1c877ff48c8063d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 30 Jun 2015 11:30:33 -0500 Subject: [PATCH 004/569] Add COPYING and make gpl3 only --- COPYING | 674 +++++++++++++++++++++++++++++++++++++++++++++++ bbb/constants.py | 3 +- bbb/provision | 4 +- bbb/runtest | 3 +- 4 files changed, 678 insertions(+), 6 deletions(-) create mode 100644 COPYING diff --git a/COPYING b/COPYING new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/bbb/constants.py b/bbb/constants.py index 35be28e5..53e5dbb8 100644 --- a/bbb/constants.py +++ b/bbb/constants.py @@ -3,8 +3,7 @@ # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. +# the Free Software Foundation, either version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of diff --git a/bbb/provision b/bbb/provision index 88d82517..93d9d773 100755 --- a/bbb/provision +++ b/bbb/provision @@ -4,8 +4,8 @@ # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. +# the Free Software Foundation, either version 3 of the License. + # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of diff --git a/bbb/runtest b/bbb/runtest index f4302e2a..f4e10541 100755 --- a/bbb/runtest +++ b/bbb/runtest @@ -4,8 +4,7 @@ # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. +# the Free Software Foundation, either version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of From 00c130fb526de0e2198d66ed60f06903d79998ae Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 30 Jun 2015 12:28:33 -0500 Subject: [PATCH 005/569] Small improvements for testing merges * Add a simple setup.py * Add a tarmac-verify script * Rename bbb scripts --- bbb/{provision => bbb-provision} | 0 bbb/{runtest => bbb-runtest} | 0 setup.py | 40 ++++++++++++++++++++++++++++++++ tarmac-verify | 9 +++++++ test_requirements.txt | 1 + 5 files changed, 50 insertions(+) rename bbb/{provision => bbb-provision} (100%) rename bbb/{runtest => bbb-runtest} (100%) create mode 100755 setup.py create mode 100755 tarmac-verify create mode 100644 test_requirements.txt diff --git a/bbb/provision b/bbb/bbb-provision similarity index 100% rename from bbb/provision rename to bbb/bbb-provision diff --git a/bbb/runtest b/bbb/bbb-runtest similarity index 100% rename from bbb/runtest rename to bbb/bbb-runtest diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..d51bc886 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys +assert sys.version_info >= (3,), 'Python 3 is required' + +from setuptools import ( + find_packages, + setup, +) + + +VERSION = '1.0.0' + + +setup( + name='snappy-device-agents', + version=VERSION, + description=('Device agents scripts for provisioning and running ' + 'tests on Snappy devices'), + author='Snappy Device Agents Developers', + author_email='paul.larson@canonical.com', + url='https://launchpad.net/snappy-device-agents', + license='GPLv3', + packages=find_packages(), +) diff --git a/tarmac-verify b/tarmac-verify new file mode 100755 index 00000000..68709d01 --- /dev/null +++ b/tarmac-verify @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +rm -rf env +virtualenv -p python3 env +. env/bin/activate +pip install -r test_requirements.txt +./setup.py flake8 +rm -rf env diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 00000000..3af2c523 --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1 @@ +flake8==2.4.0 From d2083daa7119f0c3a37c2a91a4ef1b181723a2f3 Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Tue, 30 Jun 2015 20:01:10 +0200 Subject: [PATCH 006/569] Declare provisioning scripts Signed-off-by: Zygmunt Krynicki --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index d51bc886..37a4fb59 100755 --- a/setup.py +++ b/setup.py @@ -37,4 +37,8 @@ url='https://launchpad.net/snappy-device-agents', license='GPLv3', packages=find_packages(), + scripts=[ + 'bbb/provision', + 'bbb/runtest', + ], ) From 8c17b1a825eef8298a8e4f7feb9ed57ab2cd582c Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Tue, 30 Jun 2015 20:04:22 +0200 Subject: [PATCH 007/569] Rename "tarmac" test script Currently the project is not using tarmac but process-merge-request (which is a simpler version of tarmac that has less features but supports git). The way pmr works is that it looks at the .pmr-merge-hook and executes it. Signed-off-by: Zygmunt Krynicki --- tarmac-verify => .pmr-merge-hook | 1 + 1 file changed, 1 insertion(+) rename tarmac-verify => .pmr-merge-hook (71%) diff --git a/tarmac-verify b/.pmr-merge-hook similarity index 71% rename from tarmac-verify rename to .pmr-merge-hook index 68709d01..828e4a23 100755 --- a/tarmac-verify +++ b/.pmr-merge-hook @@ -1,4 +1,5 @@ #!/bin/sh +# This hook is executed for all incoming merge requests set -e rm -rf env From 0047af791a75a011b33a837e71b8a1830aff3363 Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Tue, 30 Jun 2015 20:07:29 +0200 Subject: [PATCH 008/569] Cleanup newlines in setup.py Signed-off-by: Zygmunt Krynicki --- setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.py b/setup.py index 37a4fb59..0ae3dc7a 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 - # Copyright (C) 2015 Canonical # # This program is free software: you can redistribute it and/or modify @@ -13,7 +12,6 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# import sys assert sys.version_info >= (3,), 'Python 3 is required' From cf533108c4d7854f5d01fb0321b90a352675cc22 Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Tue, 30 Jun 2015 20:07:41 +0200 Subject: [PATCH 009/569] Ship the support .py files needed by the scripts Signed-off-by: Zygmunt Krynicki --- MANIFEST.in | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b161398c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +# This is broken as bbb/ is not a module one can import +# It just makes it possible to create a working tarball. +include bbb/*.py From 1d587889c971312cdbf2f99d2acc2296a7320bb5 Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Tue, 30 Jun 2015 20:22:08 +0200 Subject: [PATCH 010/569] Remove extra newlines from the license Signed-off-by: Zygmunt Krynicki --- bbb/bbb-provision | 3 --- bbb/bbb-runtest | 2 -- 2 files changed, 5 deletions(-) diff --git a/bbb/bbb-provision b/bbb/bbb-provision index 93d9d773..6c96f328 100755 --- a/bbb/bbb-provision +++ b/bbb/bbb-provision @@ -1,11 +1,9 @@ #!/usr/bin/env python3 - # Copyright (C) 2015 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License. - # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of @@ -14,7 +12,6 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# import argparse import subprocess diff --git a/bbb/bbb-runtest b/bbb/bbb-runtest index f4e10541..a45b53c1 100755 --- a/bbb/bbb-runtest +++ b/bbb/bbb-runtest @@ -1,5 +1,4 @@ #!/usr/bin/env python3 - # Copyright (C) 2015 Canonical # # This program is free software: you can redistribute it and/or modify @@ -13,7 +12,6 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# import argparse import os From 19861bf0cf56d41f004e97a84eccba403f3d9895 Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Tue, 30 Jun 2015 20:26:47 +0200 Subject: [PATCH 011/569] Add or improve docstrings to everything Signed-off-by: Zygmunt Krynicki --- bbb/bbb-provision | 56 +++++++++++++++++++++++++++++++++++++++++++++-- bbb/bbb-runtest | 4 ++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/bbb/bbb-provision b/bbb/bbb-provision index 6c96f328..689eb86c 100755 --- a/bbb/bbb-provision +++ b/bbb/bbb-provision @@ -13,6 +13,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +"""Provisioning script for beagle bone black.""" + import argparse import subprocess import time @@ -21,6 +23,16 @@ import constants def setboot(mode): + """ + Set the boot mode of the device. + + :param mode: + One of 'master' or 'test' + :raises RuntimeError: + If the command times out or anything else fails. + + This method sets the snappy boot method to the specified value. + """ if mode not in ('master', 'test'): raise KeyError cmd = ['ssh', constants.CONTROL_HOST, 'bin/setboot', mode] @@ -32,6 +44,16 @@ def setboot(mode): def hardreset(): + """ + Reboot the device. + + :raises RuntimeError: + If the command times out or anything else fails. + + .. note:: + This function executes ``bin/hardreset`` which is not a part of a + standard image. You need to provide it yourself. + """ cmd = ['ssh', constants.CONTROL_HOST, 'bin/hardreset'] print("DEBUG: running {}".format(cmd)) try: @@ -41,6 +63,12 @@ def hardreset(): def ensure_test_image(): + """ + Actively switch the device to boot the test image. + + :raises RuntimeError: + If the command times out or anything else fails. + """ # FIXME: I don't have a great way to ensure we're in the test image # yet, so just check that we're *not* in the emmc image print("DEBUG: Booting the test image") @@ -68,6 +96,17 @@ def ensure_test_image(): def is_emmc_image_booted(): + """ + Check if the emmc image is booted. + + :returns: + True if the emmc image is currently booted, False otherwise. + :raises RuntimeError: + If the command times out or anything else fails. + + .. note:: + The emmc contains the non-test image. + """ cmd = ['ssh', constants.TEST_USER_HOST, 'cat /etc/issue'] # FIXME: come up with a better way of checking this output = subprocess.check_output( @@ -78,7 +117,11 @@ def is_emmc_image_booted(): def ensure_emmc_image(): - """Make sure we are running the emmc image + """ + Actively switch the device to boot the test image. + + :raises RuntimeError: + If the command times out or anything else fails. """ emmc_booted = False print("DEBUG: Making sure the emmc image is booted") @@ -106,7 +149,14 @@ def ensure_emmc_image(): def flash_sd(image_url): - """Flash the image at :image_url to the sd card + """ + Flash the image at :image_url to the sd card. + + :param image_url: + URL of the image to flash. The image has to be compatible with a flat + SD card layout. It will be downloaded and gunzipped over the SD card. + :raises RuntimeError: + If the command times out or anything else fails. """ cmd = ['ssh', constants.TEST_USER_HOST, 'curl {} | gunzip| sudo dd of=/dev/mmcblk0 bs=32M'.format( @@ -120,6 +170,7 @@ def flash_sd(image_url): def parse_args(): + """Parse command line arguments and return them.""" parser = argparse.ArgumentParser() parser.add_argument('-i', '--image-url', required=True, help='URL of the image to install') @@ -128,6 +179,7 @@ def parse_args(): def main(): + """Main function.""" args = parse_args() print("DEBUG: ensure_emmc_image") diff --git a/bbb/bbb-runtest b/bbb/bbb-runtest index a45b53c1..c5584fc9 100755 --- a/bbb/bbb-runtest +++ b/bbb/bbb-runtest @@ -13,6 +13,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +"""Testing script for beagle bone black.""" + import argparse import os import subprocess @@ -22,6 +24,7 @@ import constants def parse_args(): + """Parse command line arguments and return them.""" parser = argparse.ArgumentParser() parser.add_argument('-u', '--test-url', required=True, help='URL of the test tarball') @@ -30,6 +33,7 @@ def parse_args(): def main(): + """Main function.""" args = parse_args() with tempfile.TemporaryDirectory() as tmpdir: filename = os.path.split(args.test_url)[1] From b00351ca8aa66736fa51344aff1642871445d695 Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Tue, 30 Jun 2015 20:26:59 +0200 Subject: [PATCH 012/569] Fix indenting in parse_args() Signed-off-by: Zygmunt Krynicki --- bbb/bbb-runtest | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bbb/bbb-runtest b/bbb/bbb-runtest index c5584fc9..56a80da0 100755 --- a/bbb/bbb-runtest +++ b/bbb/bbb-runtest @@ -25,11 +25,11 @@ import constants def parse_args(): """Parse command line arguments and return them.""" - parser = argparse.ArgumentParser() - parser.add_argument('-u', '--test-url', required=True, - help='URL of the test tarball') - args = parser.parse_args() - return args + parser = argparse.ArgumentParser() + parser.add_argument('-u', '--test-url', required=True, + help='URL of the test tarball') + args = parser.parse_args() + return args def main(): From ea0212f1660d09fe37067caa349a30413af5ad2d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 1 Jul 2015 15:14:33 -0500 Subject: [PATCH 013/569] Refactor agent to use subcommands Use guacamole to make device agents pluggable into an agent command with subcommands for provisioning, running tests, or whatever else they need to do. --- MANIFEST.in | 3 - agent | 20 ++++ devices/__init__.py | 20 ++++ devices/bbb/__init__.py | 85 +++++++++++++++ {bbb => devices/bbb}/bbb-provision | 0 {bbb => devices/bbb}/bbb-runtest | 0 devices/bbb/beagleboneblack.py | 167 +++++++++++++++++++++++++++++ {bbb => devices/bbb}/constants.py | 0 setup.py | 8 +- snappy_device_agents/__init__.py | 13 +++ snappy_device_agents/cmd.py | 35 ++++++ 11 files changed, 343 insertions(+), 8 deletions(-) delete mode 100644 MANIFEST.in create mode 100755 agent create mode 100644 devices/__init__.py create mode 100644 devices/bbb/__init__.py rename {bbb => devices/bbb}/bbb-provision (100%) rename {bbb => devices/bbb}/bbb-runtest (100%) create mode 100644 devices/bbb/beagleboneblack.py rename {bbb => devices/bbb}/constants.py (100%) create mode 100644 snappy_device_agents/__init__.py create mode 100755 snappy_device_agents/cmd.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b161398c..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -# This is broken as bbb/ is not a module one can import -# It just makes it possible to create a working tarball. -include bbb/*.py diff --git a/agent b/agent new file mode 100755 index 00000000..ef542e3b --- /dev/null +++ b/agent @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from snappy_device_agents.cmd import main + +if __name__ == '__main__': + main() diff --git a/devices/__init__.py b/devices/__init__.py new file mode 100644 index 00000000..37884a73 --- /dev/null +++ b/devices/__init__.py @@ -0,0 +1,20 @@ +import imp +import os + + +def load_devices(): + devices = [] + device_path = os.path.dirname(os.path.realpath(__file__)) + devs = [os.path.join(device_path, device) + for device in os.listdir(device_path) + if os.path.isdir(os.path.join(device_path, device))] + for device in devs: + if '__pycache__' in device: + continue + module = imp.load_source( + 'module', os.path.join(device, '__init__.py')) + devices.append((module.device_name, module.DeviceAgent)) + return tuple(devices) + +if __name__ == '__main__': + load_devices() diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py new file mode 100644 index 00000000..0e09476c --- /dev/null +++ b/devices/bbb/__init__.py @@ -0,0 +1,85 @@ +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Beagle Bone Black support code.""" + +import os +import subprocess +import tempfile + +import guacamole + +from devices.bbb.beagleboneblack import BeagleBoneBlack +from . import constants + +device_name = "bbb" + + +class provision(guacamole.Command): + + """Tool for provisioning beagle bone black with a given image.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + device = BeagleBoneBlack() + print("DEBUG: ensure_emmc_image") + device.ensure_emmc_image() + print("DEBUG: flash_sd") + device.flash_sd(ctx.args.image_url) + print("DEBUG: ensure_test_image") + device.ensure_test_image() + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-i', '--image-url', required=True, + help='URL of the image to install') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with tempfile.TemporaryDirectory() as tmpdir: + filename = os.path.split(ctx.args.test_url)[1] + testdir = os.path.join(tmpdir, 'test') + # XXX: Agent should pass us the output location + outputdir = '/tmp/output' + + subprocess.check_output(['wget', ctx.args.test_url], cwd=tmpdir) + os.makedirs(testdir) + # Extraction directory does not get renamed, and test_cmd + # gets run from one level above + subprocess.check_output(['tar', '-xf', filename, '-C', testdir, + '--strip-components=1'], cwd=tmpdir) + test_cmd = ['adt-run', '--built-tree', testdir, '--output-dir', + outputdir, '---', 'ssh', '-d', '-l', 'ubuntu', '-P', + 'ubuntu', '-H', constants.TEST_HOST] + subprocess.check_output(test_cmd, cwd=tmpdir) + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-u', '--test-url', required=True, + help='URL of the test tarball') + + +class DeviceAgent(guacamole.Command): + + """Device agent for BeagleBone Black.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/bbb/bbb-provision b/devices/bbb/bbb-provision similarity index 100% rename from bbb/bbb-provision rename to devices/bbb/bbb-provision diff --git a/bbb/bbb-runtest b/devices/bbb/bbb-runtest similarity index 100% rename from bbb/bbb-runtest rename to devices/bbb/bbb-runtest diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py new file mode 100644 index 00000000..54b79aa5 --- /dev/null +++ b/devices/bbb/beagleboneblack.py @@ -0,0 +1,167 @@ +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Beagle Bone Black support code.""" + +import subprocess +import time + +from . import constants + + +class BeagleBoneBlack: + + """Snappy Device Agent for Beagle Bone Black.""" + + def setboot(self, mode): + """ + Set the boot mode of the device. + + :param mode: + One of 'master' or 'test' + :raises RuntimeError: + If the command times out or anything else fails. + + This method sets the snappy boot method to the specified value. + """ + if mode not in ('master', 'test'): + raise KeyError + cmd = ['ssh', constants.CONTROL_HOST, 'bin/setboot', mode] + print("DEBUG: running {}".format(cmd)) + try: + subprocess.check_call(cmd, timeout=10) + except: + raise RuntimeError("timeout reaching control host!") + + def hardreset(self): + """ + Reboot the device. + + :raises RuntimeError: + If the command times out or anything else fails. + + .. note:: + This function executes ``bin/hardreset`` which is not a part of a + standard image. You need to provide it yourself. + """ + cmd = ['ssh', constants.CONTROL_HOST, 'bin/hardreset'] + print("DEBUG: running {}".format(cmd)) + try: + subprocess.check_call(cmd, timeout=20) + except: + raise RuntimeError("timeout reaching control host!") + + def ensure_test_image(self): + """ + Actively switch the device to boot the test image. + + :raises RuntimeError: + If the command times out or anything else fails. + """ + # FIXME: I don't have a great way to ensure we're in the test image + # yet, so just check that we're *not* in the emmc image + print("DEBUG: Booting the test image") + cmd = ['ssh', constants.TEST_USER_HOST, 'sudo /sbin/halt'] + try: + subprocess.check_call(cmd) + except: + pass + time.sleep(60) + self.setboot('test') + self.hardreset() + + emmc_booted = False + started = time.time() + while time.time() - started < 300: + try: + emmc_booted = self.is_emmc_image_booted() + except: + continue + break + # Check again if we are in the emmc image + if emmc_booted: + # XXX: This should *never* happen since we set the boot mode! + raise RuntimeError("Still booting to emmc after flashing image!") + + def is_emmc_image_booted(self): + """ + Check if the emmc image is booted. + + :returns: + True if the emmc image is currently booted, False otherwise. + :raises RuntimeError: + If the command times out or anything else fails. + + .. note:: + The emmc contains the non-test image. + """ + cmd = ['ssh', constants.TEST_USER_HOST, 'cat /etc/issue'] + # FIXME: come up with a better way of checking this + output = subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=10) + if 'BeagleBoardUbuntu' in str(output): + return True + return False + + def ensure_emmc_image(self): + """ + Actively switch the device to boot the test image. + + :raises RuntimeError: + If the command times out or anything else fails. + """ + emmc_booted = False + print("DEBUG: Making sure the emmc image is booted") + try: + emmc_booted = self.is_emmc_image_booted() + except: + # don't worry if this doesn't work, we'll hard reset later + pass + + if not emmc_booted: + # We are not in the emmc image, so just hard reset + self.setboot('master') + self.hardreset() + + started = time.time() + while time.time() - started < 300: + try: + emmc_booted = self.is_emmc_image_booted() + except: + continue + break + # Check again if we are in the emmc image + if not emmc_booted: + raise RuntimeError("Could not reboot to emmc!") + + def flash_sd(self, image_url): + """ + Flash the image at :image_url to the sd card. + + :param image_url: + URL of the image to flash. The image has to be compatible with a + flat SD card layout. It will be downloaded and gunzipped over the + SD card. + :raises RuntimeError: + If the command times out or anything else fails. + """ + cmd = ['ssh', constants.TEST_USER_HOST, + 'curl {} | gunzip| sudo dd of=/dev/mmcblk0 bs=32M'.format( + image_url)] + print("DEBUG: running {}".format(cmd)) + try: + # XXX: I hope 30 min is enough? but maybe not! + subprocess.check_call(cmd, timeout=1800) + except: + raise RuntimeError("timeout reached while flashing image!") diff --git a/bbb/constants.py b/devices/bbb/constants.py similarity index 100% rename from bbb/constants.py rename to devices/bbb/constants.py diff --git a/setup.py b/setup.py index 0ae3dc7a..8acc97a4 100755 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ ) -VERSION = '1.0.0' +VERSION = '0.0.1' setup( @@ -35,8 +35,6 @@ url='https://launchpad.net/snappy-device-agents', license='GPLv3', packages=find_packages(), - scripts=[ - 'bbb/provision', - 'bbb/runtest', - ], + install_requires=['guacamole >= 0.9'], + scripts=['agent'], ) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py new file mode 100644 index 00000000..25a48ee5 --- /dev/null +++ b/snappy_device_agents/__init__.py @@ -0,0 +1,13 @@ +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py new file mode 100755 index 00000000..62e62ecf --- /dev/null +++ b/snappy_device_agents/cmd.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from guacamole import Command + +from devices import load_devices + + +class Agent(Command): + """Main agent command + + This loads subcommands from modules in the devices directory + """ + sub_commands = load_devices() + + def invoked(self, ctx): + print(ctx.parser.format_help()) + exit(1) + + +def main(): + Agent().main() From f1612c6118ad929f48f82794c31213e62e04b69e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Jul 2015 08:37:26 -0500 Subject: [PATCH 014/569] Remove bbb-provision and bbb-runtime --- devices/bbb/bbb-provision | 193 -------------------------------------- devices/bbb/bbb-runtest | 57 ----------- 2 files changed, 250 deletions(-) delete mode 100755 devices/bbb/bbb-provision delete mode 100755 devices/bbb/bbb-runtest diff --git a/devices/bbb/bbb-provision b/devices/bbb/bbb-provision deleted file mode 100755 index 689eb86c..00000000 --- a/devices/bbb/bbb-provision +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provisioning script for beagle bone black.""" - -import argparse -import subprocess -import time - -import constants - - -def setboot(mode): - """ - Set the boot mode of the device. - - :param mode: - One of 'master' or 'test' - :raises RuntimeError: - If the command times out or anything else fails. - - This method sets the snappy boot method to the specified value. - """ - if mode not in ('master', 'test'): - raise KeyError - cmd = ['ssh', constants.CONTROL_HOST, 'bin/setboot', mode] - print("DEBUG: running {}".format(cmd)) - try: - subprocess.check_call(cmd, timeout=10) - except: - raise RuntimeError("timeout reaching control host!") - - -def hardreset(): - """ - Reboot the device. - - :raises RuntimeError: - If the command times out or anything else fails. - - .. note:: - This function executes ``bin/hardreset`` which is not a part of a - standard image. You need to provide it yourself. - """ - cmd = ['ssh', constants.CONTROL_HOST, 'bin/hardreset'] - print("DEBUG: running {}".format(cmd)) - try: - subprocess.check_call(cmd, timeout=20) - except: - raise RuntimeError("timeout reaching control host!") - - -def ensure_test_image(): - """ - Actively switch the device to boot the test image. - - :raises RuntimeError: - If the command times out or anything else fails. - """ - # FIXME: I don't have a great way to ensure we're in the test image - # yet, so just check that we're *not* in the emmc image - print("DEBUG: Booting the test image") - cmd = ['ssh', constants.TEST_USER_HOST, 'sudo /sbin/halt'] - try: - subprocess.check_call(cmd) - except: - pass - time.sleep(60) - setboot('test') - hardreset() - - emmc_booted = False - started = time.time() - while time.time() - started < 300: - try: - emmc_booted = is_emmc_image_booted() - except: - continue - break - # Check again if we are in the emmc image - if emmc_booted: - # XXX: This should *never* happen since we set the boot mode! - raise RuntimeError("Still booting to emmc after flashing image!") - - -def is_emmc_image_booted(): - """ - Check if the emmc image is booted. - - :returns: - True if the emmc image is currently booted, False otherwise. - :raises RuntimeError: - If the command times out or anything else fails. - - .. note:: - The emmc contains the non-test image. - """ - cmd = ['ssh', constants.TEST_USER_HOST, 'cat /etc/issue'] - # FIXME: come up with a better way of checking this - output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=10) - if 'BeagleBoardUbuntu' in str(output): - return True - return False - - -def ensure_emmc_image(): - """ - Actively switch the device to boot the test image. - - :raises RuntimeError: - If the command times out or anything else fails. - """ - emmc_booted = False - print("DEBUG: Making sure the emmc image is booted") - try: - emmc_booted = is_emmc_image_booted() - except: - # don't worry if this doesn't work, we'll hard reset later - pass - - if not emmc_booted: - # We are not in the emmc image, so just hard reset - setboot('master') - hardreset() - - started = time.time() - while time.time() - started < 300: - try: - emmc_booted = is_emmc_image_booted() - except: - continue - break - # Check again if we are in the emmc image - if not emmc_booted: - raise RuntimeError("Could not reboot to emmc!") - - -def flash_sd(image_url): - """ - Flash the image at :image_url to the sd card. - - :param image_url: - URL of the image to flash. The image has to be compatible with a flat - SD card layout. It will be downloaded and gunzipped over the SD card. - :raises RuntimeError: - If the command times out or anything else fails. - """ - cmd = ['ssh', constants.TEST_USER_HOST, - 'curl {} | gunzip| sudo dd of=/dev/mmcblk0 bs=32M'.format( - image_url)] - print("DEBUG: running {}".format(cmd)) - try: - # XXX: I hope 30 min is enough? but maybe not! - subprocess.check_call(cmd, timeout=1800) - except: - raise RuntimeError("timeout reached while flashing image!") - - -def parse_args(): - """Parse command line arguments and return them.""" - parser = argparse.ArgumentParser() - parser.add_argument('-i', '--image-url', required=True, - help='URL of the image to install') - args = parser.parse_args() - return args - - -def main(): - """Main function.""" - args = parse_args() - - print("DEBUG: ensure_emmc_image") - ensure_emmc_image() - print("DEBUG: flash_sd") - flash_sd(args.image_url) - print("DEBUG: ensure_test_image") - ensure_test_image() - -if __name__ == "__main__": - main() diff --git a/devices/bbb/bbb-runtest b/devices/bbb/bbb-runtest deleted file mode 100755 index 56a80da0..00000000 --- a/devices/bbb/bbb-runtest +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Testing script for beagle bone black.""" - -import argparse -import os -import subprocess -import tempfile - -import constants - - -def parse_args(): - """Parse command line arguments and return them.""" - parser = argparse.ArgumentParser() - parser.add_argument('-u', '--test-url', required=True, - help='URL of the test tarball') - args = parser.parse_args() - return args - - -def main(): - """Main function.""" - args = parse_args() - with tempfile.TemporaryDirectory() as tmpdir: - filename = os.path.split(args.test_url)[1] - testdir = os.path.join(tmpdir, 'test') - # XXX: Agent should pass us the output location - outputdir = '/tmp/output' - - subprocess.check_output(['wget', args.test_url], cwd=tmpdir) - os.makedirs(testdir) - # Extraction directory does not get renamed, and test_cmd - # gets run from one level above - subprocess.check_output(['tar', '-xf', filename, '-C', testdir, - '--strip-components=1'], cwd=tmpdir) - test_cmd = ['adt-run', '--built-tree', testdir, '--output-dir', - outputdir, '---', 'ssh', '-d', '-l', 'ubuntu', '-P', - 'ubuntu', '-H', constants.TEST_HOST] - subprocess.check_output(test_cmd, cwd=tmpdir) - - -if __name__ == '__main__': - main() From e1718204758bbfb0b05e23efe8d0a87228c7ea01 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Jul 2015 08:38:03 -0500 Subject: [PATCH 015/569] rename agent to snappy-device-agent --- setup.py | 2 +- agent => snappy-device-agent | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename agent => snappy-device-agent (100%) diff --git a/setup.py b/setup.py index 8acc97a4..bb526047 100755 --- a/setup.py +++ b/setup.py @@ -36,5 +36,5 @@ license='GPLv3', packages=find_packages(), install_requires=['guacamole >= 0.9'], - scripts=['agent'], + scripts=['snappy-device-agent'], ) diff --git a/agent b/snappy-device-agent similarity index 100% rename from agent rename to snappy-device-agent From b2eae963f67a3140f7a37e931cece18b6426dd48 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Jul 2015 08:44:09 -0500 Subject: [PATCH 016/569] Remove the invoke for the main command for now This needs to be removed for now until there is a fix for https://github.com/zyga/guacamole/issues/4 --- snappy_device_agents/cmd.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py index 62e62ecf..93c3d914 100755 --- a/snappy_device_agents/cmd.py +++ b/snappy_device_agents/cmd.py @@ -26,9 +26,12 @@ class Agent(Command): """ sub_commands = load_devices() + # XXX: Remove for now due to https://github.com/zyga/guacamole/issues/4 + """ def invoked(self, ctx): print(ctx.parser.format_help()) exit(1) + """ def main(): From 11b4528edada4c23a5d57ead957dec5dfba7793e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 14 Jul 2015 14:15:50 -0500 Subject: [PATCH 017/569] Use a config file for device specific information This adds support for a yaml config file to load device specifics such as the address, and methods for controlling the device. --- README.rst | 28 +++++++++++++++++++++ devices/bbb/__init__.py | 14 ++++++++--- devices/bbb/beagleboneblack.py | 45 ++++++++++++++++++++-------------- devices/bbb/constants.py | 24 ------------------ setup.py | 2 +- 5 files changed, 67 insertions(+), 46 deletions(-) delete mode 100644 devices/bbb/constants.py diff --git a/README.rst b/README.rst index ab5e61ea..1f7d893f 100644 --- a/README.rst +++ b/README.rst @@ -4,8 +4,36 @@ Snappy Device Agents Device agents scripts for provisioning and running tests on Snappy devices +Supported Devices +================= + Only BeagleBone Black is supported for the moment, and for provisioning to work properly, things have to be set up in a very specific way. Once we have devices in the lab, this will be made a little more generic, but will probably always require specialized hardware to be fully automated. +BeagleBone Black +---------------- + +To use snappy-device-agent with a BeagleBone, you will need to create a +config file to give it some hints about your environment. The config +file for BeagleBone needs to have the address (host or ip) of you test +system once it boots, a list of commands to force it to boot the master +(emmc) image, a list of commands to force it to boot the test (snappy) +image, and a list of commands to force a hard poweroff/poweron. + +If you have a very simple setup, these command scripts could be as +simple as a script to run over ssh. In a production environment, it +could be calling a REST API to trigger a relay and force these things. +Different devices can even use different config files. The config file +gives you the flexibility to define what works for this particular device. + +Example:: + + address: 192.168.1.147 + select_master_script: + - ssh pi@192.168.1.136 bin/setboot master + select_test_script: + - ssh pi@192.168.1.136 bin/setboot test + reboot_script: + - ssh pi@192.168.1.136 bin/hardreset diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 0e09476c..5177c450 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -17,11 +17,11 @@ import os import subprocess import tempfile +import yaml import guacamole from devices.bbb.beagleboneblack import BeagleBoneBlack -from . import constants device_name = "bbb" @@ -32,7 +32,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" - device = BeagleBoneBlack() + device = BeagleBoneBlack(ctx.args.config) print("DEBUG: ensure_emmc_image") device.ensure_emmc_image() print("DEBUG: flash_sd") @@ -42,6 +42,8 @@ def invoked(self, ctx): def register_arguments(self, parser): """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') parser.add_argument('-i', '--image-url', required=True, help='URL of the image to install') @@ -52,6 +54,10 @@ class runtest(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + test_host = config['address'] + with tempfile.TemporaryDirectory() as tmpdir: filename = os.path.split(ctx.args.test_url)[1] testdir = os.path.join(tmpdir, 'test') @@ -66,11 +72,13 @@ def invoked(self, ctx): '--strip-components=1'], cwd=tmpdir) test_cmd = ['adt-run', '--built-tree', testdir, '--output-dir', outputdir, '---', 'ssh', '-d', '-l', 'ubuntu', '-P', - 'ubuntu', '-H', constants.TEST_HOST] + 'ubuntu', '-H', test_host] subprocess.check_output(test_cmd, cwd=tmpdir) def register_arguments(self, parser): """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') parser.add_argument('-u', '--test-url', required=True, help='URL of the test tarball') diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py index 54b79aa5..35ec0bce 100644 --- a/devices/bbb/beagleboneblack.py +++ b/devices/bbb/beagleboneblack.py @@ -16,14 +16,17 @@ import subprocess import time - -from . import constants +import yaml class BeagleBoneBlack: """Snappy Device Agent for Beagle Bone Black.""" + def __init__(self, config): + with open(config) as configfile: + self.config = yaml.load(configfile) + def setboot(self, mode): """ Set the boot mode of the device. @@ -35,14 +38,18 @@ def setboot(self, mode): This method sets the snappy boot method to the specified value. """ - if mode not in ('master', 'test'): + if mode == 'master': + setboot_script = self.config['select_master_script'] + elif mode == 'test': + setboot_script = self.config['select_test_script'] + else: raise KeyError - cmd = ['ssh', constants.CONTROL_HOST, 'bin/setboot', mode] - print("DEBUG: running {}".format(cmd)) - try: - subprocess.check_call(cmd, timeout=10) - except: - raise RuntimeError("timeout reaching control host!") + for cmd in setboot_script: + print("DEBUG: running {}".format(cmd)) + try: + subprocess.check_call(cmd.split(), timeout=10) + except: + raise RuntimeError("timeout reaching control host!") def hardreset(self): """ @@ -55,12 +62,12 @@ def hardreset(self): This function executes ``bin/hardreset`` which is not a part of a standard image. You need to provide it yourself. """ - cmd = ['ssh', constants.CONTROL_HOST, 'bin/hardreset'] - print("DEBUG: running {}".format(cmd)) - try: - subprocess.check_call(cmd, timeout=20) - except: - raise RuntimeError("timeout reaching control host!") + for cmd in self.config['reboot_script']: + print("DEBUG: running {}".format(cmd)) + try: + subprocess.check_call(cmd.split(), timeout=20) + except: + raise RuntimeError("timeout reaching control host!") def ensure_test_image(self): """ @@ -72,7 +79,8 @@ def ensure_test_image(self): # FIXME: I don't have a great way to ensure we're in the test image # yet, so just check that we're *not* in the emmc image print("DEBUG: Booting the test image") - cmd = ['ssh', constants.TEST_USER_HOST, 'sudo /sbin/halt'] + cmd = ['ssh', 'ubuntu@{}'.format(self.config['address']), + 'sudo /sbin/halt'] try: subprocess.check_call(cmd) except: @@ -106,7 +114,8 @@ def is_emmc_image_booted(self): .. note:: The emmc contains the non-test image. """ - cmd = ['ssh', constants.TEST_USER_HOST, 'cat /etc/issue'] + cmd = ['ssh', 'ubuntu@{}'.format(self.config['address']), + 'cat /etc/issue'] # FIXME: come up with a better way of checking this output = subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=10) @@ -156,7 +165,7 @@ def flash_sd(self, image_url): :raises RuntimeError: If the command times out or anything else fails. """ - cmd = ['ssh', constants.TEST_USER_HOST, + cmd = ['ssh', 'ubuntu@{}'.format(self.config['address']), 'curl {} | gunzip| sudo dd of=/dev/mmcblk0 bs=32M'.format( image_url)] print("DEBUG: running {}".format(cmd)) diff --git a/devices/bbb/constants.py b/devices/bbb/constants.py deleted file mode 100644 index 53e5dbb8..00000000 --- a/devices/bbb/constants.py +++ /dev/null @@ -1,24 +0,0 @@ -# Local constants used for device-specific information -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# - -# USER@IP where we can ssh to control power and boot relays -CONTROL_HOST = 'pi@192.168.1.135' - -# USER@IP of the test device, this should almost always use ubuntu@ and -# ssh keys need to be established ahead of time -TEST_USER = 'ubuntu' -TEST_HOST = '192.168.1.147' -TEST_USER_HOST = '{}@{}'.format(TEST_USER, TEST_HOST) diff --git a/setup.py b/setup.py index bb526047..36aec58c 100755 --- a/setup.py +++ b/setup.py @@ -35,6 +35,6 @@ url='https://launchpad.net/snappy-device-agents', license='GPLv3', packages=find_packages(), - install_requires=['guacamole >= 0.9'], + install_requires=['guacamole >= 0.9', 'PyYAML>=3.11'], scripts=['snappy-device-agent'], ) From 2148ad5967d58c312ecbd2736f13d19074b5a124 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 31 Jul 2015 10:57:06 -0500 Subject: [PATCH 018/569] Add a helper function for reading the spi test_opportunity data --- snappy_device_agents/__init__.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 25a48ee5..b2e72d72 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -11,3 +11,26 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . + +import json + + +def get_test_opportunity(spi_file='spi_test_opportunity.json'): + with open(spi_file, encoding='utf-8') as spi_json: + test_opportunity = json.load(spi_json) + # test_payload and image_reference may contain json in a string + # XXX: This can be removed in the future when arbitrary json is + # supported + try: + test_opportunity['test_payload'] = json.loads( + test_opportunity['test_payload']) + except: + # If this fails, we simply leave the field alone + pass + try: + test_opportunity['image_reference'] = json.loads( + test_opportunity['image_reference']) + except: + # If this fails, we simply leave the field alone + pass + return test_opportunity From 02b3e6144fa343fa67cf3faf0b6b643b40c0e829 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 31 Jul 2015 12:14:05 -0500 Subject: [PATCH 019/569] Use logging --- devices/bbb/__init__.py | 7 ++++--- devices/bbb/beagleboneblack.py | 11 ++++++----- snappy_device_agents/cmd.py | 5 +++++ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 5177c450..0d6c551a 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -14,6 +14,7 @@ """Beagle Bone Black support code.""" +import logging import os import subprocess import tempfile @@ -33,11 +34,11 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" device = BeagleBoneBlack(ctx.args.config) - print("DEBUG: ensure_emmc_image") + logging.info("ensure_emmc_image") device.ensure_emmc_image() - print("DEBUG: flash_sd") + logging.info("flash_sd") device.flash_sd(ctx.args.image_url) - print("DEBUG: ensure_test_image") + logging.info("ensure_test_image") device.ensure_test_image() def register_arguments(self, parser): diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py index 35ec0bce..630591f7 100644 --- a/devices/bbb/beagleboneblack.py +++ b/devices/bbb/beagleboneblack.py @@ -14,6 +14,7 @@ """Beagle Bone Black support code.""" +import logging import subprocess import time import yaml @@ -45,7 +46,7 @@ def setboot(self, mode): else: raise KeyError for cmd in setboot_script: - print("DEBUG: running {}".format(cmd)) + logging.info("running {}".format(cmd)) try: subprocess.check_call(cmd.split(), timeout=10) except: @@ -63,7 +64,7 @@ def hardreset(self): standard image. You need to provide it yourself. """ for cmd in self.config['reboot_script']: - print("DEBUG: running {}".format(cmd)) + logging.info("running {}".format(cmd)) try: subprocess.check_call(cmd.split(), timeout=20) except: @@ -78,7 +79,7 @@ def ensure_test_image(self): """ # FIXME: I don't have a great way to ensure we're in the test image # yet, so just check that we're *not* in the emmc image - print("DEBUG: Booting the test image") + logging.info("Booting the test image") cmd = ['ssh', 'ubuntu@{}'.format(self.config['address']), 'sudo /sbin/halt'] try: @@ -131,7 +132,7 @@ def ensure_emmc_image(self): If the command times out or anything else fails. """ emmc_booted = False - print("DEBUG: Making sure the emmc image is booted") + logging.info("Making sure the emmc image is booted") try: emmc_booted = self.is_emmc_image_booted() except: @@ -168,7 +169,7 @@ def flash_sd(self, image_url): cmd = ['ssh', 'ubuntu@{}'.format(self.config['address']), 'curl {} | gunzip| sudo dd of=/dev/mmcblk0 bs=32M'.format( image_url)] - print("DEBUG: running {}".format(cmd)) + logging.info("running {}".format(cmd)) try: # XXX: I hope 30 min is enough? but maybe not! subprocess.check_call(cmd, timeout=1800) diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py index 93c3d914..197167a0 100755 --- a/snappy_device_agents/cmd.py +++ b/snappy_device_agents/cmd.py @@ -14,6 +14,8 @@ # along with this program. If not, see . +import logging + from guacamole import Command from devices import load_devices @@ -24,6 +26,8 @@ class Agent(Command): This loads subcommands from modules in the devices directory """ + spices = ['log:arguments'] + sub_commands = load_devices() # XXX: Remove for now due to https://github.com/zyga/guacamole/issues/4 @@ -35,4 +39,5 @@ def invoked(self, ctx): def main(): + logging.basicConfig(level=logging.INFO) Agent().main() From da4e163560f7bea3ee47931693ebe595039d22a8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 4 Aug 2015 08:13:14 -0500 Subject: [PATCH 020/569] Read incoming json data from SPI Modify the provisioning code to use either the url or options passed from the test opportunity to download or build the image, then transmit the image over a socket to the beaglebone for flashing. --- devices/bbb/__init__.py | 14 +++- devices/bbb/beagleboneblack.py | 21 ++--- setup.py | 3 +- snappy_device_agents/__init__.py | 135 +++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 13 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 0d6c551a..ef8151ff 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -15,6 +15,7 @@ """Beagle Bone Black support code.""" import logging +import multiprocessing import os import subprocess import tempfile @@ -22,6 +23,7 @@ import guacamole +import snappy_device_agents from devices.bbb.beagleboneblack import BeagleBoneBlack device_name = "bbb" @@ -36,8 +38,16 @@ def invoked(self, ctx): device = BeagleBoneBlack(ctx.args.config) logging.info("ensure_emmc_image") device.ensure_emmc_image() + image = snappy_device_agents.get_image() + server_ip = snappy_device_agents.get_local_ip_addr() + q = multiprocessing.Queue() + file_server = multiprocessing.Process( + target=snappy_device_agents.serve_file, args=(q, image,)) + file_server.start() + server_port = q.get() logging.info("flash_sd") - device.flash_sd(ctx.args.image_url) + device.flash_sd(server_ip, server_port) + file_server.terminate() logging.info("ensure_test_image") device.ensure_test_image() @@ -45,8 +55,6 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('-i', '--image-url', required=True, - help='URL of the image to install') class runtest(guacamole.Command): diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py index 630591f7..b663886a 100644 --- a/devices/bbb/beagleboneblack.py +++ b/devices/bbb/beagleboneblack.py @@ -155,21 +155,24 @@ def ensure_emmc_image(self): if not emmc_booted: raise RuntimeError("Could not reboot to emmc!") - def flash_sd(self, image_url): + def flash_sd(self, server_ip, server_port): """ Flash the image at :image_url to the sd card. - :param image_url: - URL of the image to flash. The image has to be compatible with a - flat SD card layout. It will be downloaded and gunzipped over the - SD card. + :param server_ip: + IP address of the image server. The image will be downloaded and + gunzipped over the SD card. + :param server_port: + TCP port to connect to on server_ip for downloading the image :raises RuntimeError: If the command times out or anything else fails. """ - cmd = ['ssh', 'ubuntu@{}'.format(self.config['address']), - 'curl {} | gunzip| sudo dd of=/dev/mmcblk0 bs=32M'.format( - image_url)] - logging.info("running {}".format(cmd)) + cmd = [ + 'ssh', 'ubuntu@{}'.format(self.config['address']), + 'nc {} {}| gunzip| sudo dd of=/dev/mmcblk0 bs=16M'.format( + server_ip, server_port) + ] + logging.info("running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! subprocess.check_call(cmd, timeout=1800) diff --git a/setup.py b/setup.py index 36aec58c..e9e03b42 100755 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ url='https://launchpad.net/snappy-device-agents', license='GPLv3', packages=find_packages(), - install_requires=['guacamole >= 0.9', 'PyYAML>=3.11'], + install_requires=['guacamole >= 0.9', 'PyYAML>=3.11', + 'netifaces>=0.10.4'], scripts=['snappy-device-agent'], ) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index b2e72d72..426f9e0f 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -12,10 +12,27 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import gzip import json +import logging +import netifaces +import os +import socket +import subprocess +import urllib + +IMAGEFILE = 'snappy.img' def get_test_opportunity(spi_file='spi_test_opportunity.json'): + """ + Read the json test opportunity data from spi_test_opportunity.json. + + :param spi_file: + Filename and path of the json data if not the default + :return test_opportunity: + Dictionary of values read from the json file + """ with open(spi_file, encoding='utf-8') as spi_json: test_opportunity = json.load(spi_json) # test_payload and image_reference may contain json in a string @@ -34,3 +51,121 @@ def get_test_opportunity(spi_file='spi_test_opportunity.json'): # If this fails, we simply leave the field alone pass return test_opportunity + + +def download(url): + """ + Download the snappy image at the specified URL + + :param url: + URL of the file to download + :return filename: + Filename of the downloaded snappy core image + """ + # For now, we assume that the url is for an uncompressed image + # TBD: whether or not this is a valid assumption + logging.info('Downloading image from %s', url) + filename = IMAGEFILE + urllib.request.urlretrieve(url, filename) + return filename + + +def udf_create_image(params): + """ + Create a new snappy core image with ubuntu-device-flash + + :param params: + Command-line parameters to pass after 'sudo ubuntu-device-flash' + :return filename: + Returns the filename of the image + """ + cmd = params.split() + cmd.insert(0, 'ubuntu-device-flash') + cmd.insert(0, 'sudo') + try: + output_opt = cmd.index('-o') + cmd[output_opt + 1] = IMAGEFILE + except: + # if we get here, -o was already not in the image + cmd.append('-o') + cmd.append(IMAGEFILE) + logging.info('Creating snappy image with: %s', cmd) + output = subprocess.check_output(cmd) + print(output) + return(IMAGEFILE) + + +def get_image(): + """ + Read the json data for a test opportunity from SPI and retrieve or + create the requested image. + + :return compressed_filename: + Returns the filename of the compressed image + """ + spi_data = get_test_opportunity() + image_keys = spi_data.get('image_reference').keys() + if 'url' in image_keys: + image = download(spi_data.get('image_reference').get('url')) + elif 'udf-params' in image_keys: + image = udf_create_image( + spi_data.get('image_reference').get('udf-params')) + else: + logging.error('image_reference needs to contain "url" for the image ' + 'or "udf-params"') + return compress_file(image) + + +def get_local_ip_addr(): + """ + Return our default IP address for another system to connect to + + :return ip: + Returns the ip address of this system + """ + gateways = netifaces.gateways() + default_interface = gateways['default'][netifaces.AF_INET][1] + ip = netifaces.ifaddresses(default_interface)[netifaces.AF_INET][0]['addr'] + return ip + + +def serve_file(q, filename): + """ + Wait for a connection, then send the specified file one time + + :param q: + multiprocessing queue used to send the port number back + :param filename: + The file to transmit + """ + server = socket.socket() + server.bind(("0.0.0.0", 0)) + port = server.getsockname()[1] + q.put(port) + server.listen(1) + (client, addr) = server.accept() + with open(filename, mode='rb') as f: + while True: + data = f.read(16 * 1024 * 1024) + if not data: + break + client.send(data) + client.close() + server.close() + + +def compress_file(filename): + """ + Gzip the specified file, return the filename of the compressed image + + :param filename: + The file to compress + :return compressed_filename: + The filename of the compressed file + """ + compressed_filename = "{}.gz".format(filename) + with open(filename, 'rb') as uncompressed_image: + with gzip.open(compressed_filename, 'wb') as compressed_image: + compressed_image.writelines(uncompressed_image) + os.unlink(filename) + return compressed_filename From b2ecdf9fc6131669d5a213425bc8602131c572d3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 5 Aug 2015 21:02:48 -0500 Subject: [PATCH 021/569] Add requirements.txt --- requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..df550b76 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyYAML==3.11 +guacamole==0.9 +netifaces==0.10.4 +# For the spi-agent +docopt==0.6.2 +requests==2.7.0 From ee7e39ea573dd2cd14acb261a8da37a491eef0db Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 6 Aug 2015 14:13:31 -0500 Subject: [PATCH 022/569] Don't read the whole file at once when gzipping the image --- snappy_device_agents/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 426f9e0f..5614d88b 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -163,9 +163,18 @@ def compress_file(filename): :return compressed_filename: The filename of the compressed file """ + def read_buf(f): + # Read the data in chunks, rather than the whole thing + while True: + data = f.read(4096) + if not data: + break + yield data + compressed_filename = "{}.gz".format(filename) with open(filename, 'rb') as uncompressed_image: with gzip.open(compressed_filename, 'wb') as compressed_image: - compressed_image.writelines(uncompressed_image) + for data in read_buf(uncompressed_image): + compressed_image.write(data) os.unlink(filename) return compressed_filename From eb3a56d49b29f1f25415715f30a2395c3c452e8e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 7 Aug 2015 13:56:22 -0500 Subject: [PATCH 023/569] Modify runtest to run commands from spi data --- README.rst | 2 +- devices/bbb/__init__.py | 41 +++++++++++++++------------------- devices/bbb/beagleboneblack.py | 18 +++++++++------ 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/README.rst b/README.rst index 1f7d893f..3a4c0396 100644 --- a/README.rst +++ b/README.rst @@ -30,7 +30,7 @@ gives you the flexibility to define what works for this particular device. Example:: - address: 192.168.1.147 + device_ip: 192.168.1.147 select_master_script: - ssh pi@192.168.1.136 bin/setboot master select_test_script: diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index ef8151ff..27c380ec 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -16,9 +16,7 @@ import logging import multiprocessing -import os import subprocess -import tempfile import yaml import guacamole @@ -38,7 +36,7 @@ def invoked(self, ctx): device = BeagleBoneBlack(ctx.args.config) logging.info("ensure_emmc_image") device.ensure_emmc_image() - image = snappy_device_agents.get_image() + image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() q = multiprocessing.Queue() file_server = multiprocessing.Process( @@ -55,6 +53,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') class runtest(guacamole.Command): @@ -65,31 +64,27 @@ def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: config = yaml.load(configfile) - test_host = config['address'] - - with tempfile.TemporaryDirectory() as tmpdir: - filename = os.path.split(ctx.args.test_url)[1] - testdir = os.path.join(tmpdir, 'test') - # XXX: Agent should pass us the output location - outputdir = '/tmp/output' - - subprocess.check_output(['wget', ctx.args.test_url], cwd=tmpdir) - os.makedirs(testdir) - # Extraction directory does not get renamed, and test_cmd - # gets run from one level above - subprocess.check_output(['tar', '-xf', filename, '-C', testdir, - '--strip-components=1'], cwd=tmpdir) - test_cmd = ['adt-run', '--built-tree', testdir, '--output-dir', - outputdir, '---', 'ssh', '-d', '-l', 'ubuntu', '-P', - 'ubuntu', '-H', test_host] - subprocess.check_output(test_cmd, cwd=tmpdir) + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.spi_data) + test_cmds = test_opportunity.get('test_payload').get('test_cmds') + for cmd in test_cmds: + # Settings from the device yaml configfile like device_ip can be + # formatted in test commands like "foo {device_ip}" + try: + cmd = cmd.format(**config) + except: + logging.error("Unable to format command: %s", cmd) + + logging.info("Running: %s", cmd) + proc = subprocess.Popen(cmd, shell=True) + proc.wait() def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('-u', '--test-url', required=True, - help='URL of the test tarball') + parser.add_argument('spi_data', help='SPI json data file') class DeviceAgent(guacamole.Command): diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py index b663886a..26edf6ae 100644 --- a/devices/bbb/beagleboneblack.py +++ b/devices/bbb/beagleboneblack.py @@ -80,7 +80,9 @@ def ensure_test_image(self): # FIXME: I don't have a great way to ensure we're in the test image # yet, so just check that we're *not* in the emmc image logging.info("Booting the test image") - cmd = ['ssh', 'ubuntu@{}'.format(self.config['address']), + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), 'sudo /sbin/halt'] try: subprocess.check_call(cmd) @@ -115,7 +117,9 @@ def is_emmc_image_booted(self): .. note:: The emmc contains the non-test image. """ - cmd = ['ssh', 'ubuntu@{}'.format(self.config['address']), + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), 'cat /etc/issue'] # FIXME: come up with a better way of checking this output = subprocess.check_output( @@ -167,11 +171,11 @@ def flash_sd(self, server_ip, server_port): :raises RuntimeError: If the command times out or anything else fails. """ - cmd = [ - 'ssh', 'ubuntu@{}'.format(self.config['address']), - 'nc {} {}| gunzip| sudo dd of=/dev/mmcblk0 bs=16M'.format( - server_ip, server_port) - ] + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'nc {} {}| gunzip| sudo dd of=/dev/mmcblk0 bs=16M'.format( + server_ip, server_port)] logging.info("running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! From d999002b32fe038e008caac71e4e199a28b922e9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 13 Aug 2015 14:24:19 -0500 Subject: [PATCH 024/569] Add instructions for generateing the pip cache --- README-pip-cache.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 README-pip-cache.rst diff --git a/README-pip-cache.rst b/README-pip-cache.rst new file mode 100644 index 00000000..85820d1f --- /dev/null +++ b/README-pip-cache.rst @@ -0,0 +1,14 @@ +Generating pip-cache +#################### + +The pip-cache can be used for distributing this with all dependencies. +To regenerate or update the cache contents, use the following process:: + + $ mkdir pip-cache + $ cd pip-cache + $ virtualenv -p python3 ve + $ . ve/bin/activate + $ pip install --download . -r requirements.txt + +This will download the missing wheels or tarballs, and put them in +pip-cache for redistribution. From 4cea64602a8917f348ebbc735249ba09d933dacc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 14 Aug 2015 15:04:02 -0500 Subject: [PATCH 025/569] Small fix to pass spi data filename to get_image() --- snappy_device_agents/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 5614d88b..cf5f1804 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -95,7 +95,7 @@ def udf_create_image(params): return(IMAGEFILE) -def get_image(): +def get_image(spi_file='spi_test_opportunity.json'): """ Read the json data for a test opportunity from SPI and retrieve or create the requested image. @@ -103,7 +103,7 @@ def get_image(): :return compressed_filename: Returns the filename of the compressed image """ - spi_data = get_test_opportunity() + spi_data = get_test_opportunity(spi_file) image_keys = spi_data.get('image_reference').keys() if 'url' in image_keys: image = download(spi_data.get('image_reference').get('url')) From 7397c51fb18629040de7e4117a04c3620cd90bc5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 20 Aug 2015 11:55:55 -0500 Subject: [PATCH 026/569] Less restrictive bbb provisioning timeouts --- devices/bbb/beagleboneblack.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py index 26edf6ae..e2edb235 100644 --- a/devices/bbb/beagleboneblack.py +++ b/devices/bbb/beagleboneblack.py @@ -48,7 +48,7 @@ def setboot(self, mode): for cmd in setboot_script: logging.info("running {}".format(cmd)) try: - subprocess.check_call(cmd.split(), timeout=10) + subprocess.check_call(cmd.split(), timeout=60) except: raise RuntimeError("timeout reaching control host!") @@ -66,7 +66,7 @@ def hardreset(self): for cmd in self.config['reboot_script']: logging.info("running {}".format(cmd)) try: - subprocess.check_call(cmd.split(), timeout=20) + subprocess.check_call(cmd.split(), timeout=60) except: raise RuntimeError("timeout reaching control host!") @@ -123,7 +123,7 @@ def is_emmc_image_booted(self): 'cat /etc/issue'] # FIXME: come up with a better way of checking this output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=10) + cmd, stderr=subprocess.STDOUT, timeout=60) if 'BeagleBoardUbuntu' in str(output): return True return False From 2966fa1924606d0b912022419af7ae8f8235895b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 28 Aug 2015 12:19:52 -0500 Subject: [PATCH 027/569] Add oauthlib requirements for spi --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index df550b76..c9035bef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ netifaces==0.10.4 # For the spi-agent docopt==0.6.2 requests==2.7.0 +requests-oauthlib==0.5.0 +oauthlib==1.0.1 From b2c4259703df68dc37757ad964f089db772adc05 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 1 Sep 2015 14:15:57 -0500 Subject: [PATCH 028/569] Add support for x86 baremetal This does not use the inception tool exactly, but uses the same grub config changes as inception to accomplish dual booting on x86 hardware. --- README.rst | 39 ++++++ devices/__init__.py | 22 ++++ devices/inception/__init__.py | 101 ++++++++++++++ devices/inception/inception.py | 220 +++++++++++++++++++++++++++++++ snappy_device_agents/__init__.py | 31 ++++- 5 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 devices/inception/__init__.py create mode 100644 devices/inception/inception.py diff --git a/README.rst b/README.rst index 3a4c0396..ce63d1b2 100644 --- a/README.rst +++ b/README.rst @@ -37,3 +37,42 @@ Example:: - ssh pi@192.168.1.136 bin/setboot test reboot_script: - ssh pi@192.168.1.136 bin/hardreset + +x86-64 Baremetal +---------------- + +The x86 baremetal device is currently supported using a process called inception. We boot from an ubuntu-server install running on a usb stick by default, then modify the grub entry on the host to add a boot entry for snappy on the hard drive. + +The boot entry looks like this:: + + # LAAS Inception Marker (do not remove) + menuentry "LAAS Inception Test Boot (one time)" { + insmod chain + set root=hd1 + chainloader +1 + } + # Boot into LAAS Inception OS if requested + if [ "${laas_inception}" ] ; then + set fallback="${default}" + set default="LAAS Inception Test Boot (one time)" + set laas_inception= + save_env laas_inception + set boot_once=true + fi + +To boot into this instance, you simply set the laas_inception grub variable to 1, and it will boot once into the install from the primary hard drive:: + + $ sudo grub-editenv /boot/grub/grubenv set laas_inception=1 + +Because we install to the hard drive, and not a mmc with a known location, you should also specify the test device. Here is a complete example of a config yaml file:: + + device_ip: 10.101.48.47 + test_device: /dev/sda + select_master_script: [] + select_test_script: + - ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 10.101.48.47 sudo grub-editenv /boot/grub/grubenv set laas_inception=1 + reboot_script: + - snmpset -c private -v1 pdu11.cert-maas.taipei .1.3.6.1.4.1.318.1.1.12.3.3.1.1.4.6 i 2 + - sleep 10 + - snmpset -c private -v1 pdu11.cert-maas.taipei .1.3.6.1.4.1.318.1.1.12.3.3.1.1.4.6 i 1 + diff --git a/devices/__init__.py b/devices/__init__.py index 37884a73..0bcdc2fa 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -1,7 +1,29 @@ +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see + import imp import os +class ProvisioningError(Exception): + pass + + +class RecoveryError(Exception): + pass + + def load_devices(): devices = [] device_path = os.path.dirname(os.path.realpath(__file__)) diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py new file mode 100644 index 00000000..2eef0aa6 --- /dev/null +++ b/devices/inception/__init__.py @@ -0,0 +1,101 @@ +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Inception x86 support code.""" + +import logging +import multiprocessing +import subprocess +import yaml + +import guacamole + +import snappy_device_agents +from devices.inception.inception import Inception + +device_name = "inception" + + +class provision(guacamole.Command): + + """Tool for provisioning x86 baremetal with a given image.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + device = Inception(ctx.args.config) + logging.info("ensure_master_image") + device.ensure_master_image() + image = snappy_device_agents.get_image(ctx.args.spi_data) + server_ip = snappy_device_agents.get_local_ip_addr() + test_username = snappy_device_agents.get_test_username( + ctx.args.spi_data) + test_password = snappy_device_agents.get_test_password( + ctx.args.spi_data) + q = multiprocessing.Queue() + file_server = multiprocessing.Process( + target=snappy_device_agents.serve_file, args=(q, image,)) + file_server.start() + server_port = q.get() + logging.info("flash_test_image") + device.flash_test_image(server_ip, server_port) + file_server.terminate() + logging.info("ensure_test_image") + device.ensure_test_image(test_username, test_password) + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.spi_data) + test_cmds = test_opportunity.get('test_payload').get('test_cmds') + for cmd in test_cmds: + # Settings from the device yaml configfile like device_ip can be + # formatted in test commands like "foo {device_ip}" + try: + cmd = cmd.format(**config) + except: + logging.error("Unable to format command: %s", cmd) + + logging.info("Running: %s", cmd) + proc = subprocess.Popen(cmd, shell=True) + proc.wait() + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Inception x86.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/inception/inception.py b/devices/inception/inception.py new file mode 100644 index 00000000..7b8c2017 --- /dev/null +++ b/devices/inception/inception.py @@ -0,0 +1,220 @@ +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Inception x86 support code.""" + +import logging +import subprocess +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) + + +class Inception: + + """Snappy Device Agent for Inception x86.""" + + def __init__(self, config): + with open(config) as configfile: + self.config = yaml.load(configfile) + + def setboot(self, mode): + """ + Set the boot mode of the device. + + :param mode: + One of 'master' or 'test' + :raises ProvisioningError: + If the command times out or anything else fails. + + This method sets the snappy boot method to the specified value. + """ + if mode == 'master': + setboot_script = self.config['select_master_script'] + elif mode == 'test': + setboot_script = self.config['select_test_script'] + else: + raise KeyError + for cmd in setboot_script: + logging.info("running {}".format(cmd)) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise ProvisioningError("timeout reaching control host!") + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config['reboot_script']: + logging.info("running {}".format(cmd)) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise RecoveryError("timeout reaching control host!") + + def ensure_test_image(self, test_username, test_password): + """ + Actively switch the device to boot the test image. + + :param test_username: + Username of the default user in the test image + :param test_password: + Password of the default user in the test image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + logging.info("Booting the test image") + self.setboot('test') + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'sudo /sbin/reboot'] + try: + subprocess.check_call(cmd) + except: + pass + time.sleep(60) + + started = time.time() + # Retry for a while since we might still be reooting + test_image_booted = False + while time.time() - started < 300: + try: + time.sleep(10) + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip'])] + subprocess.check_call(cmd) + test_image_booted = self.is_test_image_booted() + except: + pass + if test_image_booted: + break + # Check again if we are in the master image + if not test_image_booted: + raise ProvisioningError("Failed to boot test image!") + + def is_test_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the test image is currently booted, False otherwise. + :raises TimeoutError: + If the command times out + :raises CalledProcessError: + If the command fails + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'snappy info'] + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + # If we get here, then the above command proved we are in snappy + return True + + def is_master_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the master image is currently booted, False otherwise. + + .. note:: + The master contains the non-test image. + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'cat /boot/grub/grub.cfg'] + # This is really just checking that the master image can be reached + # and has been configured with inception grub entries + try: + output = subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + if 'laas_inception' in str(output): + return True + except: + # Don't raise any exceptions at this point, just return false + pass + return False + + def ensure_master_image(self): + """ + Actively switch the device to boot the test image. + + :raises RecoveryError: + If the command times out or anything else fails. + """ + master_booted = False + logging.info("Making sure the master image is booted") + try: + master_booted = self.is_master_image_booted() + except: + # don't worry if this doesn't work, we'll hard reset later + pass + + if not master_booted: + # We are not in the master image, so just hard reset + self.setboot('master') + self.hardreset() + + started = time.time() + while time.time() - started < 300: + try: + time.sleep(10) + master_booted = self.is_master_image_booted() + except: + pass + if master_booted: + break + # Check again if we are in the master image + if not master_booted: + raise RecoveryError("Could not reboot to master!") + + def flash_test_image(self, server_ip, server_port): + """ + Flash the image at :image_url to the sd card. + + :param server_ip: + IP address of the image server. The image will be downloaded and + gunzipped over the SD card. + :param server_port: + TCP port to connect to on server_ip for downloading the image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( + server_ip, server_port, self.config['test_device'])] + logging.info("running: %s", cmd) + try: + # XXX: I hope 30 min is enough? but maybe not! + subprocess.check_call(cmd, timeout=1800) + except: + raise ProvisioningError("timeout reached while flashing image!") diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index cf5f1804..4af64e34 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -79,20 +79,45 @@ def udf_create_image(params): :return filename: Returns the filename of the image """ + imagepath = os.path.join(os.getcwd(), IMAGEFILE) cmd = params.split() cmd.insert(0, 'ubuntu-device-flash') cmd.insert(0, 'sudo') try: output_opt = cmd.index('-o') - cmd[output_opt + 1] = IMAGEFILE + cmd[output_opt + 1] = imagepath except: # if we get here, -o was already not in the image cmd.append('-o') - cmd.append(IMAGEFILE) + cmd.append(imagepath) logging.info('Creating snappy image with: %s', cmd) output = subprocess.check_output(cmd) print(output) - return(IMAGEFILE) + return(imagepath) + + +def get_test_username(spi_file='spi_test_opportunity.json'): + """ + Read the json data for a test opportunity from SPI and return the + username in specified for the test image (default: ubuntu) + + :return username: + Returns the test image username + """ + spi_data = get_test_opportunity(spi_file) + return spi_data.get('test_payload').get('test_username', 'ubuntu') + + +def get_test_password(spi_file='spi_test_opportunity.json'): + """ + Read the json data for a test opportunity from SPI and return the + password in specified for the test image (default: ubuntu) + + :return password: + Returns the test image password + """ + spi_data = get_test_opportunity(spi_file) + return spi_data.get('test_payload').get('test_password', 'ubuntu') def get_image(spi_file='spi_test_opportunity.json'): From 04a7be7e8152c1e97188347fd35b22ce00fb60b8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 10 Sep 2015 12:43:31 -0500 Subject: [PATCH 029/569] Run ubuntu-device-flash in a shorter path There's a bug with kpartx it seems, that if you are mapping a file with a long path, deleting the mappings can silently fail sometimes. The path that SPI gives us is datestamped, and quite long. This simply creates the image in a shorter path, then moves it to the proper place so that we don't leak loopback devices. --- snappy_device_agents/__init__.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 4af64e34..5cdccd0c 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -17,8 +17,10 @@ import logging import netifaces import os +import shutil import socket import subprocess +import tempfile import urllib IMAGEFILE = 'snappy.img' @@ -83,15 +85,24 @@ def udf_create_image(params): cmd = params.split() cmd.insert(0, 'ubuntu-device-flash') cmd.insert(0, 'sudo') - try: - output_opt = cmd.index('-o') - cmd[output_opt + 1] = imagepath - except: - # if we get here, -o was already not in the image - cmd.append('-o') - cmd.append(imagepath) - logging.info('Creating snappy image with: %s', cmd) - output = subprocess.check_output(cmd) + + # A shorter tempdir path is needed than the one provided by SPI + # because of a bug in kpartx that makes it have trouble deleting + # mappings with long paths + with tempfile.TemporaryDirectory() as tmpdir: + tmp_imagepath = os.path.join(tmpdir, IMAGEFILE) + try: + output_opt = cmd.index('-o') + cmd[output_opt + 1] = imagepath + except: + # if we get here, -o was already not in the image + cmd.append('-o') + cmd.append(tmp_imagepath) + + logging.info('Creating snappy image with: %s', cmd) + output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) + shutil.move(tmp_imagepath, imagepath) + print(output) return(imagepath) From 650b80fd55c8716b302bb98303fec6640b829db5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 14 Sep 2015 23:20:31 -0500 Subject: [PATCH 030/569] Add support for logging to logstash --- README.rst | 13 ++++++++++++ devices/bbb/__init__.py | 17 ++++++++++----- devices/bbb/beagleboneblack.py | 12 ++++++----- devices/inception/__init__.py | 16 +++++++++----- devices/inception/inception.py | 12 ++++++----- requirements.txt | 1 + snappy-device-agent | 5 +++++ snappy_device_agents/__init__.py | 31 +++++++++++++++++++++++++-- snappy_device_agents/cmd.py | 36 ++++++++++++++++++++++++++++++-- 9 files changed, 119 insertions(+), 24 deletions(-) diff --git a/README.rst b/README.rst index ce63d1b2..70752c4c 100644 --- a/README.rst +++ b/README.rst @@ -76,3 +76,16 @@ Because we install to the hard drive, and not a mmc with a known location, you s - sleep 10 - snmpset -c private -v1 pdu11.cert-maas.taipei .1.3.6.1.4.1.318.1.1.12.3.3.1.1.4.6 i 1 + +Logstash Logging +================ + +Log messages can optionally be directed to a logstash server by adding +two additional values in the yaml file:: + + logstash_host: 10.0.3.207 + agent_name: test001 + +Logstash_host is the logstash server the messages will be sent to on port 5959. +Agent_name should be the name of the device this agent represents. It +will be added as extra data in the log message. diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 27c380ec..798f700c 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -26,6 +26,8 @@ device_name = "bbb" +logger = logging.getLogger() + class provision(guacamole.Command): @@ -33,8 +35,12 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = BeagleBoneBlack(ctx.args.config) - logging.info("ensure_emmc_image") + logger.info("ensure_emmc_image") device.ensure_emmc_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() @@ -43,10 +49,10 @@ def invoked(self, ctx): target=snappy_device_agents.serve_file, args=(q, image,)) file_server.start() server_port = q.get() - logging.info("flash_sd") + logger.info("flash_sd") device.flash_sd(server_ip, server_port) file_server.terminate() - logging.info("ensure_test_image") + logger.info("ensure_test_image") device.ensure_test_image() def register_arguments(self, parser): @@ -64,6 +70,7 @@ def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) @@ -74,9 +81,9 @@ def invoked(self, ctx): try: cmd = cmd.format(**config) except: - logging.error("Unable to format command: %s", cmd) + logger.error("Unable to format command: %s", cmd) - logging.info("Running: %s", cmd) + logger.info("Running: %s", cmd) proc = subprocess.Popen(cmd, shell=True) proc.wait() diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py index e2edb235..a7223f36 100644 --- a/devices/bbb/beagleboneblack.py +++ b/devices/bbb/beagleboneblack.py @@ -19,6 +19,8 @@ import time import yaml +logger = logging.getLogger() + class BeagleBoneBlack: @@ -46,7 +48,7 @@ def setboot(self, mode): else: raise KeyError for cmd in setboot_script: - logging.info("running {}".format(cmd)) + logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) except: @@ -64,7 +66,7 @@ def hardreset(self): standard image. You need to provide it yourself. """ for cmd in self.config['reboot_script']: - logging.info("running {}".format(cmd)) + logger.info("running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) except: @@ -79,7 +81,7 @@ def ensure_test_image(self): """ # FIXME: I don't have a great way to ensure we're in the test image # yet, so just check that we're *not* in the emmc image - logging.info("Booting the test image") + logger.info("Booting the test image") cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), @@ -136,7 +138,7 @@ def ensure_emmc_image(self): If the command times out or anything else fails. """ emmc_booted = False - logging.info("Making sure the emmc image is booted") + logger.info("Making sure the emmc image is booted") try: emmc_booted = self.is_emmc_image_booted() except: @@ -176,7 +178,7 @@ def flash_sd(self, server_ip, server_port): 'ubuntu@{}'.format(self.config['device_ip']), 'nc {} {}| gunzip| sudo dd of=/dev/mmcblk0 bs=16M'.format( server_ip, server_port)] - logging.info("running: %s", cmd) + logger.info("Running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! subprocess.check_call(cmd, timeout=1800) diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py index 2eef0aa6..6cfcd42e 100644 --- a/devices/inception/__init__.py +++ b/devices/inception/__init__.py @@ -26,6 +26,8 @@ device_name = "inception" +logger = logging.getLogger() + class provision(guacamole.Command): @@ -33,8 +35,11 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) device = Inception(ctx.args.config) - logging.info("ensure_master_image") + logger.info("ensure_master_image") device.ensure_master_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() @@ -47,10 +52,10 @@ def invoked(self, ctx): target=snappy_device_agents.serve_file, args=(q, image,)) file_server.start() server_port = q.get() - logging.info("flash_test_image") + logger.info("flash_test_image") device.flash_test_image(server_ip, server_port) file_server.terminate() - logging.info("ensure_test_image") + logger.info("ensure_test_image") device.ensure_test_image(test_username, test_password) def register_arguments(self, parser): @@ -68,6 +73,7 @@ def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) @@ -78,9 +84,9 @@ def invoked(self, ctx): try: cmd = cmd.format(**config) except: - logging.error("Unable to format command: %s", cmd) + logger.error("Unable to format command: %s", cmd) - logging.info("Running: %s", cmd) + logger.info("Running: %s", cmd) proc = subprocess.Popen(cmd, shell=True) proc.wait() diff --git a/devices/inception/inception.py b/devices/inception/inception.py index 7b8c2017..c4dfed81 100644 --- a/devices/inception/inception.py +++ b/devices/inception/inception.py @@ -22,6 +22,8 @@ from devices import (ProvisioningError, RecoveryError) +logger = logging.getLogger() + class Inception: @@ -49,7 +51,7 @@ def setboot(self, mode): else: raise KeyError for cmd in setboot_script: - logging.info("running {}".format(cmd)) + logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) except: @@ -67,7 +69,7 @@ def hardreset(self): in the config yaml. """ for cmd in self.config['reboot_script']: - logging.info("running {}".format(cmd)) + logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) except: @@ -84,7 +86,7 @@ def ensure_test_image(self, test_username, test_password): :raises ProvisioningError: If the command times out or anything else fails. """ - logging.info("Booting the test image") + logger.info("Booting the test image") self.setboot('test') cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', @@ -170,7 +172,7 @@ def ensure_master_image(self): If the command times out or anything else fails. """ master_booted = False - logging.info("Making sure the master image is booted") + logger.info("Making sure the master image is booted") try: master_booted = self.is_master_image_booted() except: @@ -212,7 +214,7 @@ def flash_test_image(self, server_ip, server_port): 'ubuntu@{}'.format(self.config['device_ip']), 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( server_ip, server_port, self.config['test_device'])] - logging.info("running: %s", cmd) + logger.info("Running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! subprocess.check_call(cmd, timeout=1800) diff --git a/requirements.txt b/requirements.txt index c9035bef..0b31b01c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ PyYAML==3.11 guacamole==0.9 netifaces==0.10.4 +python-logstash==0.4.5 # For the spi-agent docopt==0.6.2 requests==2.7.0 diff --git a/snappy-device-agent b/snappy-device-agent index ef542e3b..89c24313 100755 --- a/snappy-device-agent +++ b/snappy-device-agent @@ -14,7 +14,12 @@ # along with this program. If not, see . +import logging + from snappy_device_agents.cmd import main +logger = logging.getLogger() +logger.setLevel(logging.INFO) + if __name__ == '__main__': main() diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 5cdccd0c..b2599827 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -25,6 +25,8 @@ IMAGEFILE = 'snappy.img' +logger = logging.getLogger() + def get_test_opportunity(spi_file='spi_test_opportunity.json'): """ @@ -66,7 +68,7 @@ def download(url): """ # For now, we assume that the url is for an uncompressed image # TBD: whether or not this is a valid assumption - logging.info('Downloading image from %s', url) + logger.info('Downloading image from %s', url) filename = IMAGEFILE urllib.request.urlretrieve(url, filename) return filename @@ -99,7 +101,7 @@ def udf_create_image(params): cmd.append('-o') cmd.append(tmp_imagepath) - logging.info('Creating snappy image with: %s', cmd) + logger.info('Creating snappy image with: %s', cmd) output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) shutil.move(tmp_imagepath, imagepath) @@ -214,3 +216,28 @@ def read_buf(f): compressed_image.write(data) os.unlink(filename) return compressed_filename + + +def configure_logging(config): + class AgentFilter(logging.Filter): + def __init__(self, agent_name): + super(AgentFilter, self).__init__() + self.agent_name = agent_name + + def filter(self, record): + record.agent_name = self.agent_name + return True + + logging.basicConfig( + format='%(asctime)s %(agent_name)s %(levelname)s: %(message)s') + agent_name = config.get('agent_name', "") + logger.addFilter(AgentFilter(agent_name)) + logstash_host = config.get('logstash_host', None) + + if logstash_host is not None: + try: + import logstash + except ImportError: + print('Install python-logstash if you want to use logstash logging') + else: + logger.addHandler(logstash.LogstashHandler(logstash_host, 5959, 1)) diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py index 197167a0..3dbed8df 100755 --- a/snappy_device_agents/cmd.py +++ b/snappy_device_agents/cmd.py @@ -17,16 +17,46 @@ import logging from guacamole import Command +from guacamole.core import Ingredient +from guacamole.recipes.cmd import CommandRecipe +from guacamole.ingredients import ansi +from guacamole.ingredients import argparse +from guacamole.ingredients import cmdtree from devices import load_devices +logger = logging.getLogger() + + +class CrashLoggingIngredient(Ingredient): + """Use python logging if we Crash + """ + + def dispatch_failed(self, context): + logger.exception("exception") + raise + + +class AgentCommandRecipe(CommandRecipe): + """This is so we can add a custom ingredient + """ + + def get_ingredients(self): + return [ + cmdtree.CommandTreeBuilder(self.command), + cmdtree.CommandTreeDispatcher(), + argparse.AutocompleteIngredient(), + argparse.ParserIngredient(), + ansi.ANSIIngredient(), + CrashLoggingIngredient(), + ] + class Agent(Command): """Main agent command This loads subcommands from modules in the devices directory """ - spices = ['log:arguments'] sub_commands = load_devices() @@ -37,7 +67,9 @@ def invoked(self, ctx): exit(1) """ + def main(self, argv=None, exit=True): + return AgentCommandRecipe(self).main(argv, exit) + def main(): - logging.basicConfig(level=logging.INFO) Agent().main() From efb615d8e013308d62df0e334edf0a049292341c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 21 Sep 2015 15:06:24 -0500 Subject: [PATCH 031/569] Add a download_files parameter in the image_reference section Any files here will be downloaded before starting the image creation --- snappy_device_agents/__init__.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index b2599827..6225ac2a 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -21,7 +21,7 @@ import socket import subprocess import tempfile -import urllib +import urllib.request IMAGEFILE = 'snappy.img' @@ -57,19 +57,22 @@ def get_test_opportunity(spi_file='spi_test_opportunity.json'): return test_opportunity -def download(url): +def download(url, filename=None): """ - Download the snappy image at the specified URL + Download the at the specified URL :param url: URL of the file to download + :param filename: + Filename to save the file as, defaults to the basename from the url :return filename: Filename of the downloaded snappy core image """ # For now, we assume that the url is for an uncompressed image # TBD: whether or not this is a valid assumption - logger.info('Downloading image from %s', url) - filename = IMAGEFILE + logger.info('Downloading file from %s', url) + if filename is None: + filename = os.path.basename(url) urllib.request.urlretrieve(url, filename) return filename @@ -143,8 +146,12 @@ def get_image(spi_file='spi_test_opportunity.json'): """ spi_data = get_test_opportunity(spi_file) image_keys = spi_data.get('image_reference').keys() + if 'download_files' in image_keys: + for url in spi_data.get('image_reference').get('download_files'): + download(url) if 'url' in image_keys: - image = download(spi_data.get('image_reference').get('url')) + image = download(spi_data.get('image_reference').get('url'), + IMAGEFILE) elif 'udf-params' in image_keys: image = udf_create_image( spi_data.get('image_reference').get('udf-params')) From 6801e64ba2db9654447f840e7efca99e79a2344b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 25 Sep 2015 11:14:19 -0500 Subject: [PATCH 032/569] Fix random flake8 failures This doesn't show up in all flake8 versions, but could cause failures to merge in the future --- snappy_device_agents/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 6225ac2a..98316b8f 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -245,6 +245,7 @@ def filter(self, record): try: import logstash except ImportError: - print('Install python-logstash if you want to use logstash logging') + print( + 'Install python-logstash if you want to use logstash logging') else: logger.addHandler(logstash.LogstashHandler(logstash_host, 5959, 1)) From ef4b7f55e45e56456ff0560aef242e34d7270c01 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 21 Sep 2015 15:08:06 -0500 Subject: [PATCH 033/569] Add support for Raspberry pi 2 --- README.rst | 35 +++++-- devices/rpi2/__init__.py | 104 +++++++++++++++++++++ devices/rpi2/rpi2.py | 196 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 devices/rpi2/__init__.py create mode 100644 devices/rpi2/rpi2.py diff --git a/README.rst b/README.rst index 70752c4c..380d43ac 100644 --- a/README.rst +++ b/README.rst @@ -7,19 +7,19 @@ devices Supported Devices ================= -Only BeagleBone Black is supported for the moment, and for provisioning -to work properly, things have to be set up in a very specific way. Once -we have devices in the lab, this will be made a little more generic, but -will probably always require specialized hardware to be fully automated. - BeagleBone Black ---------------- -To use snappy-device-agent with a BeagleBone, you will need to create a -config file to give it some hints about your environment. The config -file for BeagleBone needs to have the address (host or ip) of you test -system once it boots, a list of commands to force it to boot the master -(emmc) image, a list of commands to force it to boot the test (snappy) +To use snappy-device-agent with a BeagleBone, you will need to first +replace the image on emmc with a customized one. The installer image +can be downloaded from:: + + http://people.canonical.com/~plars/snappy/ + +Next, create a config file to give it some hints about your environment. +The config file for BeagleBone needs to have the address (host or ip) of +you test system once it boots, a list of commands to force it to boot the +master (emmc) image, a list of commands to force it to boot the test (snappy) image, and a list of commands to force a hard poweroff/poweron. If you have a very simple setup, these command scripts could be as @@ -38,6 +38,21 @@ Example:: reboot_script: - ssh pi@192.168.1.136 bin/hardreset +Raspberry Pi 2 +-------------- +Using the provisioning kit with Raspberry Pi 2 is similar to the Beaglebone. +The rpi2 will need to have a USB stick inserted for the test image, and the +SD card should boot the raspberry pi image from:: + + http://people.canonical.com/~plars/snappy/ + +The snappy-device-agent script should just be called with the rpi2 +subcommand instead of bbb. + +Also, the default.yaml file passed to snappy-device-agent should include:: + + test_device: /dev/sda + x86-64 Baremetal ---------------- diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py new file mode 100644 index 00000000..4bf2eb76 --- /dev/null +++ b/devices/rpi2/__init__.py @@ -0,0 +1,104 @@ +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Raspberry Pi 2 support code.""" + +import logging +import multiprocessing +import subprocess +import yaml + +import guacamole + +import snappy_device_agents +from devices.rpi2.rpi2 import RaspberryPi2 + +device_name = "rpi2" + +logger = logging.getLogger() + + +class provision(guacamole.Command): + + """Tool for provisioning Raspberry Pi 2 with a given image.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + + device = RaspberryPi2(ctx.args.config) + logger.info("ensure_master_image") + device.ensure_master_image() + image = snappy_device_agents.get_image(ctx.args.spi_data) + server_ip = snappy_device_agents.get_local_ip_addr() + q = multiprocessing.Queue() + file_server = multiprocessing.Process( + target=snappy_device_agents.serve_file, args=(q, image,)) + file_server.start() + server_port = q.get() + logger.info("flash_sd") + device.flash_sd(server_ip, server_port) + file_server.terminate() + logger.info("ensure_test_image") + device.ensure_test_image() + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.spi_data) + test_cmds = test_opportunity.get('test_payload').get('test_cmds') + for cmd in test_cmds: + # Settings from the device yaml configfile like device_ip can be + # formatted in test commands like "foo {device_ip}" + try: + cmd = cmd.format(**config) + except: + logger.error("Unable to format command: %s", cmd) + + logger.info("Running: %s", cmd) + proc = subprocess.Popen(cmd, shell=True) + proc.wait() + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Raspberry Pi 2.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/rpi2/rpi2.py b/devices/rpi2/rpi2.py new file mode 100644 index 00000000..6de706fb --- /dev/null +++ b/devices/rpi2/rpi2.py @@ -0,0 +1,196 @@ +# Copyright (C) 2015 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Raspberry Pi 2 support code.""" + +import logging +import subprocess +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) + + +logger = logging.getLogger() + + +class RaspberryPi2: + + """Snappy Device Agent for Raspberry Pi 2.""" + + def __init__(self, config): + with open(config) as configfile: + self.config = yaml.load(configfile) + + def setboot(self, mode): + """ + Set the boot mode of the device. + + :param mode: + One of 'master' or 'test' + :raises RecoveryError: + If the command times out or anything else fails. + + This method sets the snappy boot method to the specified value. + """ + if mode == 'master': + setboot_script = self.config['select_master_script'] + elif mode == 'test': + setboot_script = self.config['select_test_script'] + else: + raise KeyError + for cmd in setboot_script: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise RecoveryError("timeout reaching control host!") + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function executes ``bin/hardreset`` which is not a part of a + standard image. You need to provide it yourself. + """ + for cmd in self.config['reboot_script']: + logger.info("running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise RecoveryError("timeout reaching control host!") + + def ensure_test_image(self): + """ + Actively switch the device to boot the test image. + + :raises ProvisioningError: + If the command times out or anything else fails. + """ + # FIXME: I don't have a great way to ensure we're in the test image + # yet, so just check that we're *not* in the master image + logger.info("Booting the test image") + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'sudo /sbin/halt'] + try: + subprocess.check_call(cmd) + except: + pass + time.sleep(60) + self.setboot('test') + self.hardreset() + + master_booted = False + started = time.time() + while time.time() - started < 300: + try: + master_booted = self.is_master_image_booted() + if master_booted is False: + break + except: + continue + # Check again if we are in the master image + if master_booted: + # XXX: This should *never* happen since we set the boot mode! + raise ProvisioningError( + "Still booting to master after flashing image!") + + def is_master_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the master image is currently booted, False if another + image is booted + :raises CalledProcessError: + If the command exits with an error + :raises TimeoutExpired + If the command times out + + .. note:: + The master contains the non-test image. + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'apt-get -h'] + # apt-get -h under snappy returns a warning rather than full help + output = subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + if 'update' in str(output): + return True + else: + return False + + def ensure_master_image(self): + """ + Actively switch the device to boot the test image. + + :raises RecoveryError: + If the command times out or anything else fails. + """ + master_booted = False + logger.info("Making sure the master image is booted") + try: + master_booted = self.is_master_image_booted() + except: + # don't worry if this doesn't work, we'll hard reset later + pass + + if not master_booted: + # We are not in the master image, so just hard reset + self.setboot('master') + self.hardreset() + + started = time.time() + while time.time() - started < 300: + try: + master_booted = self.is_master_image_booted() + except: + continue + break + # Check again if we are in the master image + if not master_booted: + raise RecoveryError("Could not reboot to master!") + + def flash_sd(self, server_ip, server_port): + """ + Flash the image at :image_url to the sd card. + + :param server_ip: + IP address of the image server. The image will be downloaded and + gunzipped over the SD card. + :param server_port: + TCP port to connect to on server_ip for downloading the image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( + server_ip, server_port, self.config['test_device'])] + logger.info("Running: %s", cmd) + try: + # XXX: I hope 30 min is enough? but maybe not! + subprocess.check_call(cmd, timeout=1800) + except: + raise ProvisioningError("timeout reached while flashing image!") From 9984c235230949fabda98f38ced18de15c7df3de Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 22 Sep 2015 09:55:51 -0500 Subject: [PATCH 034/569] Improve logging and add output to log message --- devices/bbb/__init__.py | 15 +++++++++++---- devices/inception/__init__.py | 15 +++++++++++---- devices/rpi2/__init__.py | 15 +++++++++++---- snappy_device_agents/__init__.py | 2 +- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 798f700c..84bc586a 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -40,7 +40,8 @@ def invoked(self, ctx): snappy_device_agents.configure_logging(config) device = BeagleBoneBlack(ctx.args.config) - logger.info("ensure_emmc_image") + logger.info("BEGIN provision") + logger.info("Booting Master Image") device.ensure_emmc_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() @@ -49,11 +50,12 @@ def invoked(self, ctx): target=snappy_device_agents.serve_file, args=(q, image,)) file_server.start() server_port = q.get() - logger.info("flash_sd") + logger.info("Flashing Test Image") device.flash_sd(server_ip, server_port) file_server.terminate() - logger.info("ensure_test_image") + logger.info("Booting Test Image") device.ensure_test_image() + logger.info("END provision") def register_arguments(self, parser): """Method called to customize the argument parser.""" @@ -71,6 +73,7 @@ def invoked(self, ctx): with open(ctx.args.config) as configfile: config = yaml.load(configfile) snappy_device_agents.configure_logging(config) + logger.info("BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) @@ -84,8 +87,12 @@ def invoked(self, ctx): logger.error("Unable to format command: %s", cmd) logger.info("Running: %s", cmd) - proc = subprocess.Popen(cmd, shell=True) + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) proc.wait() + output, _ = proc.communicate() + logger.info("output:\n%s", output) + logger.info("END testrun") def register_arguments(self, parser): """Method called to customize the argument parser.""" diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py index 6cfcd42e..f7a24ef8 100644 --- a/devices/inception/__init__.py +++ b/devices/inception/__init__.py @@ -39,7 +39,8 @@ def invoked(self, ctx): config = yaml.load(configfile) snappy_device_agents.configure_logging(config) device = Inception(ctx.args.config) - logger.info("ensure_master_image") + logger.info("BEGIN provision") + logger.info("Booting Master Image") device.ensure_master_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() @@ -52,11 +53,12 @@ def invoked(self, ctx): target=snappy_device_agents.serve_file, args=(q, image,)) file_server.start() server_port = q.get() - logger.info("flash_test_image") + logger.info("Flashing Test Image") device.flash_test_image(server_ip, server_port) file_server.terminate() - logger.info("ensure_test_image") + logger.info("Booting Test Image") device.ensure_test_image(test_username, test_password) + logger.info("END provision") def register_arguments(self, parser): """Method called to customize the argument parser.""" @@ -74,6 +76,7 @@ def invoked(self, ctx): with open(ctx.args.config) as configfile: config = yaml.load(configfile) snappy_device_agents.configure_logging(config) + logger.info("BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) @@ -87,8 +90,12 @@ def invoked(self, ctx): logger.error("Unable to format command: %s", cmd) logger.info("Running: %s", cmd) - proc = subprocess.Popen(cmd, shell=True) + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) proc.wait() + output, _ = proc.communicate() + logger.info("output:\n%s", output) + logger.info("END testrun") def register_arguments(self, parser): """Method called to customize the argument parser.""" diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py index 4bf2eb76..00954f32 100644 --- a/devices/rpi2/__init__.py +++ b/devices/rpi2/__init__.py @@ -40,7 +40,8 @@ def invoked(self, ctx): snappy_device_agents.configure_logging(config) device = RaspberryPi2(ctx.args.config) - logger.info("ensure_master_image") + logger.info("BEGIN provision") + logger.info("Booting Master Image") device.ensure_master_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() @@ -49,11 +50,12 @@ def invoked(self, ctx): target=snappy_device_agents.serve_file, args=(q, image,)) file_server.start() server_port = q.get() - logger.info("flash_sd") + logger.info("Flashing Test Image") device.flash_sd(server_ip, server_port) file_server.terminate() - logger.info("ensure_test_image") + logger.info("Booting Test Image") device.ensure_test_image() + logger.info("END provision") def register_arguments(self, parser): """Method called to customize the argument parser.""" @@ -71,6 +73,7 @@ def invoked(self, ctx): with open(ctx.args.config) as configfile: config = yaml.load(configfile) snappy_device_agents.configure_logging(config) + logger.info("BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) @@ -84,8 +87,12 @@ def invoked(self, ctx): logger.error("Unable to format command: %s", cmd) logger.info("Running: %s", cmd) - proc = subprocess.Popen(cmd, shell=True) + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) proc.wait() + output, _ = proc.communicate() + logger.info("output:\n%s", output) + logger.info("END testrun") def register_arguments(self, parser): """Method called to customize the argument parser.""" diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 98316b8f..3a7b711b 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -106,9 +106,9 @@ def udf_create_image(params): logger.info('Creating snappy image with: %s', cmd) output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) + logger.info('Image Creation Output:\n %s', output) shutil.move(tmp_imagepath, imagepath) - print(output) return(imagepath) From 26504b3b4feb5a4a100e0999b9d45a64d429dfcb Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 1 Oct 2015 10:59:03 -0500 Subject: [PATCH 035/569] Exit non-0 if any test_cmd failed and warn --- devices/bbb/__init__.py | 8 +++++++- devices/inception/__init__.py | 8 +++++++- devices/rpi2/__init__.py | 7 ++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 84bc586a..924111d5 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -78,21 +78,27 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) test_cmds = test_opportunity.get('test_payload').get('test_cmds') + exitcode = 0 for cmd in test_cmds: # Settings from the device yaml configfile like device_ip can be # formatted in test commands like "foo {device_ip}" try: cmd = cmd.format(**config) except: + exitcode = 20 logger.error("Unable to format command: %s", cmd) logger.info("Running: %s", cmd) proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - proc.wait() + rc = proc.wait() output, _ = proc.communicate() + if rc: + exitcode = 4 + logger.warn("Command failed, rc=%d", rc) logger.info("output:\n%s", output) logger.info("END testrun") + return exitcode def register_arguments(self, parser): """Method called to customize the argument parser.""" diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py index f7a24ef8..a5737f35 100644 --- a/devices/inception/__init__.py +++ b/devices/inception/__init__.py @@ -81,21 +81,27 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) test_cmds = test_opportunity.get('test_payload').get('test_cmds') + exitcode = 0 for cmd in test_cmds: # Settings from the device yaml configfile like device_ip can be # formatted in test commands like "foo {device_ip}" try: cmd = cmd.format(**config) except: + exitcode = 20 logger.error("Unable to format command: %s", cmd) logger.info("Running: %s", cmd) proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - proc.wait() + rc = proc.wait() output, _ = proc.communicate() + if rc: + exitcode = 4 + logger.warn("Command failed, rc=%d", rc) logger.info("output:\n%s", output) logger.info("END testrun") + return exitcode def register_arguments(self, parser): """Method called to customize the argument parser.""" diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py index 00954f32..c429d40c 100644 --- a/devices/rpi2/__init__.py +++ b/devices/rpi2/__init__.py @@ -78,6 +78,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) test_cmds = test_opportunity.get('test_payload').get('test_cmds') + exitcode = 0 for cmd in test_cmds: # Settings from the device yaml configfile like device_ip can be # formatted in test commands like "foo {device_ip}" @@ -89,10 +90,14 @@ def invoked(self, ctx): logger.info("Running: %s", cmd) proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - proc.wait() + rc = proc.wait() output, _ = proc.communicate() + if rc: + exitcode = 4 + logger.warn("Command failed, rc=%d", rc) logger.info("output:\n%s", output) logger.info("END testrun") + return exitcode def register_arguments(self, parser): """Method called to customize the argument parser.""" From 93cca75cd022bf8478b68cd8d96bc0fb84bb2172 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 29 Oct 2015 09:47:25 -0500 Subject: [PATCH 036/569] Add logmsg for splitting long log messages --- snappy_device_agents/__init__.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3a7b711b..cfd11051 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -249,3 +249,24 @@ def filter(self, record): 'Install python-logstash if you want to use logstash logging') else: logger.addHandler(logstash.LogstashHandler(logstash_host, 5959, 1)) + + +def logmsg(level, msg, *args, **kwargs): + """ + Front end to logging that splits messages into 4096 byte chunks + + :param level: + log level + :param msg: + log message + :param args: + args for filling message variables + :param kwargs: + key/value args, not currently used, but can be used through logging + """ + + if args: + msg = msg % args + logger.log(level, msg[:4096]) + if len(msg) > 4096: + logmsg(level, msg[4096:]) From 99fb6f79cd5f0a538602d06096dddab234e31ebc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 29 Oct 2015 09:58:38 -0500 Subject: [PATCH 037/569] Use logmsg in device logging --- devices/bbb/__init__.py | 26 +++++++++++++------------- devices/inception/__init__.py | 25 ++++++++++++------------- devices/rpi2/__init__.py | 26 +++++++++++++------------- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 924111d5..9359cba6 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -23,10 +23,10 @@ import snappy_device_agents from devices.bbb.beagleboneblack import BeagleBoneBlack +from snappy_device_agents import logmsg -device_name = "bbb" -logger = logging.getLogger() +device_name = "bbb" class provision(guacamole.Command): @@ -40,8 +40,8 @@ def invoked(self, ctx): snappy_device_agents.configure_logging(config) device = BeagleBoneBlack(ctx.args.config) - logger.info("BEGIN provision") - logger.info("Booting Master Image") + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Booting Master Image") device.ensure_emmc_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() @@ -50,12 +50,12 @@ def invoked(self, ctx): target=snappy_device_agents.serve_file, args=(q, image,)) file_server.start() server_port = q.get() - logger.info("Flashing Test Image") + logmsg(logging.INFO, "Flashing Test Image") device.flash_sd(server_ip, server_port) file_server.terminate() - logger.info("Booting Test Image") + logmsg(logging.INFO, "Booting Test Image") device.ensure_test_image() - logger.info("END provision") + logmsg(logging.INFO, "END provision") def register_arguments(self, parser): """Method called to customize the argument parser.""" @@ -73,7 +73,7 @@ def invoked(self, ctx): with open(ctx.args.config) as configfile: config = yaml.load(configfile) snappy_device_agents.configure_logging(config) - logger.info("BEGIN testrun") + logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) @@ -86,18 +86,18 @@ def invoked(self, ctx): cmd = cmd.format(**config) except: exitcode = 20 - logger.error("Unable to format command: %s", cmd) + logmsg(logging.ERROR, "Unable to format command: %s", cmd) - logger.info("Running: %s", cmd) + logmsg(logging.INFO, "Running: %s", cmd) proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) rc = proc.wait() output, _ = proc.communicate() if rc: exitcode = 4 - logger.warn("Command failed, rc=%d", rc) - logger.info("output:\n%s", output) - logger.info("END testrun") + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + logmsg(logging.INFO, "output:\n%s", output) + logmsg(logging.INFO, "END testrun") return exitcode def register_arguments(self, parser): diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py index a5737f35..30c32421 100644 --- a/devices/inception/__init__.py +++ b/devices/inception/__init__.py @@ -23,11 +23,10 @@ import snappy_device_agents from devices.inception.inception import Inception +from snappy_device_agents import logmsg device_name = "inception" -logger = logging.getLogger() - class provision(guacamole.Command): @@ -39,8 +38,8 @@ def invoked(self, ctx): config = yaml.load(configfile) snappy_device_agents.configure_logging(config) device = Inception(ctx.args.config) - logger.info("BEGIN provision") - logger.info("Booting Master Image") + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Booting Master Image") device.ensure_master_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() @@ -53,12 +52,12 @@ def invoked(self, ctx): target=snappy_device_agents.serve_file, args=(q, image,)) file_server.start() server_port = q.get() - logger.info("Flashing Test Image") + logmsg(logging.INFO, "Flashing Test Image") device.flash_test_image(server_ip, server_port) file_server.terminate() - logger.info("Booting Test Image") + logmsg(logging.INFO, "Booting Test Image") device.ensure_test_image(test_username, test_password) - logger.info("END provision") + logmsg(logging.INFO, "END provision") def register_arguments(self, parser): """Method called to customize the argument parser.""" @@ -76,7 +75,7 @@ def invoked(self, ctx): with open(ctx.args.config) as configfile: config = yaml.load(configfile) snappy_device_agents.configure_logging(config) - logger.info("BEGIN testrun") + logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) @@ -89,18 +88,18 @@ def invoked(self, ctx): cmd = cmd.format(**config) except: exitcode = 20 - logger.error("Unable to format command: %s", cmd) + logmsg(logging.ERROR, "Unable to format command: %s", cmd) - logger.info("Running: %s", cmd) + logmsg(logging.INFO, "Running: %s", cmd) proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) rc = proc.wait() output, _ = proc.communicate() if rc: exitcode = 4 - logger.warn("Command failed, rc=%d", rc) - logger.info("output:\n%s", output) - logger.info("END testrun") + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + logmsg(logging.INFO, "output:\n%s", output) + logmsg(logging.INFO, "END testrun") return exitcode def register_arguments(self, parser): diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py index c429d40c..9f06331c 100644 --- a/devices/rpi2/__init__.py +++ b/devices/rpi2/__init__.py @@ -23,10 +23,10 @@ import snappy_device_agents from devices.rpi2.rpi2 import RaspberryPi2 +from snappy_device_agents import logmsg -device_name = "rpi2" -logger = logging.getLogger() +device_name = "rpi2" class provision(guacamole.Command): @@ -40,8 +40,8 @@ def invoked(self, ctx): snappy_device_agents.configure_logging(config) device = RaspberryPi2(ctx.args.config) - logger.info("BEGIN provision") - logger.info("Booting Master Image") + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Booting Master Image") device.ensure_master_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() @@ -50,12 +50,12 @@ def invoked(self, ctx): target=snappy_device_agents.serve_file, args=(q, image,)) file_server.start() server_port = q.get() - logger.info("Flashing Test Image") + logmsg(logging.INFO, "Flashing Test Image") device.flash_sd(server_ip, server_port) file_server.terminate() - logger.info("Booting Test Image") + logmsg(logging.INFO, "Booting Test Image") device.ensure_test_image() - logger.info("END provision") + logmsg(logging.INFO, "END provision") def register_arguments(self, parser): """Method called to customize the argument parser.""" @@ -73,7 +73,7 @@ def invoked(self, ctx): with open(ctx.args.config) as configfile: config = yaml.load(configfile) snappy_device_agents.configure_logging(config) - logger.info("BEGIN testrun") + logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) @@ -85,18 +85,18 @@ def invoked(self, ctx): try: cmd = cmd.format(**config) except: - logger.error("Unable to format command: %s", cmd) + logmsg(logging.ERROR, "Unable to format command: %s", cmd) - logger.info("Running: %s", cmd) + logmsg(logging.INFO, "Running: %s", cmd) proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) rc = proc.wait() output, _ = proc.communicate() if rc: exitcode = 4 - logger.warn("Command failed, rc=%d", rc) - logger.info("output:\n%s", output) - logger.info("END testrun") + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + logmsg(logging.INFO, "output:\n%s", output) + logmsg(logging.INFO, "END testrun") return exitcode def register_arguments(self, parser): From e22d2c7b43eb1abc2a0442c601a0a8c701fec673 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 11 Dec 2015 09:55:52 -0600 Subject: [PATCH 038/569] Fix detection of provisioning failure on beaglebone --- devices/bbb/__init__.py | 6 +++- devices/bbb/beagleboneblack.py | 58 ++++++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 9359cba6..0039ec70 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -45,6 +45,10 @@ def invoked(self, ctx): device.ensure_emmc_image() image = snappy_device_agents.get_image(ctx.args.spi_data) server_ip = snappy_device_agents.get_local_ip_addr() + test_username = snappy_device_agents.get_test_username( + ctx.args.spi_data) + test_password = snappy_device_agents.get_test_password( + ctx.args.spi_data) q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, args=(q, image,)) @@ -54,7 +58,7 @@ def invoked(self, ctx): device.flash_sd(server_ip, server_port) file_server.terminate() logmsg(logging.INFO, "Booting Test Image") - device.ensure_test_image() + device.ensure_test_image(test_username, test_password) logmsg(logging.INFO, "END provision") def register_arguments(self, parser): diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py index a7223f36..b79c54c3 100644 --- a/devices/bbb/beagleboneblack.py +++ b/devices/bbb/beagleboneblack.py @@ -19,6 +19,8 @@ import time import yaml +from devices import ProvisioningError + logger = logging.getLogger() @@ -72,16 +74,19 @@ def hardreset(self): except: raise RuntimeError("timeout reaching control host!") - def ensure_test_image(self): + def ensure_test_image(self, test_username, test_password): """ Actively switch the device to boot the test image. - :raises RuntimeError: + :param test_username: + Username of the default user in the test image + :param test_password: + Password of the default user in the test image + :raises ProvisioningError: If the command times out or anything else fails. """ - # FIXME: I don't have a great way to ensure we're in the test image - # yet, so just check that we're *not* in the emmc image logger.info("Booting the test image") + self.setboot('test') cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), @@ -91,21 +96,48 @@ def ensure_test_image(self): except: pass time.sleep(60) - self.setboot('test') self.hardreset() - emmc_booted = False started = time.time() + # Retry for a while since we might still be rebooting + test_image_booted = False while time.time() - started < 300: try: - emmc_booted = self.is_emmc_image_booted() + time.sleep(10) + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip'])] + subprocess.check_call(cmd) + test_image_booted = self.is_test_image_booted() except: - continue - break - # Check again if we are in the emmc image - if emmc_booted: - # XXX: This should *never* happen since we set the boot mode! - raise RuntimeError("Still booting to emmc after flashing image!") + pass + if test_image_booted: + break + # Check again if we are in the master image + if not test_image_booted: + raise ProvisioningError("Failed to boot test image!") + + def is_test_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the test image is currently booted, False otherwise. + :raises TimeoutError: + If the command times out + :raises CalledProcessError: + If the command fails + """ + + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'snappy info'] + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + # If we get here, then the above command proved we are in snappy + return True def is_emmc_image_booted(self): """ From 199baf9f442ecd443b201bc31a183c044dcb8c5d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 13 Jan 2016 16:45:42 -0600 Subject: [PATCH 039/569] Fix typo in inception.py --- devices/inception/inception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/inception/inception.py b/devices/inception/inception.py index c4dfed81..2cf29cdd 100644 --- a/devices/inception/inception.py +++ b/devices/inception/inception.py @@ -99,7 +99,7 @@ def ensure_test_image(self, test_username, test_password): time.sleep(60) started = time.time() - # Retry for a while since we might still be reooting + # Retry for a while since we might still be rebooting test_image_booted = False while time.time() - started < 300: try: From cfb6386f431203ba2e5dc54e9f6dbdf1f25cae84 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 27 Jan 2016 12:16:30 -0600 Subject: [PATCH 040/569] Add device support for netboot provisioning method --- devices/netboot/__init__.py | 119 +++++++++++++++++++ devices/netboot/netboot.py | 220 ++++++++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 devices/netboot/__init__.py create mode 100644 devices/netboot/netboot.py diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py new file mode 100644 index 00000000..cb7ad53d --- /dev/null +++ b/devices/netboot/__init__.py @@ -0,0 +1,119 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Netboot support code.""" + +import logging +import multiprocessing +import subprocess +import yaml + +import guacamole + +import snappy_device_agents +from devices.netboot.netboot import Netboot +from snappy_device_agents import logmsg + +device_name = "netboot" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = Netboot(ctx.args.config) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Booting Master Image") + device.ensure_master_image() + image = snappy_device_agents.get_image(ctx.args.spi_data) + server_ip = snappy_device_agents.get_local_ip_addr() + test_username = snappy_device_agents.get_test_username( + ctx.args.spi_data) + test_password = snappy_device_agents.get_test_password( + ctx.args.spi_data) + q = multiprocessing.Queue() + file_server = multiprocessing.Process( + target=snappy_device_agents.serve_file, args=(q, image,)) + file_server.start() + server_port = q.get() + logmsg(logging.INFO, "Flashing Test Image") + device.flash_test_image(server_ip, server_port) + file_server.terminate() + logmsg(logging.INFO, "Booting Test Image") + device.ensure_test_image(test_username, test_password) + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.spi_data) + test_cmds = test_opportunity.get('test_payload').get('test_cmds') + exitcode = 0 + for cmd in test_cmds: + # Settings from the device yaml configfile like device_ip can be + # formatted in test commands like "foo {device_ip}" + try: + cmd = cmd.format(**config) + except: + exitcode = 20 + logmsg(logging.ERROR, "Unable to format command: %s", cmd) + + logmsg(logging.INFO, "Running: %s", cmd) + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + rc = proc.wait() + output, _ = proc.communicate() + if rc: + exitcode = 4 + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + logmsg(logging.INFO, "output:\n%s", output) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Netboot.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py new file mode 100644 index 00000000..494c3de9 --- /dev/null +++ b/devices/netboot/netboot.py @@ -0,0 +1,220 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Netboot support code.""" + +import logging +import subprocess +import urllib.request +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) + +logger = logging.getLogger() + + +class Netboot: + + """Snappy Device Agent for Netboot.""" + + def __init__(self, config): + with open(config) as configfile: + self.config = yaml.load(configfile) + + def setboot(self, mode): + """ + Set the boot mode of the device. + + :param mode: + One of 'master' or 'test' + :raises ProvisioningError: + If the command times out or anything else fails. + + This method sets the snappy boot method to the specified value. + """ + if mode == 'master': + setboot_script = self.config['select_master_script'] + elif mode == 'test': + setboot_script = self.config['select_test_script'] + else: + raise KeyError + for cmd in setboot_script: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise ProvisioningError("timeout reaching control host!") + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config['reboot_script']: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise RecoveryError("timeout reaching control host!") + + def ensure_test_image(self, test_username, test_password): + """ + Actively switch the device to boot the test image. + + :param test_username: + Username of the default user in the test image + :param test_password: + Password of the default user in the test image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + logger.info("Booting the test image") + self.setboot('test') + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'sudo /sbin/reboot'] + try: + subprocess.check_call(cmd) + except: + pass + time.sleep(60) + + started = time.time() + # Retry for a while since we might still be rebooting + test_image_booted = False + while time.time() - started < 300: + try: + time.sleep(10) + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip'])] + subprocess.check_call(cmd) + test_image_booted = self.is_test_image_booted() + except: + pass + if test_image_booted: + break + # Check again if we are in the master image + if not test_image_booted: + raise ProvisioningError("Failed to boot test image!") + + def is_test_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the test image is currently booted, False otherwise. + :raises TimeoutError: + If the command times out + :raises CalledProcessError: + If the command fails + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'snappy info'] + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + # If we get here, then the above command proved we are in snappy + return True + + def is_master_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the master image is currently booted, False otherwise. + + .. note:: + The master image is used for writing a new image to local media + """ + check_url = 'http://{}:8989/check'.format(self.config['device_ip']) + data = "" + try: + logger.info("Checking if master image booted: %s", check_url) + with urllib.request.urlopen(check_url) as url: + data = url.read() + except: + # Any connection error will fail through the normal path + pass + if 'Snappy Test Device Imager' in str(data): + return True + else: + return False + + def ensure_master_image(self): + """ + Actively switch the device to boot the test image. + + :raises RecoveryError: + If the command times out or anything else fails. + """ + logger.info("Making sure the master image is booted") + master_booted = self.is_master_image_booted() + + if not master_booted: + # We are not in the master image, so just hard reset + self.setboot('master') + self.hardreset() + + started = time.time() + while time.time() - started < 300: + time.sleep(10) + master_booted = self.is_master_image_booted() + if master_booted: + break + # Check again if we are in the master image + if not master_booted: + raise RecoveryError("Could not reboot to master!") + + def flash_test_image(self, server_ip, server_port): + """ + Flash the image at :image_url to the sd card. + + :param server_ip: + IP address of the image server. The image will be downloaded and + gunzipped over the SD card. + :param server_port: + TCP port to connect to on server_ip for downloading the image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + url = 'http://{}:8989/writeimage?server={}:{}\&dev={}'.format( + self.config['device_ip'], server_ip, server_port, + self.config['test_device']) + logger.info("Triggering: %s", url) + try: + # XXX: I hope 30 min is enough? but maybe not! + urllib.request.urlopen(url, timeout=1800) + except: + raise ProvisioningError("Error while flashing image!") + + # Now reboot the target system + url = 'http://{}:8989/reboot'.format(self.config['device_ip']) + try: + logger.info("Rebooting target device: %s", url) + urllib.request.urlopen(url, timeout=10) + except: + # FIXME: This could fail to return right now due to a bug + pass From 8c65811cda246985548c1cbbd234daf6f5eb4fe4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 1 Mar 2016 11:18:01 -0600 Subject: [PATCH 041/569] Capture called process output when image build fails --- snappy_device_agents/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index cfd11051..6c63b45a 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -105,7 +105,11 @@ def udf_create_image(params): cmd.append(tmp_imagepath) logger.info('Creating snappy image with: %s', cmd) - output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) + try: + output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + logger.error('Image Creation Output:\n %s', e.output) + raise logger.info('Image Creation Output:\n %s', output) shutil.move(tmp_imagepath, imagepath) From 07cbfb76f931d05515ac9ca17b77314a3b855dc4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 23 Mar 2016 14:38:00 -0500 Subject: [PATCH 042/569] Add support for dragonboard --- devices/dragonboard/__init__.py | 119 ++++++++++++++ devices/dragonboard/dragonboard.py | 248 +++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 devices/dragonboard/__init__.py create mode 100644 devices/dragonboard/dragonboard.py diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py new file mode 100644 index 00000000..06f579fd --- /dev/null +++ b/devices/dragonboard/__init__.py @@ -0,0 +1,119 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Dragonboard support code.""" + +import logging +import multiprocessing +import subprocess +import yaml + +import guacamole + +import snappy_device_agents +from devices.dragonboard.dragonboard import Dragonboard +from snappy_device_agents import logmsg + +device_name = "dragonboard" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = Dragonboard(ctx.args.config) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Booting Master Image") + device.ensure_master_image() + image = snappy_device_agents.get_image(ctx.args.spi_data) + server_ip = snappy_device_agents.get_local_ip_addr() + test_username = snappy_device_agents.get_test_username( + ctx.args.spi_data) + test_password = snappy_device_agents.get_test_password( + ctx.args.spi_data) + q = multiprocessing.Queue() + file_server = multiprocessing.Process( + target=snappy_device_agents.serve_file, args=(q, image,)) + file_server.start() + server_port = q.get() + logmsg(logging.INFO, "Flashing Test Image") + device.flash_test_image(server_ip, server_port) + file_server.terminate() + logmsg(logging.INFO, "Booting Test Image") + device.ensure_test_image(test_username, test_password) + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.spi_data) + test_cmds = test_opportunity.get('test_payload').get('test_cmds') + exitcode = 0 + for cmd in test_cmds: + # Settings from the device yaml configfile like device_ip can be + # formatted in test commands like "foo {device_ip}" + try: + cmd = cmd.format(**config) + except: + exitcode = 20 + logmsg(logging.ERROR, "Unable to format command: %s", cmd) + + logmsg(logging.INFO, "Running: %s", cmd) + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + rc = proc.wait() + output, _ = proc.communicate() + if rc: + exitcode = 4 + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + logmsg(logging.INFO, "output:\n%s", output) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('spi_data', help='SPI json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Dragonboard.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py new file mode 100644 index 00000000..2ae49038 --- /dev/null +++ b/devices/dragonboard/dragonboard.py @@ -0,0 +1,248 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Dragonboard support code.""" + +import logging +import subprocess +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) + +logger = logging.getLogger() + + +class Dragonboard: + + """Snappy Device Agent for Dragonboard.""" + + def __init__(self, config): + with open(config) as configfile: + self.config = yaml.load(configfile) + + def setboot(self, mode): + """ + Set the boot mode of the device. + + :param mode: + One of 'master' or 'test' + :raises ProvisioningError: + If the command times out or anything else fails. + + This method sets the snappy boot method to the specified value. + """ + if mode == 'master': + setboot_script = self.config['select_master_script'] + elif mode == 'test': + setboot_script = self.config['select_test_script'] + else: + raise KeyError + for cmd in setboot_script: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise ProvisioningError("timeout reaching control host!") + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config['reboot_script']: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise RecoveryError("timeout reaching control host!") + + def ensure_test_image(self, test_username, test_password): + """ + Actively switch the device to boot the test image. + + :param test_username: + Username of the default user in the test image + :param test_password: + Password of the default user in the test image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + logger.info("Booting the test image") + self.setboot('test') + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), + 'sudo /sbin/reboot'] + try: + subprocess.check_call(cmd) + except: + pass + time.sleep(60) + + started = time.time() + # Retry for a while since we might still be rebooting + test_image_booted = False + while time.time() - started < 300: + try: + time.sleep(10) + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip'])] + subprocess.check_call(cmd) + test_image_booted = self.is_test_image_booted() + except: + pass + if test_image_booted: + break + # Check again if we are in the master image + if not test_image_booted: + raise ProvisioningError("Failed to boot test image!") + + def is_test_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the test image is currently booted, False otherwise. + :raises TimeoutError: + If the command times out + :raises CalledProcessError: + If the command fails + """ + logger.info("Checking if test image booted.") + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'snappy info'] + try: + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + except: + return False + # If we get here, then the above command proved we are in snappy + return True + + def is_master_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the master image is currently booted, False otherwise. + + .. note:: + The master image is used for writing a new image to local media + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), + 'cat /etc/issue'] + # FIXME: come up with a better way of checking this + logger.info("Checking if master image booted.") + try: + output = subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + except: + logger.info("Error checking device state. Forcing reboot...") + return False + if 'Debian GNU' in str(output): + return True + return False + + def ensure_master_image(self): + """ + Actively switch the device to boot the test image. + + :raises RecoveryError: + If the command times out or anything else fails. + """ + logger.info("Making sure the master image is booted") + + # most likely, we are still in a test image, check that first + test_booted = self.is_test_image_booted() + + if test_booted: + # We are not in the master image, so just hard reset + self.setboot('master') + self.hardreset() + + started = time.time() + while time.time() - started < 300: + time.sleep(10) + master_booted = self.is_master_image_booted() + if master_booted: + return + # Check again if we are in the master image + if not master_booted: + raise RecoveryError("Could not reboot to master!") + + master_booted = self.is_master_image_booted() + if not master_booted: + logging.warn( + "Device is in an unknown state, attempting to recover") + self.hardreset() + started = time.time() + while time.time() - started < 300: + time.sleep(10) + if self.is_master_image_booted(): + return + elif self.is_test_image_booted(): + # device was stuck, but booted to the test image + # So rerun ourselves to get to the master image + return self.ensure_master_image() + # timeout reached, this could be a dead device + raise RecoveryError( + "Device is in an unknown state, may require manual recovery!") + # If we get here, the master image was already booted, so just return + + def flash_test_image(self, server_ip, server_port): + """ + Flash the image at :image_url to the sd card. + + :param server_ip: + IP address of the image server. The image will be downloaded and + gunzipped over the SD card. + :param server_port: + TCP port to connect to on server_ip for downloading the image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), + 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( + server_ip, server_port, self.config['test_device'])] + logger.info("Running: %s", cmd) + try: + # XXX: I hope 30 min is enough? but maybe not! + subprocess.check_call(cmd, timeout=1800) + except: + raise ProvisioningError("timeout reached while flashing image!") + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), 'sync'] + try: + subprocess.check_call(cmd, timeout=1800) + except: + # Nothing should go wrong here, but let's sleep if it does + logger.warn("Something went wrong with the sync, sleeping...") + time.sleep(30) From 34a6ab8dc2e345771f6d20630d7c57ff9f70e5ee Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 Apr 2016 13:33:51 -0500 Subject: [PATCH 043/569] Add a delayed retry mechanism --- snappy_device_agents/__init__.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 6c63b45a..aa00e824 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -77,6 +77,30 @@ def download(url, filename=None): return filename +def delayretry(func, args, max_retry=3, delay=0): + """ + Retry the called function with a delay inserted between attempts + + :param func: + Function to retry + :param args: + List of args to pass to func() + :param max_retry: + Maximum number of times to retry + :delay: + Time (in seconds) to delay between attempts + """ + for retry_count in range(max_retry): + try: + ret = func(*args) + except: + time.sleep(delay) + if retry_count == max_retry-1: + raise + continue + return ret + + def udf_create_image(params): """ Create a new snappy core image with ubuntu-device-flash @@ -157,8 +181,9 @@ def get_image(spi_file='spi_test_opportunity.json'): image = download(spi_data.get('image_reference').get('url'), IMAGEFILE) elif 'udf-params' in image_keys: - image = udf_create_image( - spi_data.get('image_reference').get('udf-params')) + udf_params = spi_data.get('image_reference').get('udf-params') + image = delayretry(udf_create_image, [udf_params], + max_retries=3, delay=60) else: logging.error('image_reference needs to contain "url" for the image ' 'or "udf-params"') From 5db033dba5b4757ad8a99979c41706c2b735fa48 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 13 Apr 2016 22:27:55 -0500 Subject: [PATCH 044/569] Check if snappy is booted using a new command --- devices/bbb/beagleboneblack.py | 2 +- devices/dragonboard/dragonboard.py | 2 +- devices/inception/inception.py | 2 +- devices/netboot/netboot.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py index b79c54c3..9e65a25a 100644 --- a/devices/bbb/beagleboneblack.py +++ b/devices/bbb/beagleboneblack.py @@ -133,7 +133,7 @@ def is_test_image_booted(self): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), - 'snappy info'] + 'snap -h'] subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) # If we get here, then the above command proved we are in snappy diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index 2ae49038..ec1412ff 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -133,7 +133,7 @@ def is_test_image_booted(self): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), - 'snappy info'] + 'snap -h'] try: subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) diff --git a/devices/inception/inception.py b/devices/inception/inception.py index 2cf29cdd..214a6726 100644 --- a/devices/inception/inception.py +++ b/devices/inception/inception.py @@ -132,7 +132,7 @@ def is_test_image_booted(self): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), - 'snappy info'] + 'snap -h'] subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) # If we get here, then the above command proved we are in snappy diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 494c3de9..8bb6a907 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -133,7 +133,7 @@ def is_test_image_booted(self): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), - 'snappy info'] + 'snap -h'] subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) # If we get here, then the above command proved we are in snappy From afa7473996644c2155515741eb274c5fae8d130f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 25 Apr 2016 16:52:21 +0800 Subject: [PATCH 045/569] Fix max_retries parameter name --- snappy_device_agents/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index aa00e824..2da3ef7d 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -77,7 +77,7 @@ def download(url, filename=None): return filename -def delayretry(func, args, max_retry=3, delay=0): +def delayretry(func, args, max_retries=3, delay=0): """ Retry the called function with a delay inserted between attempts @@ -85,17 +85,17 @@ def delayretry(func, args, max_retry=3, delay=0): Function to retry :param args: List of args to pass to func() - :param max_retry: + :param max_retries: Maximum number of times to retry :delay: Time (in seconds) to delay between attempts """ - for retry_count in range(max_retry): + for retry_count in range(max_retries): try: ret = func(*args) except: time.sleep(delay) - if retry_count == max_retry-1: + if retry_count == max_retries-1: raise continue return ret From 7f0a0c50f53d9c19e31017b60093b6b5e5ba0997 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 25 Apr 2016 17:49:46 +0800 Subject: [PATCH 046/569] missing import --- snappy_device_agents/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 2da3ef7d..6a0626df 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -21,6 +21,7 @@ import socket import subprocess import tempfile +import time import urllib.request IMAGEFILE = 'snappy.img' From 3022d90a8f290bdd21e9ba5306d1864e84477f9f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 23 May 2016 20:01:41 -0500 Subject: [PATCH 047/569] Better handling of compressed image downloads --- snappy_device_agents/__init__.py | 45 +++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 6a0626df..71c417b7 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -12,9 +12,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import bz2 import gzip import json import logging +import lzma import netifaces import os import shutil @@ -58,6 +60,21 @@ def get_test_opportunity(spi_file='spi_test_opportunity.json'): return test_opportunity +def filetype(filename): + magic_headers = { + b"\x1f\x8b\x08": "gz", + b"\x42\x5a\x68": "bz2", + b"\xfd\x37\x7a\x58\x5a\x00": "xz"} + with open(filename, 'rb') as f: + filehead = f.read(1024) + filetype = "unknown" + for k, v in magic_headers.items(): + if filehead.startswith(k): + filetype = v + break + return filetype + + def download(url, filename=None): """ Download the at the specified URL @@ -69,8 +86,6 @@ def download(url, filename=None): :return filename: Filename of the downloaded snappy core image """ - # For now, we assume that the url is for an uncompressed image - # TBD: whether or not this is a valid assumption logger.info('Downloading file from %s', url) if filename is None: filename = os.path.basename(url) @@ -238,19 +253,23 @@ def compress_file(filename): :return compressed_filename: The filename of the compressed file """ - def read_buf(f): - # Read the data in chunks, rather than the whole thing - while True: - data = f.read(4096) - if not data: - break - yield data - compressed_filename = "{}.gz".format(filename) - with open(filename, 'rb') as uncompressed_image: + if filetype(filename) is 'gz': + # just hard link it so we can unlink later without special handling + os.link(filename, compressed_filename) + elif filetype(filename) is 'bz2': with gzip.open(compressed_filename, 'wb') as compressed_image: - for data in read_buf(uncompressed_image): - compressed_image.write(data) + with bz2.BZ2File(filename, 'rb') as old_compressed: + shutil.copyfileobj(old_compressed, compressed_image) + elif filetype(filename) is 'xz': + with gzip.open(compressed_filename, 'wb') as compressed_image: + with lzma.LZMAFile(filename, 'rb') as old_compressed: + shutil.copyfileobj(old_compressed, compressed_image) + else: + # filetype is 'unknown' so assumed to be raw image + with open(filename, 'rb') as uncompressed_image: + with gzip.open(compressed_filename, 'wb') as compressed_image: + shutil.copyfileobj(uncompressed_image, compressed_image) os.unlink(filename) return compressed_filename From 0f81b2c1e75403eb664cfe73765a95d71105f328 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 24 May 2016 14:13:57 -0500 Subject: [PATCH 048/569] Remove compressed image from previous run if it exists --- snappy_device_agents/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 71c417b7..88fa1f70 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -254,6 +254,11 @@ def compress_file(filename): The filename of the compressed file """ compressed_filename = "{}.gz".format(filename) + try: + # If debug enabled in SPI, an older image could still be there + os.unlink(compressed_filename) + except: + pass if filetype(filename) is 'gz': # just hard link it so we can unlink later without special handling os.link(filename, compressed_filename) From efcbbda98c212f66a30cf3d9bd358e5a9c5fa2cb Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 8 Aug 2016 12:52:51 -0500 Subject: [PATCH 049/569] Create project for testflinger-agent --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..4b115b10 --- /dev/null +++ b/README.rst @@ -0,0 +1,7 @@ +================= +Testflinger Agent +================= + +Testflinger agent waits for job requests on a configured queue, then processes +them. The Testflinger Server submits those jobs, and once the job is complete, +the agent can submit outcome data with limited results back to the server. From 56a9a2764aae23bb07607abe173abeffb93d89a7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 9 Aug 2016 23:17:15 -0500 Subject: [PATCH 050/569] Add the command, config loading, and a simple test --- testflinger-agent | 24 ++++++++++++++++ testflinger_agent/__init__.py | 39 ++++++++++++++++++++++++++ testflinger_agent/tests/__init__.py | 15 ++++++++++ testflinger_agent/tests/test_config.py | 33 ++++++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100755 testflinger-agent create mode 100644 testflinger_agent/__init__.py create mode 100644 testflinger_agent/tests/__init__.py create mode 100644 testflinger_agent/tests/test_config.py diff --git a/testflinger-agent b/testflinger-agent new file mode 100755 index 00000000..9d9b3c4f --- /dev/null +++ b/testflinger-agent @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging + +from testflinger_agent import main + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +if __name__ == '__main__': + main() diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py new file mode 100644 index 00000000..607312ff --- /dev/null +++ b/testflinger_agent/__init__.py @@ -0,0 +1,39 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +import logging +import yaml + +logger = logging.getLogger() + +config = dict() + + +def main(): + args = parse_args() + load_config(args.config) + + +def load_config(configfile): + global config + with open(configfile) as f: + config = yaml.safe_load(f) + + +def parse_args(): + parser = argparse.ArgumentParser(description='Testflinger Agent') + parser.add_argument('--config', '-c', default='testflinger-agent.conf', + help='Testflinger agent config file') + return parser.parse_args() diff --git a/testflinger_agent/tests/__init__.py b/testflinger_agent/tests/__init__.py new file mode 100644 index 00000000..d16f4571 --- /dev/null +++ b/testflinger_agent/tests/__init__.py @@ -0,0 +1,15 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# diff --git a/testflinger_agent/tests/test_config.py b/testflinger_agent/tests/test_config.py new file mode 100644 index 00000000..de12df87 --- /dev/null +++ b/testflinger_agent/tests/test_config.py @@ -0,0 +1,33 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import tempfile +import testflinger_agent + +from unittest import TestCase + + +class ConfigTest(TestCase): + def setUp(self): + with tempfile.NamedTemporaryFile(delete=False) as config: + self.configfile = config.name + config.write('agent_id: agent-foo'.encode()) + + def tearDown(self): + os.unlink(self.configfile) + + def test_config(self): + testflinger_agent.load_config(self.configfile) + self.assertEqual('agent-foo', testflinger_agent.config.get('agent_id')) From 96323fe835bb0bde1b58e8b217c01d9270640f79 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 9 Aug 2016 23:18:13 -0500 Subject: [PATCH 051/569] Add setup.py and .gitignore --- .gitignore | 6 ++++++ setup.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 .gitignore create mode 100755 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c26700c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*$py.class +env/ +*.conf +*.egg* diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..256b2fd0 --- /dev/null +++ b/setup.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +from setuptools import setup + +INSTALL_REQUIRES = [ + "PyYAML", +] + +setup( + name='testflinger-agent', + version='1.0', + long_description=__doc__, + packages=['testflinger_agent'], + zip_safe=False, + install_requires=INSTALL_REQUIRES, + test_suite='testflinger_agent.tests', + scripts=['testflinger-agent'], +) From 88e979369b99fc063da9273991c889d72f81472b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 11 Aug 2016 23:04:55 -0500 Subject: [PATCH 052/569] Add schema validation --- setup.py | 1 + testflinger_agent/__init__.py | 3 +++ testflinger_agent/schema.py | 37 ++++++++++++++++++++++++++ testflinger_agent/tests/test_config.py | 26 +++++++++++++++--- 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 testflinger_agent/schema.py diff --git a/setup.py b/setup.py index 256b2fd0..3a71fb30 100755 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ INSTALL_REQUIRES = [ "PyYAML", + "voluptuous", ] setup( diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 607312ff..6eb57cf5 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -16,6 +16,8 @@ import logging import yaml +from testflinger_agent import schema + logger = logging.getLogger() config = dict() @@ -30,6 +32,7 @@ def load_config(configfile): global config with open(configfile) as f: config = yaml.safe_load(f) + schema.validate(config) def parse_args(): diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py new file mode 100644 index 00000000..60607e88 --- /dev/null +++ b/testflinger_agent/schema.py @@ -0,0 +1,37 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import voluptuous + +SCHEMA_V1 = { + voluptuous.Required('agent_id'): str, + 'polling_interval': int, + voluptuous.Required('server_address'): str, + 'execution_basedir': str, + 'logging_basedir': str, + voluptuous.Required('job_queues'): list, + 'setup_command': str, + 'provision_command': str, + 'test_command': str, +} + + +def validate(data): + """Validate data according to known schemas + + :param data: + Data to validate + """ + v1 = voluptuous.Schema(SCHEMA_V1) + v1(data) diff --git a/testflinger_agent/tests/test_config.py b/testflinger_agent/tests/test_config.py index de12df87..a71cf0a9 100644 --- a/testflinger_agent/tests/test_config.py +++ b/testflinger_agent/tests/test_config.py @@ -15,19 +15,39 @@ import os import tempfile import testflinger_agent +import voluptuous from unittest import TestCase +GOOD_CONFIG = """ +agent_id: test01 +polling_interval: 10 +server_address: 127.0.0.1:8000 +job_queues: + - test +""" + +BAD_CONFIG = """ +badkey: foo +""" + class ConfigTest(TestCase): def setUp(self): with tempfile.NamedTemporaryFile(delete=False) as config: self.configfile = config.name - config.write('agent_id: agent-foo'.encode()) def tearDown(self): os.unlink(self.configfile) - def test_config(self): + def test_config_good(self): + with open(self.configfile, 'w') as config: + config.write(GOOD_CONFIG) testflinger_agent.load_config(self.configfile) - self.assertEqual('agent-foo', testflinger_agent.config.get('agent_id')) + self.assertEqual('test01', testflinger_agent.config.get('agent_id')) + + def test_config_bad(self): + with open(self.configfile, 'w') as config: + config.write(BAD_CONFIG) + self.assertRaises(voluptuous.error.MultipleInvalid, + testflinger_agent.load_config, self.configfile) From 107a904a75236baaf5541d665de2ec7bc7077333 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 12 Aug 2016 12:49:10 -0500 Subject: [PATCH 053/569] Set defaults for config options where we can --- testflinger_agent/schema.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index 60607e88..ddbfae24 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -16,14 +16,16 @@ SCHEMA_V1 = { voluptuous.Required('agent_id'): str, - 'polling_interval': int, + voluptuous.Required('polling_interval', default=10): int, voluptuous.Required('server_address'): str, - 'execution_basedir': str, - 'logging_basedir': str, + voluptuous.Required('execution_basedir', + default='/tmp/testflinger/run'): str, + voluptuous.Required('logging_basedir', + default='/tmp/testflinger/logs'): str, voluptuous.Required('job_queues'): list, - 'setup_command': str, - 'provision_command': str, - 'test_command': str, + voluptuous.Required('setup_command', default=''): str, + voluptuous.Required('provision_command', default=''): str, + voluptuous.Required('test_command', default=''): str, } From 26680432a9eb61e5471c31c3f4425cca02679a55 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 15 Aug 2016 11:16:55 -0500 Subject: [PATCH 054/569] fix config import of default settings after schema validation --- testflinger_agent/__init__.py | 2 +- testflinger_agent/schema.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 6eb57cf5..b984b12d 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -32,7 +32,7 @@ def load_config(configfile): global config with open(configfile) as f: config = yaml.safe_load(f) - schema.validate(config) + config = schema.validate(config) def parse_args(): diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index ddbfae24..d7263cc5 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -36,4 +36,4 @@ def validate(data): Data to validate """ v1 = voluptuous.Schema(SCHEMA_V1) - v1(data) + return v1(data) From 4baceb01fe9e8bc645bd3037e7b4faf85e396a72 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 15 Aug 2016 14:48:14 -0500 Subject: [PATCH 055/569] Handle logging for both files and console --- README.rst | 79 +++++++++++++++++++++++++++++++++++ testflinger-agent | 5 --- testflinger_agent/__init__.py | 24 +++++++++++ testflinger_agent/schema.py | 2 + 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 4b115b10..e7b6d094 100644 --- a/README.rst +++ b/README.rst @@ -5,3 +5,82 @@ Testflinger Agent Testflinger agent waits for job requests on a configured queue, then processes them. The Testflinger Server submits those jobs, and once the job is complete, the agent can submit outcome data with limited results back to the server. + +Overview +-------- + +Testflinger-agent connects to the Testflinger microservice to request and +service requests for tests. + +Installation +------------ + +To create a virtual environment and install testflinger-agent: + +.. code-block:: console + + $ virtualenv env + $ . env/bin/activate + $ ./setup install + +Testing +------- + +To run the unit tests, first install (see above) then: + +.. code-block:: console + + $ ./setup test + +Configuration +------------- + +Configuration is loaded from a yaml configuration file called +testflinger-agent.conf by default. You can specify a different file +to use for config data using the -c option. + +The following configuration options are supported: + +- **agent_id**: + + - Unique identifier for this agent + +- **polling_interval**: + + - Time to sleep between polling for new tests (default: 10s) + +- **server address**: + + - Host/IP and port of the testflinger server + +- **execution_basedir**: + + - Base directory to use for running jobs (default: /tmp/testflinger/run) + +- **logging_basedir**: + + - Base directory to use for agent logging (default: /tmp/testflinger/logs) + +- **logging_level**: + + - Python loglevel name to use for logging (default: INFO) + +- **logging_quiet**: + + - Only log to the logfile, and not to the console (default: False) + +- **job_queues**: + + - List of queues that can be serviced by this device + +- **setup_command**: + + - Command to run for the setup phase + +- **provision_command**: + + - Command to run for the provision phase + +- **test_command**: + + - Command to run for the testing phase diff --git a/testflinger-agent b/testflinger-agent index 9d9b3c4f..b4de8fb6 100755 --- a/testflinger-agent +++ b/testflinger-agent @@ -13,12 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import logging - from testflinger_agent import main -logger = logging.getLogger() -logger.setLevel(logging.INFO) - if __name__ == '__main__': main() diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index b984b12d..2851720e 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -14,6 +14,7 @@ import argparse import logging +import os import yaml from testflinger_agent import schema @@ -26,6 +27,7 @@ def main(): args = parse_args() load_config(args.config) + configure_logging() def load_config(configfile): @@ -35,6 +37,28 @@ def load_config(configfile): config = schema.validate(config) +def configure_logging(): + global config + os.makedirs(config.get('logging_basedir'), exist_ok=True) + log_level = logging.getLevelName(config.get('logging_level')) + # This should help if they specify something invalid + if not isinstance(log_level, int): + log_level = logging.INFO + logfmt = logging.Formatter( + fmt='[%(asctime)s] %(levelname)+7.7s: %(message)s', + datefmt='%y-%m-%d %H:%M:%S') + file_log = logging.FileHandler( + filename=os.path.join(config.get('logging_basedir'), + 'testflinger-agent.log')) + file_log.setFormatter(logfmt) + logger.addHandler(file_log) + if not config.get('logging_quiet'): + console_log = logging.StreamHandler() + console_log.setFormatter(logfmt) + logger.addHandler(console_log) + logger.setLevel(log_level) + + def parse_args(): parser = argparse.ArgumentParser(description='Testflinger Agent') parser.add_argument('--config', '-c', default='testflinger-agent.conf', diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index d7263cc5..14296bc5 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -22,6 +22,8 @@ default='/tmp/testflinger/run'): str, voluptuous.Required('logging_basedir', default='/tmp/testflinger/logs'): str, + voluptuous.Required('logging_level', default='INFO'): str, + voluptuous.Required('logging_quiet', default=False): bool, voluptuous.Required('job_queues'): list, voluptuous.Required('setup_command', default=''): str, voluptuous.Required('provision_command', default=''): str, From 1e43a755c5d77e65506a63858155e5e552c14d64 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 16 Aug 2016 14:06:29 -0500 Subject: [PATCH 056/569] Run in a loop and request jobs from the testflinger server --- setup.py | 6 +++ testflinger_agent/__init__.py | 14 ++++++- testflinger_agent/client.py | 55 ++++++++++++++++++++++++++ testflinger_agent/tests/test_client.py | 40 +++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 testflinger_agent/client.py create mode 100644 testflinger_agent/tests/test_client.py diff --git a/setup.py b/setup.py index 3a71fb30..01cb3c7c 100755 --- a/setup.py +++ b/setup.py @@ -20,9 +20,14 @@ INSTALL_REQUIRES = [ "PyYAML", + "requests", "voluptuous", ] +TEST_REQUIRES = [ + "mock", +] + setup( name='testflinger-agent', version='1.0', @@ -31,5 +36,6 @@ zip_safe=False, install_requires=INSTALL_REQUIRES, test_suite='testflinger_agent.tests', + tests_require=TEST_REQUIRES, scripts=['testflinger-agent'], ) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 2851720e..f15c1e1a 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -15,9 +15,11 @@ import argparse import logging import os +import sys +import time import yaml -from testflinger_agent import schema +from testflinger_agent import (client, schema) logger = logging.getLogger() @@ -28,6 +30,16 @@ def main(): args = parse_args() load_config(args.config) configure_logging() + check_interval = config.get('polling_interval') + while True: + try: + logger.info("Checking jobs") + client.process_jobs() + logger.info("Sleeping for {}".format(check_interval)) + time.sleep(check_interval) + except KeyboardInterrupt: + logger.info('Caught interrupt, exiting!') + sys.exit(0) def load_config(configfile): diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py new file mode 100644 index 00000000..3322ed39 --- /dev/null +++ b/testflinger_agent/client.py @@ -0,0 +1,55 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import requests +import time + +from urllib.parse import urljoin + +import testflinger_agent + +logger = logging.getLogger() + + +def process_jobs(): + """Coordinate checking for new jobs and handling them if they exists""" + job_data = check_jobs() + if not job_data: + return + logger.info("Starting job %s", job_data.get('job_id')) + + +def check_jobs(): + """Check for new jobs for on the Testflinger server + + :return: Dict with job data, or None if no job found + """ + try: + server = testflinger_agent.config.get('server_address') + if not server.lower().startswith('http'): + server = 'http://' + server + job_uri = urljoin(server, '/v1/job') + logger.info(server) + queue_list = testflinger_agent.config.get('job_queues') + logger.debug("Requesting a job") + job_request = requests.get(job_uri, params={'queue': queue_list}) + if job_request.content: + return job_request.json() + else: + return None + except Exception as e: + logger.exception(e) + # Wait a little extra before trying again + time.sleep(60) diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py new file mode 100644 index 00000000..26caaaae --- /dev/null +++ b/testflinger_agent/tests/test_client.py @@ -0,0 +1,40 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import requests +import uuid + +import testflinger_agent + +from mock import patch +from unittest import TestCase + + +class ClientTest(TestCase): + @patch('requests.get') + def test_check_jobs_empty(self, mock_requests_get): + mock_requests_get.return_value = requests.Response() + job_data = testflinger_agent.client.check_jobs() + self.assertEqual(job_data, None) + + @patch('requests.get') + def test_check_jobs_with_job(self, mock_requests_get): + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test_queue'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + mock_requests_get.return_value = fake_response + job_data = testflinger_agent.client.check_jobs() + self.assertEqual(job_data, fake_job_data) From 5d862227cd7fb49fd063f6027195d29b6ce0ae92 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 17 Aug 2016 16:00:03 -0500 Subject: [PATCH 057/569] Run test phases as defined by the config --- testflinger_agent/client.py | 40 +++++++++++++++++ testflinger_agent/tests/test_client.py | 60 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 3322ed39..c184d2e7 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -13,7 +13,11 @@ # along with this program. If not, see . import logging +import json +import os import requests +import subprocess +import sys import time from urllib.parse import urljoin @@ -25,10 +29,20 @@ def process_jobs(): """Coordinate checking for new jobs and handling them if they exists""" + TEST_PHASES = ['setup', 'provision', 'test'] job_data = check_jobs() if not job_data: return logger.info("Starting job %s", job_data.get('job_id')) + rundir = os.path.join(testflinger_agent.config.get('execution_basedir'), + job_data.get('job_id')) + os.makedirs(rundir) + # Dump the job data to testflinger.json in our execution directory + with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: + json.dump(job_data, f) + + for phase in TEST_PHASES: + run_test_phase(phase, rundir) def check_jobs(): @@ -53,3 +67,29 @@ def check_jobs(): logger.exception(e) # Wait a little extra before trying again time.sleep(60) + + +def run_test_phase(phase, rundir): + cmd = testflinger_agent.config.get(phase+'_command') + if not cmd: + return + phase_log = os.path.join(rundir, phase+'.log') + logger.info('Running %s_command: %s' % (phase, cmd)) + run_with_log(cmd, phase_log, rundir) + + +def run_with_log(cmd, logfile, cwd=None): + with open(logfile, 'w') as f: + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=True, cwd=cwd) + while process.poll() is None: + line = process.stdout.readline() + if line: + sys.stdout.write(line.decode()) + f.write(line.decode()) + f.flush() + line = process.stdout.read() + if line: + sys.stdout.write(line.decode()) + f.write(line.decode()) diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 26caaaae..940fade3 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -14,6 +14,9 @@ import json import requests +import tempfile +import os +import shutil import uuid import testflinger_agent @@ -38,3 +41,60 @@ def test_check_jobs_with_job(self, mock_requests_get): mock_requests_get.return_value = fake_response job_data = testflinger_agent.client.check_jobs() self.assertEqual(job_data, fake_job_data) + + +class ClientRunTests(TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + testflinger_agent.config = {'agent_id': 'test01', + 'polling_interval': '2', + 'server_address': '127.0.0.1:8000', + 'job_queues': ['test'], + 'execution_basedir': self.tmpdir, + 'logging_basedir': self.tmpdir, + } + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + @patch('requests.get') + def test_check_and_run_setup(self, mock_requests_get): + testflinger_agent.config['setup_command'] = 'echo setup1' + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + mock_requests_get.return_value = fake_response + testflinger_agent.client.process_jobs() + setuplog = open(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'setup.log')).read() + self.assertEqual('setup1', setuplog.strip()) + + @patch('requests.get') + def test_check_and_run_provision(self, mock_requests_get): + testflinger_agent.config['provision_command'] = 'echo provision1' + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + mock_requests_get.return_value = fake_response + testflinger_agent.client.process_jobs() + provisionlog = open(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'provision.log')).read() + self.assertEqual('provision1', provisionlog.strip()) + + @patch('requests.get') + def test_check_and_run_test(self, mock_requests_get): + testflinger_agent.config['test_command'] = 'echo test1' + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + mock_requests_get.return_value = fake_response + testflinger_agent.client.process_jobs() + testlog = open(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'test.log')).read() + self.assertEqual('test1', testlog.strip()) From 651981b104d5d15cf0218ff6381c7bee239e5ada Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 18 Aug 2016 10:26:00 -0500 Subject: [PATCH 058/569] Add an example config --- testflinger-agent.conf.example | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 testflinger-agent.conf.example diff --git a/testflinger-agent.conf.example b/testflinger-agent.conf.example new file mode 100644 index 00000000..8a867270 --- /dev/null +++ b/testflinger-agent.conf.example @@ -0,0 +1,36 @@ +# Unique identifier for this agent +# agent_id: agent-007 + +# Time to sleep between polling for new tests (default: 10s) +# polling_interval: 10 + +# Host/IP and port of the testflinger server +# server address: 127.0.0.1:8000 + +# Base directory to use for running jobs +# (default: /tmp/testflinger/run) +# execution_basedir: /tmp/testflinger/run + +# Base directory to use for agent logging +# (default: /tmp/testflinger/logs) +# logging_basedir: /tmp/testflinger/logs + +# Python loglevel name to use for logging (default: INFO) +# logging_level: DEBUG + +# Only log to the logfile, and not to the console (default: False) +# logging_quiet: True + +# List of queues that can be serviced by this device +# job_queues: +# - myqueue +# - anotherqueue + +# Command to run for the setup phase +# setup_command: echo setup phase && run-setup-tasks.sh + +# Command to run for the provision phase +# provision_command: echo provision phase && provision-system.sh + +# Command to run for the testing phase +# test_command: echo test phase && run-test.sh From 87e00c336cf5b6ef189b4f1932d489f02b1d3035 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 18 Aug 2016 16:38:07 -0500 Subject: [PATCH 059/569] Submit result at the end of the job --- testflinger_agent/client.py | 52 +++++++++++++++++++++++++- testflinger_agent/tests/test_client.py | 10 +++-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index c184d2e7..109cf999 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -40,10 +40,15 @@ def process_jobs(): # Dump the job data to testflinger.json in our execution directory with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: json.dump(job_data, f) + # Create json outcome file where phases will store their output + with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: + json.dump({}, f) for phase in TEST_PHASES: run_test_phase(phase, rundir) + transmit_job_outcome(rundir, job_data) + def check_jobs(): """Check for new jobs for on the Testflinger server @@ -55,7 +60,6 @@ def check_jobs(): if not server.lower().startswith('http'): server = 'http://' + server job_uri = urljoin(server, '/v1/job') - logger.info(server) queue_list = testflinger_agent.config.get('job_queues') logger.debug("Requesting a job") job_request = requests.get(job_uri, params={'queue': queue_list}) @@ -75,10 +79,53 @@ def run_test_phase(phase, rundir): return phase_log = os.path.join(rundir, phase+'.log') logger.info('Running %s_command: %s' % (phase, cmd)) - run_with_log(cmd, phase_log, rundir) + try: + exitcode = run_with_log(cmd, phase_log, rundir) + finally: + # Save the output log in the json file no matter what + with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: + outcome_data = json.load(f) + outcome_data[phase+'_output'] = open(phase_log).read() + outcome_data[phase+'_status'] = exitcode + with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: + json.dump(outcome_data, f) + + +def transmit_job_outcome(rundir, job_data): + """Post job outcome json data to the testflinger server + + :param rundir: + Execution dir where the results can be found + :param job_data: + Original job data for the test run, so we can get job_id and other info + """ + server = testflinger_agent.config.get('server_address') + if not server.lower().startswith('http'): + server = 'http://' + server + # Create uri for API: /v1/result/ + job_id = job_data.get('job_id') + result_uri = urljoin(server, '/v1/result/') + result_uri = urljoin(result_uri, job_id) + logger.info('Submitting job outcome for job: %s' % job_id) + with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: + job_request = requests.post(result_uri, json=json.load(f)) + if job_request.status_code != 200: + logging.error('Unable to post results to: %s (error: %s)' % + (result_uri, job_request.status_code)) def run_with_log(cmd, logfile, cwd=None): + """Execute command in a subprocess and log the output + + :param cmd: + Command to run + :param logfile: + Filename to save the output in + :param cwd: + Path to run the command from + :return: + returncode from the process + """ with open(logfile, 'w') as f: process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -93,3 +140,4 @@ def run_with_log(cmd, logfile, cwd=None): if line: sys.stdout.write(line.decode()) f.write(line.decode()) + return process.returncode diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 940fade3..f08eb5a4 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -57,8 +57,9 @@ def setUp(self): def tearDown(self): shutil.rmtree(self.tmpdir) + @patch('requests.post') @patch('requests.get') - def test_check_and_run_setup(self, mock_requests_get): + def test_check_and_run_setup(self, mock_requests_get, mock_requests_post): testflinger_agent.config['setup_command'] = 'echo setup1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test'} @@ -71,8 +72,10 @@ def test_check_and_run_setup(self, mock_requests_get): 'setup.log')).read() self.assertEqual('setup1', setuplog.strip()) + @patch('requests.post') @patch('requests.get') - def test_check_and_run_provision(self, mock_requests_get): + def test_check_and_run_provision(self, mock_requests_get, + mock_requests_post): testflinger_agent.config['provision_command'] = 'echo provision1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test'} @@ -85,8 +88,9 @@ def test_check_and_run_provision(self, mock_requests_get): 'provision.log')).read() self.assertEqual('provision1', provisionlog.strip()) + @patch('requests.post') @patch('requests.get') - def test_check_and_run_test(self, mock_requests_get): + def test_check_and_run_test(self, mock_requests_get, mock_requests_post): testflinger_agent.config['test_command'] = 'echo test1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test'} From 21fe582ff694343763ee3464307fb1a39f3971bf Mon Sep 17 00:00:00 2001 From: Maciej Kisielewski Date: Fri, 19 Aug 2016 13:40:09 +0200 Subject: [PATCH 060/569] don't sleep if there's another job in the queue This patch makes process_jobs try fetching another job, and returning only if there's none. This way, if there's anything left in the queue, the agent will start to process it immedietaly. Previously unit tests mocked only the one return value of what can come from the server. Logic suggests that when the queue is empty, empty content is returned. So this patch introduces a fake 'terminator' object and changes the one returned value to a list of side effects (two - first -> fake content, second -> the terminator). Signed-off-by: Maciej Kisielewski --- testflinger_agent/client.py | 36 ++++++++++++++------------ testflinger_agent/tests/test_client.py | 12 ++++++--- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 109cf999..997e281d 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -31,23 +31,25 @@ def process_jobs(): """Coordinate checking for new jobs and handling them if they exists""" TEST_PHASES = ['setup', 'provision', 'test'] job_data = check_jobs() - if not job_data: - return - logger.info("Starting job %s", job_data.get('job_id')) - rundir = os.path.join(testflinger_agent.config.get('execution_basedir'), - job_data.get('job_id')) - os.makedirs(rundir) - # Dump the job data to testflinger.json in our execution directory - with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: - json.dump(job_data, f) - # Create json outcome file where phases will store their output - with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: - json.dump({}, f) - - for phase in TEST_PHASES: - run_test_phase(phase, rundir) - - transmit_job_outcome(rundir, job_data) + while job_data: + logger.info("Starting job %s", job_data.get('job_id')) + rundir = os.path.join( + testflinger_agent.config.get('execution_basedir'), + job_data.get('job_id')) + os.makedirs(rundir) + # Dump the job data to testflinger.json in our execution directory + with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: + json.dump(job_data, f) + # Create json outcome file where phases will store their output + with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: + json.dump({}, f) + + for phase in TEST_PHASES: + run_test_phase(phase, rundir) + + transmit_job_outcome(rundir, job_data) + + job_data = check_jobs() def check_jobs(): diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index f08eb5a4..280536ab 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -65,7 +65,9 @@ def test_check_and_run_setup(self, mock_requests_get, mock_requests_post): 'job_queue': 'test'} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() - mock_requests_get.return_value = fake_response + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] testflinger_agent.client.process_jobs() setuplog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), @@ -81,7 +83,9 @@ def test_check_and_run_provision(self, mock_requests_get, 'job_queue': 'test'} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() - mock_requests_get.return_value = fake_response + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] testflinger_agent.client.process_jobs() provisionlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), @@ -96,7 +100,9 @@ def test_check_and_run_test(self, mock_requests_get, mock_requests_post): 'job_queue': 'test'} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() - mock_requests_get.return_value = fake_response + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] testflinger_agent.client.process_jobs() testlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), From 8239bdd11ee449946b66168f3b3e1879524f5276 Mon Sep 17 00:00:00 2001 From: Maciej Kisielewski Date: Fri, 19 Aug 2016 13:44:57 +0200 Subject: [PATCH 061/569] fix possible leak of the phase_log file Signed-off-by: Maciej Kisielewski --- testflinger_agent/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 997e281d..0d4d4f48 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -87,8 +87,9 @@ def run_test_phase(phase, rundir): # Save the output log in the json file no matter what with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: outcome_data = json.load(f) - outcome_data[phase+'_output'] = open(phase_log).read() - outcome_data[phase+'_status'] = exitcode + with open(phase_log) as f: + outcome_data[phase+'_output'] = f.read() + outcome_data[phase+'_status'] = exitcode with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: json.dump(outcome_data, f) From ae533cbde6fbc7752853d5b30056770891da00bb Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 19 Aug 2016 11:23:17 -0500 Subject: [PATCH 062/569] Stop executing the job if a test phase fails --- testflinger_agent/client.py | 19 ++++++++++++++++--- testflinger_agent/tests/test_client.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 0d4d4f48..eaeb6348 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -45,8 +45,10 @@ def process_jobs(): json.dump({}, f) for phase in TEST_PHASES: - run_test_phase(phase, rundir) - + exitcode = run_test_phase(phase, rundir) + if exitcode: + logger.debug('Phase %s failed, aborting job' % phase) + break transmit_job_outcome(rundir, job_data) job_data = check_jobs() @@ -76,9 +78,19 @@ def check_jobs(): def run_test_phase(phase, rundir): + """Run the specified test phase in rundir + + :param phase: + Name of the test phase (setup, provision, test, ...) + :param rundir: + Directory in which to run the command defined for the phase + :return: + Returncode from the command that was executed, 0 will be returned + if there was no command to run + """ cmd = testflinger_agent.config.get(phase+'_command') if not cmd: - return + return 0 phase_log = os.path.join(rundir, phase+'.log') logger.info('Running %s_command: %s' % (phase, cmd)) try: @@ -92,6 +104,7 @@ def run_test_phase(phase, rundir): outcome_data[phase+'_status'] = exitcode with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: json.dump(outcome_data, f) + return exitcode def transmit_job_outcome(rundir, job_data): diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 280536ab..72612687 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -108,3 +108,25 @@ def test_check_and_run_test(self, mock_requests_get, mock_requests_post): fake_job_data.get('job_id'), 'test.log')).read() self.assertEqual('test1', testlog.strip()) + + @patch('requests.post') + @patch('requests.get') + def test_phase_failed(self, mock_requests_get, mock_requests_post): + """Make sure we stop running after a failed phase""" + testflinger_agent.config['provision_command'] = '/bin/false' + testflinger_agent.config['test_command'] = 'echo test1' + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] + testflinger_agent.client.process_jobs() + outcome_file = os.path.join(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'testflinger-outcome.json')) + with open(outcome_file) as f: + outcome_data = json.load(f) + self.assertEqual(1, outcome_data.get('provision_status')) + self.assertEqual(None, outcome_data.get('test_status')) From c007601f10e110eceb74083ac1ec69321f0db22c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 19 Aug 2016 12:03:03 -0500 Subject: [PATCH 063/569] read the job data from testflinger.json in transmit_job_data rather than passing it in --- testflinger_agent/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index eaeb6348..e3c0a2d8 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -49,7 +49,7 @@ def process_jobs(): if exitcode: logger.debug('Phase %s failed, aborting job' % phase) break - transmit_job_outcome(rundir, job_data) + transmit_job_outcome(rundir) job_data = check_jobs() @@ -107,18 +107,18 @@ def run_test_phase(phase, rundir): return exitcode -def transmit_job_outcome(rundir, job_data): +def transmit_job_outcome(rundir): """Post job outcome json data to the testflinger server :param rundir: Execution dir where the results can be found - :param job_data: - Original job data for the test run, so we can get job_id and other info """ server = testflinger_agent.config.get('server_address') if not server.lower().startswith('http'): server = 'http://' + server # Create uri for API: /v1/result/ + with open(os.path.join(rundir, 'testflinger.json')) as f: + job_data = json.load(f) job_id = job_data.get('job_id') result_uri = urljoin(server, '/v1/result/') result_uri = urljoin(result_uri, job_id) From ec4b830b4e2daf57ce23c1e85540a738a973d53a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 22 Aug 2016 13:56:40 -0500 Subject: [PATCH 064/569] Save the results to results_basedir/ for retry if transmitting them fails --- testflinger_agent/__init__.py | 3 +++ testflinger_agent/client.py | 18 ++++++++++--- testflinger_agent/errors.py | 17 ++++++++++++ testflinger_agent/schema.py | 2 ++ testflinger_agent/tests/test_client.py | 36 ++++++++++++++++++++++---- 5 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 testflinger_agent/errors.py diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index f15c1e1a..d10dc0b1 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -51,7 +51,10 @@ def load_config(configfile): def configure_logging(): global config + # Create these at the beginning so we fail early if there are + # permission problems os.makedirs(config.get('logging_basedir'), exist_ok=True) + os.makedirs(config.get('results_basedir'), exist_ok=True) log_level = logging.getLevelName(config.get('logging_level')) # This should help if they specify something invalid if not isinstance(log_level, int): diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index e3c0a2d8..f3e09f8e 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -16,6 +16,7 @@ import json import os import requests +import shutil import subprocess import sys import time @@ -24,6 +25,8 @@ import testflinger_agent +from testflinger_agent.errors import TFServerError + logger = logging.getLogger() @@ -49,7 +52,11 @@ def process_jobs(): if exitcode: logger.debug('Phase %s failed, aborting job' % phase) break - transmit_job_outcome(rundir) + try: + transmit_job_outcome(rundir) + except: + results_basedir = testflinger_agent.config.get('results_basedir') + shutil.move(rundir, results_basedir) job_data = check_jobs() @@ -99,9 +106,10 @@ def run_test_phase(phase, rundir): # Save the output log in the json file no matter what with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: outcome_data = json.load(f) - with open(phase_log) as f: - outcome_data[phase+'_output'] = f.read() - outcome_data[phase+'_status'] = exitcode + if os.path.exists(phase_log): + with open(phase_log) as f: + outcome_data[phase+'_output'] = f.read() + outcome_data[phase+'_status'] = exitcode with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: json.dump(outcome_data, f) return exitcode @@ -128,6 +136,8 @@ def transmit_job_outcome(rundir): if job_request.status_code != 200: logging.error('Unable to post results to: %s (error: %s)' % (result_uri, job_request.status_code)) + raise TFServerError(job_request.status_code) + shutil.rmtree(rundir) def run_with_log(cmd, logfile, cwd=None): diff --git a/testflinger_agent/errors.py b/testflinger_agent/errors.py new file mode 100644 index 00000000..dc1090d1 --- /dev/null +++ b/testflinger_agent/errors.py @@ -0,0 +1,17 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +class TFServerError(Exception): + pass diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index 14296bc5..4932a9e7 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -22,6 +22,8 @@ default='/tmp/testflinger/run'): str, voluptuous.Required('logging_basedir', default='/tmp/testflinger/logs'): str, + voluptuous.Required('results_basedir', + default='/tmp/testflinger/results'): str, voluptuous.Required('logging_level', default='INFO'): str, voluptuous.Required('logging_quiet', default=False): bool, voluptuous.Required('job_queues'): list, diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 72612687..12d93aff 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -21,7 +21,7 @@ import testflinger_agent -from mock import patch +from mock import (patch, MagicMock) from unittest import TestCase @@ -52,14 +52,19 @@ def setUp(self): 'job_queues': ['test'], 'execution_basedir': self.tmpdir, 'logging_basedir': self.tmpdir, + 'results_basedir': os.path.join( + self.tmpdir, + 'results') } def tearDown(self): shutil.rmtree(self.tmpdir) + @patch('shutil.rmtree') @patch('requests.post') @patch('requests.get') - def test_check_and_run_setup(self, mock_requests_get, mock_requests_post): + def test_check_and_run_setup(self, mock_requests_get, mock_requests_post, + mock_rmtree): testflinger_agent.config['setup_command'] = 'echo setup1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test'} @@ -68,16 +73,21 @@ def test_check_and_run_setup(self, mock_requests_get, mock_requests_post): terminator = requests.Response() terminator._content = {} mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.side_effect = [MagicMock(status_code=200)] testflinger_agent.client.process_jobs() setuplog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'setup.log')).read() self.assertEqual('setup1', setuplog.strip()) + @patch('shutil.rmtree') @patch('requests.post') @patch('requests.get') def test_check_and_run_provision(self, mock_requests_get, - mock_requests_post): + mock_requests_post, mock_rmtree): testflinger_agent.config['provision_command'] = 'echo provision1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test'} @@ -86,15 +96,21 @@ def test_check_and_run_provision(self, mock_requests_get, terminator = requests.Response() terminator._content = {} mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.side_effect = [MagicMock(status_code=200)] testflinger_agent.client.process_jobs() provisionlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'provision.log')).read() self.assertEqual('provision1', provisionlog.strip()) + @patch('shutil.rmtree') @patch('requests.post') @patch('requests.get') - def test_check_and_run_test(self, mock_requests_get, mock_requests_post): + def test_check_and_run_test(self, mock_requests_get, mock_requests_post, + mock_rmtree): testflinger_agent.config['test_command'] = 'echo test1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test'} @@ -103,15 +119,21 @@ def test_check_and_run_test(self, mock_requests_get, mock_requests_post): terminator = requests.Response() terminator._content = {} mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.side_effect = [MagicMock(status_code=200)] testflinger_agent.client.process_jobs() testlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'test.log')).read() self.assertEqual('test1', testlog.strip()) + @patch('shutil.rmtree') @patch('requests.post') @patch('requests.get') - def test_phase_failed(self, mock_requests_get, mock_requests_post): + def test_phase_failed(self, mock_requests_get, mock_requests_post, + mock_rmtree): """Make sure we stop running after a failed phase""" testflinger_agent.config['provision_command'] = '/bin/false' testflinger_agent.config['test_command'] = 'echo test1' @@ -122,6 +144,10 @@ def test_phase_failed(self, mock_requests_get, mock_requests_post): terminator = requests.Response() terminator._content = {} mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.side_effect = [MagicMock(status_code=200)] testflinger_agent.client.process_jobs() outcome_file = os.path.join(os.path.join(self.tmpdir, fake_job_data.get('job_id'), From eba1af586a5263540db887fd5341276c1abcaf29 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 22 Aug 2016 16:18:07 -0500 Subject: [PATCH 065/569] Retry sending any saved results at the beginning of each interval --- testflinger_agent/client.py | 22 ++++++++++++++++++ testflinger_agent/errors.py | 7 +++++- testflinger_agent/tests/test_client.py | 31 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index f3e09f8e..ee13c75d 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -33,6 +33,10 @@ def process_jobs(): """Coordinate checking for new jobs and handling them if they exists""" TEST_PHASES = ['setup', 'provision', 'test'] + + # First, see if we have any old results that we couldn't send last time + retry_old_results() + job_data = check_jobs() while job_data: logger.info("Starting job %s", job_data.get('job_id')) @@ -61,6 +65,24 @@ def process_jobs(): job_data = check_jobs() +def retry_old_results(): + """Retry sending results that we previously failed to send""" + + results_dir = testflinger_agent.config.get('results_basedir') + # List all the directories in 'results_basedir', where we store the + # results that we couldn't transmit before + old_results = [os.path.join(results_dir, d) + for d in os.listdir(results_dir) + if os.path.isdir(os.path.join(results_dir, d))] + for result in old_results: + try: + logger.info('Attempting to send result: %s' % result) + transmit_job_outcome(result) + except TFServerError: + # Problems still, better luck next time? + pass + + def check_jobs(): """Check for new jobs for on the Testflinger server diff --git a/testflinger_agent/errors.py b/testflinger_agent/errors.py index dc1090d1..d4b6db3b 100644 --- a/testflinger_agent/errors.py +++ b/testflinger_agent/errors.py @@ -14,4 +14,9 @@ class TFServerError(Exception): - pass + def __init__(self, m): + self.code = m + self.message = 'HTTP Status: {}'.format(m) + + def __str__(self): + return self.message diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 12d93aff..38e55b8b 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -20,6 +20,7 @@ import uuid import testflinger_agent +from testflinger_agent.errors import TFServerError from mock import (patch, MagicMock) from unittest import TestCase @@ -56,6 +57,7 @@ def setUp(self): self.tmpdir, 'results') } + testflinger_agent.configure_logging() def tearDown(self): shutil.rmtree(self.tmpdir) @@ -156,3 +158,32 @@ def test_phase_failed(self, mock_requests_get, mock_requests_post, outcome_data = json.load(f) self.assertEqual(1, outcome_data.get('provision_status')) self.assertEqual(None, outcome_data.get('test_status')) + + @patch('testflinger_agent.client.transmit_job_outcome') + @patch('requests.get') + def test_retry_transmit(self, mock_requests_get, + mock_transmit_job_outcome): + """Make sure we retry sending test results""" + testflinger_agent.config['provision_command'] = '/bin/false' + testflinger_agent.config['test_command'] = 'echo test1' + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + # Send an extra terminator since we will be calling get 3 times + mock_requests_get.side_effect = [fake_response, terminator, terminator] + # Make sure we fail the first time when transmitting the results + mock_transmit_job_outcome.side_effect = [TFServerError(404), 200] + testflinger_agent.client.process_jobs() + first_dir = os.path.join( + testflinger_agent.config.get('execution_basedir'), + fake_job_data.get('job_id')) + mock_transmit_job_outcome.assert_called_with(first_dir) + # Try processing the jobs again, now it should be in results_basedir + testflinger_agent.client.process_jobs() + retry_dir = os.path.join( + testflinger_agent.config.get('results_basedir'), + fake_job_data.get('job_id')) + mock_transmit_job_outcome.assert_called_with(retry_dir) From 145c6adbf394f1e9e5cf4f1c04e43415e2bfc7ca Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 23 Aug 2016 09:05:27 -0500 Subject: [PATCH 066/569] Comment on why we broadly catch errors, and logging so we know the details if we get an exception when trying to transmit outcome --- testflinger_agent/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index ee13c75d..a492b190 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -58,7 +58,10 @@ def process_jobs(): break try: transmit_job_outcome(rundir) - except: + except Exception as e: + # TFServerError will happen if we get other-than-good status + # Other errors can happen too for things like connection problems + logger.exception(e) results_basedir = testflinger_agent.config.get('results_basedir') shutil.move(rundir, results_basedir) From 58f3978b2e299dab7238b308b426d612f3679ca1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 24 Aug 2016 13:15:47 -0500 Subject: [PATCH 067/569] avoid crashing if we get interrupted during running a test phase, before we have proper exit status --- testflinger_agent/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index a492b190..ce6e0e1c 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -125,6 +125,8 @@ def run_test_phase(phase, rundir): return 0 phase_log = os.path.join(rundir, phase+'.log') logger.info('Running %s_command: %s' % (phase, cmd)) + # Set the exitcode to some failed status in case we get interrupted + exitcode = 99 try: exitcode = run_with_log(cmd, phase_log, rundir) finally: From 659a6e95474e7d8e538ae0c92fa88909122fd1e9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 24 Aug 2016 13:16:07 -0500 Subject: [PATCH 068/569] Add example results_basedir to the example config --- testflinger-agent.conf.example | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testflinger-agent.conf.example b/testflinger-agent.conf.example index 8a867270..f1b8e22b 100644 --- a/testflinger-agent.conf.example +++ b/testflinger-agent.conf.example @@ -5,7 +5,7 @@ # polling_interval: 10 # Host/IP and port of the testflinger server -# server address: 127.0.0.1:8000 +# server_address: 127.0.0.1:8000 # Base directory to use for running jobs # (default: /tmp/testflinger/run) @@ -15,6 +15,10 @@ # (default: /tmp/testflinger/logs) # logging_basedir: /tmp/testflinger/logs +# Base directory to use for queuing results for retransmit +# (default: /tmp/testflinger/results) +# results_basedir: /tmp/testflinger/results + # Python loglevel name to use for logging (default: INFO) # logging_level: DEBUG From 08da75b111fa4444b87ef8198fde18c571941b73 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 24 Aug 2016 10:39:38 -0500 Subject: [PATCH 069/569] SPI is deprecated, expect to talk to testflinger instead --- devices/bbb/__init__.py | 2 +- devices/dragonboard/__init__.py | 2 +- devices/inception/__init__.py | 2 +- devices/netboot/__init__.py | 2 +- devices/rpi2/__init__.py | 2 +- snappy_device_agents/__init__.py | 25 ++++++++++++++----------- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 0039ec70..ee89fb2a 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -81,7 +81,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) - test_cmds = test_opportunity.get('test_payload').get('test_cmds') + test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: # Settings from the device yaml configfile like device_ip can be diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 06f579fd..a8de5f9a 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -79,7 +79,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) - test_cmds = test_opportunity.get('test_payload').get('test_cmds') + test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: # Settings from the device yaml configfile like device_ip can be diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py index 30c32421..85f991d5 100644 --- a/devices/inception/__init__.py +++ b/devices/inception/__init__.py @@ -79,7 +79,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) - test_cmds = test_opportunity.get('test_payload').get('test_cmds') + test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: # Settings from the device yaml configfile like device_ip can be diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index cb7ad53d..efbcd8ce 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -79,7 +79,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) - test_cmds = test_opportunity.get('test_payload').get('test_cmds') + test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: # Settings from the device yaml configfile like device_ip can be diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py index 9f06331c..e6176574 100644 --- a/devices/rpi2/__init__.py +++ b/devices/rpi2/__init__.py @@ -77,7 +77,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.spi_data) - test_cmds = test_opportunity.get('test_payload').get('test_cmds') + test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: # Settings from the device yaml configfile like device_ip can be diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 88fa1f70..bc356a9f 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -31,7 +31,7 @@ logger = logging.getLogger() -def get_test_opportunity(spi_file='spi_test_opportunity.json'): +def get_test_opportunity(spi_file='testflinger.json'): """ Read the json test opportunity data from spi_test_opportunity.json. @@ -40,11 +40,13 @@ def get_test_opportunity(spi_file='spi_test_opportunity.json'): :return test_opportunity: Dictionary of values read from the json file """ + # PWL: TODO: probably get rid of this entire section = we should have all of it as real json already with open(spi_file, encoding='utf-8') as spi_json: test_opportunity = json.load(spi_json) # test_payload and image_reference may contain json in a string # XXX: This can be removed in the future when arbitrary json is # supported + """ try: test_opportunity['test_payload'] = json.loads( test_opportunity['test_payload']) @@ -57,6 +59,7 @@ def get_test_opportunity(spi_file='spi_test_opportunity.json'): except: # If this fails, we simply leave the field alone pass + """ return test_opportunity @@ -156,7 +159,7 @@ def udf_create_image(params): return(imagepath) -def get_test_username(spi_file='spi_test_opportunity.json'): +def get_test_username(spi_file='testflinger.json'): """ Read the json data for a test opportunity from SPI and return the username in specified for the test image (default: ubuntu) @@ -165,10 +168,10 @@ def get_test_username(spi_file='spi_test_opportunity.json'): Returns the test image username """ spi_data = get_test_opportunity(spi_file) - return spi_data.get('test_payload').get('test_username', 'ubuntu') + return spi_data.get('test_data').get('test_username', 'ubuntu') -def get_test_password(spi_file='spi_test_opportunity.json'): +def get_test_password(spi_file='testflinger.json'): """ Read the json data for a test opportunity from SPI and return the password in specified for the test image (default: ubuntu) @@ -177,10 +180,10 @@ def get_test_password(spi_file='spi_test_opportunity.json'): Returns the test image password """ spi_data = get_test_opportunity(spi_file) - return spi_data.get('test_payload').get('test_password', 'ubuntu') + return spi_data.get('test_data').get('test_password', 'ubuntu') -def get_image(spi_file='spi_test_opportunity.json'): +def get_image(spi_file='testflinger.json'): """ Read the json data for a test opportunity from SPI and retrieve or create the requested image. @@ -189,19 +192,19 @@ def get_image(spi_file='spi_test_opportunity.json'): Returns the filename of the compressed image """ spi_data = get_test_opportunity(spi_file) - image_keys = spi_data.get('image_reference').keys() + image_keys = spi_data.get('provision_data').keys() if 'download_files' in image_keys: - for url in spi_data.get('image_reference').get('download_files'): + for url in spi_data.get('provision_data').get('download_files'): download(url) if 'url' in image_keys: - image = download(spi_data.get('image_reference').get('url'), + image = download(spi_data.get('provision_data').get('url'), IMAGEFILE) elif 'udf-params' in image_keys: - udf_params = spi_data.get('image_reference').get('udf-params') + udf_params = spi_data.get('provision_data').get('udf-params') image = delayretry(udf_create_image, [udf_params], max_retries=3, delay=60) else: - logging.error('image_reference needs to contain "url" for the image ' + logging.error('provision_data needs to contain "url" for the image ' 'or "udf-params"') return compress_file(image) From 1d74f863561e0c3d9158fd11f89f96041b2f2a6f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 24 Aug 2016 23:01:10 -0500 Subject: [PATCH 070/569] Remove remaining references to SPI --- devices/bbb/__init__.py | 12 ++++---- devices/dragonboard/__init__.py | 12 ++++---- devices/inception/__init__.py | 12 ++++---- devices/netboot/__init__.py | 12 ++++---- devices/rpi2/__init__.py | 8 ++--- snappy_device_agents/__init__.py | 52 +++++++++++--------------------- 6 files changed, 45 insertions(+), 63 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index ee89fb2a..09a8298b 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -43,12 +43,12 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") device.ensure_emmc_image() - image = snappy_device_agents.get_image(ctx.args.spi_data) + image = snappy_device_agents.get_image(ctx.args.job_data) server_ip = snappy_device_agents.get_local_ip_addr() test_username = snappy_device_agents.get_test_username( - ctx.args.spi_data) + ctx.args.job_data) test_password = snappy_device_agents.get_test_password( - ctx.args.spi_data) + ctx.args.job_data) q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, args=(q, image,)) @@ -65,7 +65,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class runtest(guacamole.Command): @@ -80,7 +80,7 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.spi_data) + ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: @@ -108,7 +108,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class DeviceAgent(guacamole.Command): diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index a8de5f9a..a7f7bf98 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -41,12 +41,12 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") device.ensure_master_image() - image = snappy_device_agents.get_image(ctx.args.spi_data) + image = snappy_device_agents.get_image(ctx.args.job_data) server_ip = snappy_device_agents.get_local_ip_addr() test_username = snappy_device_agents.get_test_username( - ctx.args.spi_data) + ctx.args.job_data) test_password = snappy_device_agents.get_test_password( - ctx.args.spi_data) + ctx.args.job_data) q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, args=(q, image,)) @@ -63,7 +63,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class runtest(guacamole.Command): @@ -78,7 +78,7 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.spi_data) + ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: @@ -106,7 +106,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class DeviceAgent(guacamole.Command): diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py index 85f991d5..aa54c360 100644 --- a/devices/inception/__init__.py +++ b/devices/inception/__init__.py @@ -41,12 +41,12 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") device.ensure_master_image() - image = snappy_device_agents.get_image(ctx.args.spi_data) + image = snappy_device_agents.get_image(ctx.args.job_data) server_ip = snappy_device_agents.get_local_ip_addr() test_username = snappy_device_agents.get_test_username( - ctx.args.spi_data) + ctx.args.job_data) test_password = snappy_device_agents.get_test_password( - ctx.args.spi_data) + ctx.args.job_data) q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, args=(q, image,)) @@ -63,7 +63,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class runtest(guacamole.Command): @@ -78,7 +78,7 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.spi_data) + ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: @@ -106,7 +106,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class DeviceAgent(guacamole.Command): diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index efbcd8ce..03fe171b 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -41,12 +41,12 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") device.ensure_master_image() - image = snappy_device_agents.get_image(ctx.args.spi_data) + image = snappy_device_agents.get_image(ctx.args.job_data) server_ip = snappy_device_agents.get_local_ip_addr() test_username = snappy_device_agents.get_test_username( - ctx.args.spi_data) + ctx.args.job_data) test_password = snappy_device_agents.get_test_password( - ctx.args.spi_data) + ctx.args.job_data) q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, args=(q, image,)) @@ -63,7 +63,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class runtest(guacamole.Command): @@ -78,7 +78,7 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.spi_data) + ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: @@ -106,7 +106,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class DeviceAgent(guacamole.Command): diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py index e6176574..f29c97c8 100644 --- a/devices/rpi2/__init__.py +++ b/devices/rpi2/__init__.py @@ -43,7 +43,7 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") device.ensure_master_image() - image = snappy_device_agents.get_image(ctx.args.spi_data) + image = snappy_device_agents.get_image(ctx.args.job_data) server_ip = snappy_device_agents.get_local_ip_addr() q = multiprocessing.Queue() file_server = multiprocessing.Process( @@ -61,7 +61,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class runtest(guacamole.Command): @@ -76,7 +76,7 @@ def invoked(self, ctx): logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.spi_data) + ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') exitcode = 0 for cmd in test_cmds: @@ -103,7 +103,7 @@ def register_arguments(self, parser): """Method called to customize the argument parser.""" parser.add_argument('-c', '--config', required=True, help='Config file for this device') - parser.add_argument('spi_data', help='SPI json data file') + parser.add_argument('job_data', help='Testflinger json data file') class DeviceAgent(guacamole.Command): diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index bc356a9f..f69a3b55 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -31,35 +31,17 @@ logger = logging.getLogger() -def get_test_opportunity(spi_file='testflinger.json'): +def get_test_opportunity(job_data='testflinger.json'): """ - Read the json test opportunity data from spi_test_opportunity.json. + Read the json test opportunity data from testflinger.json. - :param spi_file: + :param job_data: Filename and path of the json data if not the default :return test_opportunity: Dictionary of values read from the json file """ - # PWL: TODO: probably get rid of this entire section = we should have all of it as real json already - with open(spi_file, encoding='utf-8') as spi_json: - test_opportunity = json.load(spi_json) - # test_payload and image_reference may contain json in a string - # XXX: This can be removed in the future when arbitrary json is - # supported - """ - try: - test_opportunity['test_payload'] = json.loads( - test_opportunity['test_payload']) - except: - # If this fails, we simply leave the field alone - pass - try: - test_opportunity['image_reference'] = json.loads( - test_opportunity['image_reference']) - except: - # If this fails, we simply leave the field alone - pass - """ + with open(job_data, encoding='utf-8') as job_data_json: + test_opportunity = json.load(job_data_json) return test_opportunity @@ -159,7 +141,7 @@ def udf_create_image(params): return(imagepath) -def get_test_username(spi_file='testflinger.json'): +def get_test_username(job_data='testflinger.json'): """ Read the json data for a test opportunity from SPI and return the username in specified for the test image (default: ubuntu) @@ -167,11 +149,11 @@ def get_test_username(spi_file='testflinger.json'): :return username: Returns the test image username """ - spi_data = get_test_opportunity(spi_file) - return spi_data.get('test_data').get('test_username', 'ubuntu') + testflinger_data = get_test_opportunity(job_data) + return testflinger_data.get('test_data').get('test_username', 'ubuntu') -def get_test_password(spi_file='testflinger.json'): +def get_test_password(job_data='testflinger.json'): """ Read the json data for a test opportunity from SPI and return the password in specified for the test image (default: ubuntu) @@ -179,11 +161,11 @@ def get_test_password(spi_file='testflinger.json'): :return password: Returns the test image password """ - spi_data = get_test_opportunity(spi_file) - return spi_data.get('test_data').get('test_password', 'ubuntu') + testflinger_data = get_test_opportunity(job_data) + return testflinger_data.get('test_data').get('test_password', 'ubuntu') -def get_image(spi_file='testflinger.json'): +def get_image(job_data='testflinger.json'): """ Read the json data for a test opportunity from SPI and retrieve or create the requested image. @@ -191,16 +173,16 @@ def get_image(spi_file='testflinger.json'): :return compressed_filename: Returns the filename of the compressed image """ - spi_data = get_test_opportunity(spi_file) - image_keys = spi_data.get('provision_data').keys() + testflinger_data = get_test_opportunity(job_data) + image_keys = testflinger_data.get('provision_data').keys() if 'download_files' in image_keys: - for url in spi_data.get('provision_data').get('download_files'): + for url in testflinger_data.get('provision_data').get('download_files'): download(url) if 'url' in image_keys: - image = download(spi_data.get('provision_data').get('url'), + image = download(testflinger_data.get('provision_data').get('url'), IMAGEFILE) elif 'udf-params' in image_keys: - udf_params = spi_data.get('provision_data').get('udf-params') + udf_params = testflinger_data.get('provision_data').get('udf-params') image = delayretry(udf_create_image, [udf_params], max_retries=3, delay=60) else: From 6c7e8635794778519fd67b553758e65d6290adb7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 21 Sep 2016 23:40:00 -0500 Subject: [PATCH 071/569] Add touch device support --- devices/touch/__init__.py | 94 ++++++++++++++++++++ devices/touch/touch.py | 147 +++++++++++++++++++++++++++++++ snappy_device_agents/__init__.py | 8 ++ 3 files changed, 249 insertions(+) create mode 100644 devices/touch/__init__.py create mode 100644 devices/touch/touch.py diff --git a/devices/touch/__init__.py b/devices/touch/__init__.py new file mode 100644 index 00000000..77bf13c6 --- /dev/null +++ b/devices/touch/__init__.py @@ -0,0 +1,94 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu Touch support code.""" + +import logging +import os +import yaml + +import guacamole + +import snappy_device_agents +from devices.touch.touch import Touch +from snappy_device_agents import logmsg, runcmd + +device_name = "touch" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = Touch(ctx.args.config, ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Recovering device") + device.recover() + device.provision() + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = 0 + env = os.environ.copy() + env['ANDROID_SERIAL'] = config.get('serial') + for cmd in test_cmds: + logmsg(logging.INFO, "Running: %s", cmd) + rc, output = runcmd(cmd, env=env) + if rc: + exitcode = 4 + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + logmsg(logging.INFO, "output:\n%s", output) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Ubuntu Touch.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/touch/touch.py b/devices/touch/touch.py new file mode 100644 index 00000000..41dc3a59 --- /dev/null +++ b/devices/touch/touch.py @@ -0,0 +1,147 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu Touch support code.""" + +import json +import logging +import yaml + +from devices import (ProvisioningError, + RecoveryError) +from snappy_device_agents import download, runcmd + +logger = logging.getLogger() + + +class Touch: + + """Device Agent for Touch.""" + + def __init__(self, config, job_data): + with open(config) as configfile: + self.config = yaml.load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) + + def recover(self): + recovery_script = self.config.get('recovery_script') + for cmd in recovery_script: + logger.info("Running %s", cmd) + rc, output = runcmd(cmd) + if rc: + logger.error('output: {}'.format(output)) + raise RecoveryError("Device recovery failed!") + + def provision(self): + p = self.job_data.get('provision_data') + self.get_recovery_image() + + server = p.get('server', 'https://system-image.ubuntu.com') + + if p.get('revision'): + rev_arg = '--revision={}'.format(p.get('revision')) + else: + rev_arg = '' + + password = p.get('password', '0000') + + self.adb_reboot_bootloader() + + cmd = ('ubuntu-device-flash --server={} {} touch --serial={} ' + '--channel={} --device={} --recovery-image=recovery.img ' + '--developer-mode --password={} ' + '--bootstrap'.format(server, rev_arg, self.config.get('serial'), + p.get('channel'), + self.config.get('device_type'), password)) + logger.info('Running ubuntu-device-flash') + rc, output = runcmd(cmd) + if rc: + logger.error('output: {}'.format(output)) + raise ProvisioningError("Flashing new image failed!") + self.adb_wait_for_device() + self.handle_welcome_wizard() + self.handle_edges_intro() + self.configure_network() + + def configure_network(self): + netspec = self.config.get('network_spec') + serial = self.config.get('serial') + if not netspec: + logger.warning('No network settings specified in the config') + return + logger.info('Configuring the network') + rc, out = runcmd('phablet-config -s {} network --write "{}"'.format( + serial, netspec)) + if rc: + logger.error('Error configuring network') + + def handle_welcome_wizard(self): + p = self.job_data.get('provision_data') + wizard = p.get('welcome_wizard', 'off') + if wizard.lower() == 'on': + logger.info('Welcome wizard will be left enabled') + return + + logger.info('Disabling the welcome wizard') + serial = self.config.get('serial') + cmd = ('phablet-config -s {} welcome-wizard ' + '--disable'.format(serial)) + rc, output = runcmd(cmd) + if rc: + logger.error('output: {}'.format(output)) + raise ProvisioningError("Disable welcome wizard failed!") + self.adb_wait_for_device() + + def handle_edges_intro(self): + p = self.job_data.get('provision_data') + intro = p.get('edges_intro', 'off') + if intro.lower() == 'on': + logger.info('Edges intro will be left enabled') + return + + logger.info('Disabling the edges intro') + serial = self.config.get('serial') + cmd = ('phablet-config -s {} edges-intro ' + '--disable'.format(serial)) + rc, output = runcmd(cmd) + if rc: + logger.error('output: {}'.format(output)) + raise ProvisioningError("Disable edges intro failed!") + self.adb_wait_for_device() + + def adb_reboot_bootloader(self): + serial = self.config.get('serial') + cmd = 'adb -s {} reboot-bootloader'.format(serial) + rc, output = runcmd(cmd) + if rc: + logger.error('output: {}'.format(output)) + raise RecoveryError("Reboot to bootloader failed!") + # FIXME: we should probably attempt hard-recovery here + + def adb_wait_for_device(self): + serial = self.config.get('serial') + cmd = 'adb -s {} wait-for-device'.format(serial) + rc, output = runcmd(cmd) + if rc: + logger.error('output: {}'.format(output)) + raise ProvisioningError("Wait for device failed!") + + def get_recovery_image(self): + device = self.config.get('device_type') + if not device: + raise ProvisioningError('No device_type specified in config') + url = ('http://people.canonical.com/~plars/touch/' + 'recovery-{}.img'.format(device)) + download(url, 'recovery.img') diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index f69a3b55..4a8e50e1 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -309,3 +309,11 @@ def logmsg(level, msg, *args, **kwargs): logger.log(level, msg[:4096]) if len(msg) > 4096: logmsg(level, msg[4096:]) + + +def runcmd(cmd, env=None): + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, env=env) + rc = proc.wait() + output, _ = proc.communicate() + return rc, output From 2874c7f0709548556d6cceaffca1ff770953bc1c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 29 Sep 2016 08:53:53 -0500 Subject: [PATCH 072/569] pep8 fix --- snappy_device_agents/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 4a8e50e1..3e5c7194 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -176,8 +176,9 @@ def get_image(job_data='testflinger.json'): testflinger_data = get_test_opportunity(job_data) image_keys = testflinger_data.get('provision_data').keys() if 'download_files' in image_keys: - for url in testflinger_data.get('provision_data').get('download_files'): - download(url) + for url in testflinger_data.get( + 'provision_data').get('download_files'): + download(url) if 'url' in image_keys: image = download(testflinger_data.get('provision_data').get('url'), IMAGEFILE) From 05b9a80a8dfeb0ff33f45f495c21285377658da1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 29 Sep 2016 08:58:46 -0500 Subject: [PATCH 073/569] Exit with a different returncode to indicate recovery failure --- README.rst | 10 ++++++++++ devices/__init__.py | 17 +++++++++++++++++ devices/dragonboard/__init__.py | 2 ++ devices/inception/__init__.py | 2 ++ devices/netboot/__init__.py | 2 ++ devices/rpi2/__init__.py | 2 ++ devices/touch/__init__.py | 2 ++ 7 files changed, 37 insertions(+) diff --git a/README.rst b/README.rst index 380d43ac..f880aeab 100644 --- a/README.rst +++ b/README.rst @@ -104,3 +104,13 @@ two additional values in the yaml file:: Logstash_host is the logstash server the messages will be sent to on port 5959. Agent_name should be the name of the device this agent represents. It will be added as extra data in the log message. + +Exit Status +=========== + +Device agents will exit with a value of ''46'' if something goes wrong during +device recovery. This can be used as an indication that the device is unusable +for some reason, and can't be recovere using automated recovery mechanisms. +The system calling the device agent may want to take further action, such +as alerting someone that it needs manual recovery, or to stop attempting to +run tests on it until it's fixed. diff --git a/devices/__init__.py b/devices/__init__.py index 0bcdc2fa..7676d11e 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -24,6 +24,23 @@ class RecoveryError(Exception): pass +def Catch(exception, returnval=0): + """ Decorator for catching Exceptions and returning values instead + + This is useful because for certain things, like RecoveryError, we + need to give the calling process a hint that we failed for that + reason, so it can act accordingly, by disabling the device for example + """ + def _wrapper(func): + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except exception: + return returnval + return wrapper + return _wrapper + + def load_devices(): devices = [] device_path = os.path.dirname(os.path.realpath(__file__)) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index a7f7bf98..b02c07c7 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -24,6 +24,7 @@ import snappy_device_agents from devices.dragonboard.dragonboard import Dragonboard from snappy_device_agents import logmsg +from devices import (Catch, RecoveryError) device_name = "dragonboard" @@ -32,6 +33,7 @@ class provision(guacamole.Command): """Tool for provisioning baremetal with a given image.""" + @Catch(RecoveryError, 46) def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py index aa54c360..3a5e0e61 100644 --- a/devices/inception/__init__.py +++ b/devices/inception/__init__.py @@ -24,6 +24,7 @@ import snappy_device_agents from devices.inception.inception import Inception from snappy_device_agents import logmsg +from devices import (Catch, RecoveryError) device_name = "inception" @@ -32,6 +33,7 @@ class provision(guacamole.Command): """Tool for provisioning x86 baremetal with a given image.""" + @Catch(RecoveryError, 46) def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 03fe171b..b29fc572 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -24,6 +24,7 @@ import snappy_device_agents from devices.netboot.netboot import Netboot from snappy_device_agents import logmsg +from devices import (Catch, RecoveryError) device_name = "netboot" @@ -32,6 +33,7 @@ class provision(guacamole.Command): """Tool for provisioning baremetal with a given image.""" + @Catch(RecoveryError, 46) def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py index f29c97c8..1a5084b7 100644 --- a/devices/rpi2/__init__.py +++ b/devices/rpi2/__init__.py @@ -24,6 +24,7 @@ import snappy_device_agents from devices.rpi2.rpi2 import RaspberryPi2 from snappy_device_agents import logmsg +from devices import (Catch, RecoveryError) device_name = "rpi2" @@ -33,6 +34,7 @@ class provision(guacamole.Command): """Tool for provisioning Raspberry Pi 2 with a given image.""" + @Catch(RecoveryError, 46) def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: diff --git a/devices/touch/__init__.py b/devices/touch/__init__.py index 77bf13c6..440d811a 100644 --- a/devices/touch/__init__.py +++ b/devices/touch/__init__.py @@ -23,6 +23,7 @@ import snappy_device_agents from devices.touch.touch import Touch from snappy_device_agents import logmsg, runcmd +from devices import (Catch, RecoveryError) device_name = "touch" @@ -31,6 +32,7 @@ class provision(guacamole.Command): """Tool for provisioning baremetal with a given image.""" + @Catch(RecoveryError, 46) def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: From 3146f9ad2490be2b64123b6bd62f880f4939d5ec Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 29 Sep 2016 23:18:39 -0500 Subject: [PATCH 074/569] Use a marker file to set the device offline if we can't recover --- testflinger_agent/__init__.py | 18 ++++++++++++ testflinger_agent/client.py | 4 +++ testflinger_agent/tests/test_client.py | 39 +++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index d10dc0b1..7c8d051a 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -25,6 +25,9 @@ config = dict() +OFFLINE_FILE = os.path.join( + '/tmp', 'TESTFLINGER-DEVICE-OFFLINE-{}'.format(config.get('agent_id'))) + def main(): args = parse_args() @@ -33,6 +36,12 @@ def main(): check_interval = config.get('polling_interval') while True: try: + if check_offline(): + logger.error("Agent %s is offline, not processing jobs!" + "Remove %s to resume processing" % + (config.get('agent_id'), OFFLINE_FILE)) + while check_offline(): + time.sleep(check_interval) logger.info("Checking jobs") client.process_jobs() logger.info("Sleeping for {}".format(check_interval)) @@ -42,6 +51,15 @@ def main(): sys.exit(0) +def check_offline(): + return os.path.exists(OFFLINE_FILE) + + +def mark_device_offline(): + # Create the offline file, this should work even if it exists + open(OFFLINE_FILE, 'w').close() + + def load_config(configfile): global config with open(configfile) as f: diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index ce6e0e1c..53553603 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -53,6 +53,10 @@ def process_jobs(): for phase in TEST_PHASES: exitcode = run_test_phase(phase, rundir) + # exit code 46 is our indication that recovery failed! + # In this case, we need to mark the device offline + if exitcode == 46: + testflinger_agent.mark_device_offline() if exitcode: logger.debug('Phase %s failed, aborting job' % phase) break diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 38e55b8b..e55cb625 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -159,10 +159,12 @@ def test_phase_failed(self, mock_requests_get, mock_requests_post, self.assertEqual(1, outcome_data.get('provision_status')) self.assertEqual(None, outcome_data.get('test_status')) + @patch('testflinger_agent.client.logger.exception') @patch('testflinger_agent.client.transmit_job_outcome') @patch('requests.get') def test_retry_transmit(self, mock_requests_get, - mock_transmit_job_outcome): + mock_transmit_job_outcome, + mock_logger_exception): """Make sure we retry sending test results""" testflinger_agent.config['provision_command'] = '/bin/false' testflinger_agent.config['test_command'] = 'echo test1' @@ -187,3 +189,38 @@ def test_retry_transmit(self, mock_requests_get, testflinger_agent.config.get('results_basedir'), fake_job_data.get('job_id')) mock_transmit_job_outcome.assert_called_with(retry_dir) + + @patch('shutil.rmtree') + @patch('requests.post') + @patch('requests.get') + def test_recovery_failed(self, mock_requests_get, mock_requests_post, + mock_rmtree): + """Make sure we stop processing jobs after a device recovery error""" + OFFLINE_FILE = '/tmp/TESTFLINGER-DEVICE-OFFLINE-test001' + if os.path.exists(OFFLINE_FILE): + os.path.unlink(OFFLINE_FILE) + testflinger_agent.config['agent_id'] = 'test001' + testflinger_agent.config['provision_command'] = 'exit 46' + testflinger_agent.config['test_command'] = 'echo test1' + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.side_effect = [MagicMock(status_code=200)] + testflinger_agent.client.process_jobs() + outcome_file = os.path.join(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'testflinger-outcome.json')) + with open(outcome_file) as f: + outcome_data = json.load(f) + self.assertEqual(46, outcome_data.get('provision_status')) + self.assertEqual(None, outcome_data.get('test_status')) + self.assertEqual(True, testflinger_agent.check_offline()) + if os.path.exists(OFFLINE_FILE): + os.path.unlink(OFFLINE_FILE) From a8c49c8e71d115d20ba701647b93ec9a9f578a54 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 30 Sep 2016 11:37:05 -0500 Subject: [PATCH 075/569] Re-post the job if to the testflinger server if we fail because the device is having problems --- testflinger_agent/client.py | 17 +++++++++++++++++ testflinger_agent/tests/test_client.py | 16 ++++++---------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 53553603..165eda68 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -57,6 +57,10 @@ def process_jobs(): # In this case, we need to mark the device offline if exitcode == 46: testflinger_agent.mark_device_offline() + repost_job(job_data) + shutil.rmtree(rundir) + # Return NOW so we don't keep trying to process jobs + return if exitcode: logger.debug('Phase %s failed, aborting job' % phase) break @@ -146,6 +150,19 @@ def run_test_phase(phase, rundir): return exitcode +def repost_job(job_data): + server = testflinger_agent.config.get('server_address') + if not server.lower().startswith('http'): + server = 'http://' + server + job_uri = urljoin(server, '/v1/job') + logger.info('Resubmitting job for job: %s' % job_data.get('job_id')) + job_request = requests.post(job_uri, json=job_data) + if job_request.status_code != 200: + logging.error('Unable to re-post job to: %s (error: %s)' % + (job_uri, job_request.status_code)) + raise TFServerError(job_request.status_code) + + def transmit_job_outcome(rundir): """Post job outcome json data to the testflinger server diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index e55cb625..7657a332 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -209,18 +209,14 @@ def test_recovery_failed(self, mock_requests_get, mock_requests_post, terminator = requests.Response() terminator._content = {} mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test + # In this case we are making sure that the repost job request + # gets good status mock_requests_post.side_effect = [MagicMock(status_code=200)] testflinger_agent.client.process_jobs() - outcome_file = os.path.join(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'testflinger-outcome.json')) - with open(outcome_file) as f: - outcome_data = json.load(f) - self.assertEqual(46, outcome_data.get('provision_status')) - self.assertEqual(None, outcome_data.get('test_status')) self.assertEqual(True, testflinger_agent.check_offline()) + # These are the args we would expect when it reposts the job + repost_args = ('http://127.0.0.1:8000/v1/job') + repost_kwargs = dict(json=fake_job_data) + mock_requests_post.assert_called_with(repost_args, **repost_kwargs) if os.path.exists(OFFLINE_FILE): os.path.unlink(OFFLINE_FILE) From 29dc343be8f2aa3ec9711e5076a1396ff5da10d9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 3 Oct 2016 12:57:16 -0500 Subject: [PATCH 076/569] Read the config before getting the offline file Doing it this way ensures tests that inject their own config data can still work. --- testflinger_agent/__init__.py | 16 +++++++++------- testflinger_agent/tests/test_client.py | 4 ++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 7c8d051a..8aeaa962 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -25,9 +25,6 @@ config = dict() -OFFLINE_FILE = os.path.join( - '/tmp', 'TESTFLINGER-DEVICE-OFFLINE-{}'.format(config.get('agent_id'))) - def main(): args = parse_args() @@ -37,9 +34,9 @@ def main(): while True: try: if check_offline(): - logger.error("Agent %s is offline, not processing jobs!" + logger.error("Agent %s is offline, not processing jobs! " "Remove %s to resume processing" % - (config.get('agent_id'), OFFLINE_FILE)) + (config.get('agent_id'), get_offline_file())) while check_offline(): time.sleep(check_interval) logger.info("Checking jobs") @@ -51,13 +48,18 @@ def main(): sys.exit(0) +def get_offline_file(): + return os.path.join( + '/tmp', 'TESTFLINGER-DEVICE-OFFLINE-{}'.format(config.get('agent_id'))) + + def check_offline(): - return os.path.exists(OFFLINE_FILE) + return os.path.exists(get_offline_file()) def mark_device_offline(): # Create the offline file, this should work even if it exists - open(OFFLINE_FILE, 'w').close() + open(get_offline_file(), 'w').close() def load_config(configfile): diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 7657a332..4a37ab7e 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -198,7 +198,7 @@ def test_recovery_failed(self, mock_requests_get, mock_requests_post, """Make sure we stop processing jobs after a device recovery error""" OFFLINE_FILE = '/tmp/TESTFLINGER-DEVICE-OFFLINE-test001' if os.path.exists(OFFLINE_FILE): - os.path.unlink(OFFLINE_FILE) + os.unlink(OFFLINE_FILE) testflinger_agent.config['agent_id'] = 'test001' testflinger_agent.config['provision_command'] = 'exit 46' testflinger_agent.config['test_command'] = 'echo test1' @@ -219,4 +219,4 @@ def test_recovery_failed(self, mock_requests_get, mock_requests_post, repost_kwargs = dict(json=fake_job_data) mock_requests_post.assert_called_with(repost_args, **repost_kwargs) if os.path.exists(OFFLINE_FILE): - os.path.unlink(OFFLINE_FILE) + os.unlink(OFFLINE_FILE) From 673eaab66c38e74c7ebf080064e09e81e1270d20 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 5 Oct 2016 22:51:16 -0500 Subject: [PATCH 077/569] Detect artifacts after running and upload them --- README.rst | 14 +++++++++ testflinger_agent/client.py | 39 ++++++++++++++++++++++---- testflinger_agent/tests/test_client.py | 30 +++++++++++++++++++- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index e7b6d094..7f6ccb52 100644 --- a/README.rst +++ b/README.rst @@ -84,3 +84,17 @@ The following configuration options are supported: - **test_command**: - Command to run for the testing phase + +Usage +----- + +When running testflinger, your output will be automatically accumulated +for each stage (setup, provision, test) and sent to the testflinger server, +along with an exit status for each stage. If any stage encounters a non-zero +exit code, no further stages will be executed, but the outcome will still +be sent. + +If you have additional artifacts that you would like to save along with +the output, you can create a 'artifacts' directory from your test command. +Any files in the artifacts directory under your test execution directory +will automatically be compressed (tar.gz) and sent to the testflinger server. diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 165eda68..2f7e8a62 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -19,6 +19,7 @@ import shutil import subprocess import sys +import tempfile import time from urllib.parse import urljoin @@ -179,12 +180,38 @@ def transmit_job_outcome(rundir): result_uri = urljoin(server, '/v1/result/') result_uri = urljoin(result_uri, job_id) logger.info('Submitting job outcome for job: %s' % job_id) - with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: - job_request = requests.post(result_uri, json=json.load(f)) - if job_request.status_code != 200: - logging.error('Unable to post results to: %s (error: %s)' % - (result_uri, job_request.status_code)) - raise TFServerError(job_request.status_code) + # Do not retransmit outcome if it's already been done and removed + outcome_file = os.path.join(rundir, 'testflinger-outcome.json') + if os.path.exists(outcome_file): + with open(outcome_file) as f: + job_request = requests.post(result_uri, json=json.load(f)) + if job_request.status_code != 200: + logging.error('Unable to post results to: %s (error: %s)' % + (result_uri, job_request.status_code)) + raise TFServerError(job_request.status_code) + else: + # Remove the outcome file so we don't retransmit + os.unlink(outcome_file) + artifacts_dir = os.path.join(rundir, 'artifacts') + # If we find an 'artifacts' dir under rundir, archive it, and transmit it + # to the Testflinger server + if os.path.exists(artifacts_dir): + with tempfile.TemporaryDirectory() as tmpdir: + artifact_file = os.path.join(tmpdir, 'artifacts') + shutil.make_archive(artifact_file, format='gztar', + root_dir=rundir, base_dir='artifacts') + artifact_uri = urljoin( + server, '/v1/result/{}/artifact'.format(job_id)) + with open(artifact_file+'.tar.gz', 'rb') as tarball: + file_upload = {'file': ('file', tarball, 'application/x-gzip')} + artifact_request = requests.post( + artifact_uri, files=file_upload) + if artifact_request.status_code != 200: + logging.error('Unable to post results to: %s (error: %s)' % + (result_uri, artifact_request.status_code)) + raise TFServerError(artifact_request.status_code) + else: + shutil.rmtree(artifacts_dir) shutil.rmtree(rundir) diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 4a37ab7e..4005fca0 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -131,11 +131,12 @@ def test_check_and_run_test(self, mock_requests_get, mock_requests_post, 'test.log')).read() self.assertEqual('test1', testlog.strip()) + @patch('testflinger_agent.client.os.unlink') @patch('shutil.rmtree') @patch('requests.post') @patch('requests.get') def test_phase_failed(self, mock_requests_get, mock_requests_post, - mock_rmtree): + mock_rmtree, mock_unlink): """Make sure we stop running after a failed phase""" testflinger_agent.config['provision_command'] = '/bin/false' testflinger_agent.config['test_command'] = 'echo test1' @@ -190,6 +191,33 @@ def test_retry_transmit(self, mock_requests_get, fake_job_data.get('job_id')) mock_transmit_job_outcome.assert_called_with(retry_dir) + @patch('testflinger_agent.client.logger.exception') + @patch('requests.post') + @patch('requests.get') + def test_post_artifact(self, mock_requests_get, + mock_requests_post, + mock_logger_exception): + """Test posting files from the artifact directory""" + # Create an artifact as part of the test process + testflinger_agent.config['test_command'] = ('mkdir artifacts && ' + 'echo test1 > artifacts/t') + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + # Send an extra terminator since we will be calling get 3 times + mock_requests_get.side_effect = [fake_response, terminator, terminator] + # Make sure we fail the first time when transmitting the results + mock_requests_post.side_effect = [MagicMock(status_code=200)] + testflinger_agent.client.process_jobs() + # Ok, I know this is weird. The second time post is called when we + # have an artifact, it will be sending the artifact and there + # should be a 'files' key in the call arguments. Replicating all + # the args is not feasible or useful + self.assertTrue('files' in str(mock_requests_post.mock_calls[1])) + @patch('shutil.rmtree') @patch('requests.post') @patch('requests.get') From bf32c626c118c0d0f92c9a6a4967e0c639b89e3e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 4 Nov 2016 12:11:39 -0500 Subject: [PATCH 078/569] Stream output of running commands to stdout so that we get progress from long running commands --- devices/dragonboard/__init__.py | 7 ++----- snappy_device_agents/__init__.py | 33 +++++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index b02c07c7..350b5036 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -23,7 +23,7 @@ import snappy_device_agents from devices.dragonboard.dragonboard import Dragonboard -from snappy_device_agents import logmsg +from snappy_device_agents import logmsg, runcmd from devices import (Catch, RecoveryError) device_name = "dragonboard" @@ -93,10 +93,7 @@ def invoked(self, ctx): logmsg(logging.ERROR, "Unable to format command: %s", cmd) logmsg(logging.INFO, "Running: %s", cmd) - proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - rc = proc.wait() - output, _ = proc.communicate() + rc, output = runcmd(cmd) if rc: exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3e5c7194..6039757d 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -22,6 +22,7 @@ import shutil import socket import subprocess +import sys import tempfile import time import urllib.request @@ -313,8 +314,30 @@ def logmsg(level, msg, *args, **kwargs): def runcmd(cmd, env=None): - proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, env=env) - rc = proc.wait() - output, _ = proc.communicate() - return rc, output + """ + Run a command and stream the output to stdout + + :param cmd: + Command to run + :param env: + Environment to pass to Popen + :return returncode: + Return value from running the command + :return output: + Output of stderr and stdout from running the command + """ + + output = "" + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=True, env=env) + while process.poll() is None: + line = process.stdout.readline() + if line: + sys.stdout.write(line.decode()) + output += line.decode() + line = process.stdout.read() + if line: + sys.stdout.write(line.decode()) + output += line.decode() + return process.returncode, output From 0a3b356b54c95b2d2ce78e4f3f4ef0147b7663df Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 16 Nov 2016 10:12:59 -0600 Subject: [PATCH 079/569] Make sure that we use the test_username if specified, and add a bit of debugging output related to the image write operation --- devices/netboot/netboot.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 8bb6a907..5b2e8c2b 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -91,7 +91,7 @@ def ensure_test_image(self, test_username, test_password): self.setboot('test') cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), + '{}@{}'.format(test_username, self.config['device_ip']), 'sudo /sbin/reboot'] try: subprocess.check_call(cmd) @@ -105,12 +105,12 @@ def ensure_test_image(self, test_username, test_password): while time.time() - started < 300: try: time.sleep(10) - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', '-f', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '{}@{}'.format(test_username, self.config['device_ip'])] subprocess.check_call(cmd) - test_image_booted = self.is_test_image_booted() + test_image_booted = self.is_test_image_booted(test_username) except: pass if test_image_booted: @@ -119,12 +119,14 @@ def ensure_test_image(self, test_username, test_password): if not test_image_booted: raise ProvisioningError("Failed to boot test image!") - def is_test_image_booted(self): + def is_test_image_booted(self, test_username): """ Check if the master image is booted. :returns: True if the test image is currently booted, False otherwise. + :param test_username: + Username of the default user in the test image :raises TimeoutError: If the command times out :raises CalledProcessError: @@ -132,7 +134,7 @@ def is_test_image_booted(self): """ cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), + '{}@{}'.format(test_username, self.config['device_ip']), 'snap -h'] subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) @@ -206,9 +208,12 @@ def flash_test_image(self, server_ip, server_port): logger.info("Triggering: %s", url) try: # XXX: I hope 30 min is enough? but maybe not! - urllib.request.urlopen(url, timeout=1800) + req = urllib.request.urlopen(url, timeout=1800) except: raise ProvisioningError("Error while flashing image!") + finally: + logger.info("Image write output:") + logger.info(str(req.read())) # Now reboot the target system url = 'http://{}:8989/reboot'.format(self.config['device_ip']) From 1b5468bf61ca3761648552239a817c69ce926398 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 21 Nov 2016 09:41:42 -0600 Subject: [PATCH 080/569] Support building dragonboard images with system-user assertions injected into the image --- devices/dragonboard/__init__.py | 22 +----- devices/dragonboard/dragonboard.py | 106 ++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 350b5036..23de4ee9 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -15,8 +15,6 @@ """Dragonboard support code.""" import logging -import multiprocessing -import subprocess import yaml import guacamole @@ -39,27 +37,11 @@ def invoked(self, ctx): with open(ctx.args.config) as configfile: config = yaml.load(configfile) snappy_device_agents.configure_logging(config) - device = Dragonboard(ctx.args.config) + device = Dragonboard(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") device.ensure_master_image() - image = snappy_device_agents.get_image(ctx.args.job_data) - server_ip = snappy_device_agents.get_local_ip_addr() - test_username = snappy_device_agents.get_test_username( - ctx.args.job_data) - test_password = snappy_device_agents.get_test_password( - ctx.args.job_data) - q = multiprocessing.Queue() - file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, args=(q, image,)) - file_server.start() - server_port = q.get() - logmsg(logging.INFO, "Flashing Test Image") - device.flash_test_image(server_ip, server_port) - file_server.terminate() - logmsg(logging.INFO, "Booting Test Image") - device.ensure_test_image(test_username, test_password) - logmsg(logging.INFO, "END provision") + device.provision() def register_arguments(self, parser): """Method called to customize the argument parser.""" diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index ec1412ff..621f7253 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -14,11 +14,14 @@ """Dragonboard support code.""" +import json import logging +import multiprocessing import subprocess import time import yaml +import snappy_device_agents from devices import (ProvisioningError, RecoveryError) @@ -29,9 +32,11 @@ class Dragonboard: """Snappy Device Agent for Dragonboard.""" - def __init__(self, config): + def __init__(self, config, job_data): with open(config) as configfile: self.config = yaml.load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) def setboot(self, mode): """ @@ -226,6 +231,15 @@ def flash_test_image(self, server_ip, server_port): :raises ProvisioningError: If the command times out or anything else fails. """ + # First unmount, just in case + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), + 'sudo umount /mnt'] + try: + subprocess.check_call(cmd, timeout=60) + except: + pass cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'linaro@{}'.format(self.config['device_ip']), @@ -241,8 +255,96 @@ def flash_test_image(self, server_ip, server_port): '-o', 'UserKnownHostsFile=/dev/null', 'linaro@{}'.format(self.config['device_ip']), 'sync'] try: - subprocess.check_call(cmd, timeout=1800) + subprocess.check_call(cmd, timeout=30) except: # Nothing should go wrong here, but let's sleep if it does logger.warn("Something went wrong with the sync, sleeping...") time.sleep(30) + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), + 'sudo hdparm -z {}'.format(self.config['test_device'])] + try: + subprocess.check_call(cmd, timeout=30) + except: + raise ProvisioningError("Unable to run hdparm to rescan " + "partitions") + + def write_system_user_file(self): + """Write the system-user assertion to the writable area""" + # Mount the writable partition + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), + 'sudo mount {} /mnt'.format( + self.config['snappy_writable_partition'])] + try: + subprocess.check_call(cmd, timeout=60) + except: + err = ("Error mounting writable partition on test image {}. " + "Check device configuration".format( + self.config['snappy_writable_partition'])) + raise ProvisioningError(err) + # Copy the system-user assertion to the device + cmd = ['scp', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + self.config['user_assertion'], + 'linaro@{}:/tmp/autoimport.assert'.format( + self.config['device_ip'])] + try: + subprocess.check_call(cmd, timeout=60) + except: + raise ProvisioningError("Error writing system-user assertion") + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), + 'sudo cp /tmp/autoimport.assert /mnt'] + try: + subprocess.check_call(cmd, timeout=60) + except: + raise ProvisioningError("Error copying system-user assertion") + + def provision(self): + """Provision the device""" + url = self.job_data['provision_data'].get('url') + if url: + snappy_device_agents.download(url, 'snappy.img') + else: + try: + model_assertion = self.config['model_assertion'] + channel = self.job_data['provision_data']['channel'] + extra_snaps = self.job_data.get( + 'provision_data').get('extra-snaps', []) + cmd = ['sudo', 'ubuntu-image', '-c', channel, + model_assertion, '-o', 'snappy.img'] + for snap in extra_snaps: + cmd.append('--extra-snaps') + cmd.append(snap) + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except Exception: + logger.exception("Bad data passed for provisioning") + raise ProvisioningError("Error copying system-user assertion") + image_file = snappy_device_agents.compress_file('snappy.img') + test_username = self.job_data.get( + 'test_data').get('test_username', 'ubuntu') + test_password = self.job_data.get( + 'test_data').get('test_password', 'ubuntu') + server_ip = snappy_device_agents.get_local_ip_addr() + serve_q = multiprocessing.Queue() + file_server = multiprocessing.Process( + target=snappy_device_agents.serve_file, + args=(serve_q, image_file,)) + file_server.start() + server_port = serve_q.get() + logger.info("Flashing Test Image") + self.flash_test_image(server_ip, server_port) + file_server.terminate() + if not url: + # If we didn't specify the url, we need to do this + # XXX: This is one of those cases where we hope the user did + # the right thing and included the assertion in the image! + logger.info("Creating Test User") + self.write_system_user_file() + logger.info("Booting Test Image") + self.ensure_test_image(test_username, test_password) + logger.info("END provision") From 51c591a3c3a68cf6fce0f6f7816bca9f67f153e5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 2 Dec 2016 13:54:42 -0600 Subject: [PATCH 081/569] use runcmd for select_* processing so that pipes and other things like that can be used --- devices/netboot/netboot.py | 9 +++++++-- snappy_device_agents/__init__.py | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 5b2e8c2b..a00ccb80 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -20,6 +20,8 @@ import time import yaml +from snappy_device_agents import (runcmd, + TimeoutError) from devices import (ProvisioningError, RecoveryError) @@ -54,9 +56,12 @@ def setboot(self, mode): for cmd in setboot_script: logger.info("Running %s", cmd) try: - subprocess.check_call(cmd.split(), timeout=60) - except: + rc, output = runcmd(cmd, timeout=60) + except TimeoutError: raise ProvisioningError("timeout reaching control host!") + if rc: + raise ProvisioningError( + "Error running {} (rc={})".format(cmd, rc)) def hardreset(self): """ diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 6039757d..4392cbdf 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -32,6 +32,10 @@ logger = logging.getLogger() +class TimeoutError(Exception): + pass + + def get_test_opportunity(job_data='testflinger.json'): """ Read the json test opportunity data from testflinger.json. @@ -313,7 +317,7 @@ def logmsg(level, msg, *args, **kwargs): logmsg(level, msg[4096:]) -def runcmd(cmd, env=None): +def runcmd(cmd, env=None, timeout=None): """ Run a command and stream the output to stdout @@ -321,6 +325,8 @@ def runcmd(cmd, env=None): Command to run :param env: Environment to pass to Popen + :param timeout: + Seconds after which we should timeout :return returncode: Return value from running the command :return output: @@ -328,10 +334,17 @@ def runcmd(cmd, env=None): """ output = "" + if timeout: + deadline = time.time() + timeout + else: + deadline = None process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, env=env) while process.poll() is None: + if deadline and time.time() > deadline: + process.terminate() + raise TimeoutError line = process.stdout.readline() if line: sys.stdout.write(line.decode()) From a17b82279cb830537812bdcae4d79af6f034bf97 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sun, 4 Dec 2016 23:17:07 -0600 Subject: [PATCH 082/569] Add job state update at each test phase and completion --- testflinger_agent/client.py | 50 ++++++++++++++++++-------- testflinger_agent/tests/test_client.py | 23 ++++++------ 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 2f7e8a62..8fb65f0b 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -40,10 +40,10 @@ def process_jobs(): job_data = check_jobs() while job_data: - logger.info("Starting job %s", job_data.get('job_id')) + job_id = job_data.get('job_id') + logger.info("Starting job %s", job_id) rundir = os.path.join( - testflinger_agent.config.get('execution_basedir'), - job_data.get('job_id')) + testflinger_agent.config.get('execution_basedir'), job_id) os.makedirs(rundir) # Dump the job data to testflinger.json in our execution directory with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: @@ -53,6 +53,11 @@ def process_jobs(): json.dump({}, f) for phase in TEST_PHASES: + # Try to update the job_state on the testflinger server + try: + post_result(job_id, {'job_state': phase}) + except TFServerError: + pass exitcode = run_test_phase(phase, rundir) # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline @@ -164,6 +169,26 @@ def repost_job(job_data): raise TFServerError(job_request.status_code) +def post_result(job_id, data): + """Post data to the testflinger server result for this job + + :param job_id: + id for the job on which we want to post results + :param data: + dict with data to be posted in json + """ + server = testflinger_agent.config.get('server_address') + if not server.lower().startswith('http'): + server = 'http://' + server + result_uri = urljoin(server, '/v1/result/') + result_uri = urljoin(result_uri, job_id) + job_request = requests.post(result_uri, json=data) + if job_request.status_code != 200: + logging.error('Unable to post results to: %s (error: %s)' % + (result_uri, job_request.status_code)) + raise TFServerError(job_request.status_code) + + def transmit_job_outcome(rundir): """Post job outcome json data to the testflinger server @@ -177,21 +202,16 @@ def transmit_job_outcome(rundir): with open(os.path.join(rundir, 'testflinger.json')) as f: job_data = json.load(f) job_id = job_data.get('job_id') - result_uri = urljoin(server, '/v1/result/') - result_uri = urljoin(result_uri, job_id) - logger.info('Submitting job outcome for job: %s' % job_id) # Do not retransmit outcome if it's already been done and removed outcome_file = os.path.join(rundir, 'testflinger-outcome.json') if os.path.exists(outcome_file): + logger.info('Submitting job outcome for job: %s' % job_id) with open(outcome_file) as f: - job_request = requests.post(result_uri, json=json.load(f)) - if job_request.status_code != 200: - logging.error('Unable to post results to: %s (error: %s)' % - (result_uri, job_request.status_code)) - raise TFServerError(job_request.status_code) - else: - # Remove the outcome file so we don't retransmit - os.unlink(outcome_file) + data = json.load(f) + data['job_state'] = 'complete' + post_result(job_id, data) + # Remove the outcome file so we don't retransmit + os.unlink(outcome_file) artifacts_dir = os.path.join(rundir, 'artifacts') # If we find an 'artifacts' dir under rundir, archive it, and transmit it # to the Testflinger server @@ -208,7 +228,7 @@ def transmit_job_outcome(rundir): artifact_uri, files=file_upload) if artifact_request.status_code != 200: logging.error('Unable to post results to: %s (error: %s)' % - (result_uri, artifact_request.status_code)) + (artifact_uri, artifact_request.status_code)) raise TFServerError(artifact_request.status_code) else: shutil.rmtree(artifacts_dir) diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 4005fca0..57691e08 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -78,7 +78,7 @@ def test_check_and_run_setup(self, mock_requests_get, mock_requests_post, # Make sure we return good status when posting the outcome # shutil.rmtree is mocked so that we avoid removing the files # before finishing the test - mock_requests_post.side_effect = [MagicMock(status_code=200)] + mock_requests_post.return_value = MagicMock(status_code=200) testflinger_agent.client.process_jobs() setuplog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), @@ -101,7 +101,7 @@ def test_check_and_run_provision(self, mock_requests_get, # Make sure we return good status when posting the outcome # shutil.rmtree is mocked so that we avoid removing the files # before finishing the test - mock_requests_post.side_effect = [MagicMock(status_code=200)] + mock_requests_post.return_value = MagicMock(status_code=200) testflinger_agent.client.process_jobs() provisionlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), @@ -124,7 +124,7 @@ def test_check_and_run_test(self, mock_requests_get, mock_requests_post, # Make sure we return good status when posting the outcome # shutil.rmtree is mocked so that we avoid removing the files # before finishing the test - mock_requests_post.side_effect = [MagicMock(status_code=200)] + mock_requests_post.return_value = MagicMock(status_code=200) testflinger_agent.client.process_jobs() testlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), @@ -150,7 +150,7 @@ def test_phase_failed(self, mock_requests_get, mock_requests_post, # Make sure we return good status when posting the outcome # shutil.rmtree is mocked so that we avoid removing the files # before finishing the test - mock_requests_post.side_effect = [MagicMock(status_code=200)] + mock_requests_post.return_value = MagicMock(status_code=200) testflinger_agent.client.process_jobs() outcome_file = os.path.join(os.path.join(self.tmpdir, fake_job_data.get('job_id'), @@ -163,7 +163,8 @@ def test_phase_failed(self, mock_requests_get, mock_requests_post, @patch('testflinger_agent.client.logger.exception') @patch('testflinger_agent.client.transmit_job_outcome') @patch('requests.get') - def test_retry_transmit(self, mock_requests_get, + @patch('requests.post') + def test_retry_transmit(self, mock_requests_post, mock_requests_get, mock_transmit_job_outcome, mock_logger_exception): """Make sure we retry sending test results""" @@ -178,7 +179,9 @@ def test_retry_transmit(self, mock_requests_get, # Send an extra terminator since we will be calling get 3 times mock_requests_get.side_effect = [fake_response, terminator, terminator] # Make sure we fail the first time when transmitting the results - mock_transmit_job_outcome.side_effect = [TFServerError(404), 200] + mock_transmit_job_outcome.side_effect = [TFServerError(404), + terminator, terminator] + mock_requests_post.return_value = MagicMock(status_code=200) testflinger_agent.client.process_jobs() first_dir = os.path.join( testflinger_agent.config.get('execution_basedir'), @@ -210,13 +213,13 @@ def test_post_artifact(self, mock_requests_get, # Send an extra terminator since we will be calling get 3 times mock_requests_get.side_effect = [fake_response, terminator, terminator] # Make sure we fail the first time when transmitting the results - mock_requests_post.side_effect = [MagicMock(status_code=200)] + mock_requests_post.return_value = MagicMock(status_code=200) testflinger_agent.client.process_jobs() - # Ok, I know this is weird. The second time post is called when we + # Ok, I know this is weird. The fifth time post is called when we # have an artifact, it will be sending the artifact and there # should be a 'files' key in the call arguments. Replicating all # the args is not feasible or useful - self.assertTrue('files' in str(mock_requests_post.mock_calls[1])) + self.assertTrue('files' in str(mock_requests_post.mock_calls[4])) @patch('shutil.rmtree') @patch('requests.post') @@ -239,7 +242,7 @@ def test_recovery_failed(self, mock_requests_get, mock_requests_post, mock_requests_get.side_effect = [fake_response, terminator] # In this case we are making sure that the repost job request # gets good status - mock_requests_post.side_effect = [MagicMock(status_code=200)] + mock_requests_post.return_value = MagicMock(status_code=200) testflinger_agent.client.process_jobs() self.assertEqual(True, testflinger_agent.check_offline()) # These are the args we would expect when it reposts the job From 86d31d3057ace44e068dd75ac20db1dc7ff02e59 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 4 Nov 2016 12:11:39 -0500 Subject: [PATCH 083/569] Stream output of running commands to stdout so that we get progress from long running commands --- devices/bbb/__init__.py | 9 ++------- devices/dragonboard/__init__.py | 3 +-- devices/inception/__init__.py | 9 ++------- devices/netboot/__init__.py | 10 +++------- devices/netboot/netboot.py | 2 +- devices/rpi2/__init__.py | 9 ++------- devices/touch/__init__.py | 3 +-- devices/touch/touch.py | 20 +++++++------------- snappy_device_agents/__init__.py | 7 +------ 9 files changed, 20 insertions(+), 52 deletions(-) diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py index 09a8298b..63033ccc 100644 --- a/devices/bbb/__init__.py +++ b/devices/bbb/__init__.py @@ -16,14 +16,13 @@ import logging import multiprocessing -import subprocess import yaml import guacamole import snappy_device_agents from devices.bbb.beagleboneblack import BeagleBoneBlack -from snappy_device_agents import logmsg +from snappy_device_agents import logmsg, runcmd device_name = "bbb" @@ -93,14 +92,10 @@ def invoked(self, ctx): logmsg(logging.ERROR, "Unable to format command: %s", cmd) logmsg(logging.INFO, "Running: %s", cmd) - proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - rc = proc.wait() - output, _ = proc.communicate() + rc = runcmd(cmd) if rc: exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "output:\n%s", output) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 23de4ee9..6d8c42fa 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -75,11 +75,10 @@ def invoked(self, ctx): logmsg(logging.ERROR, "Unable to format command: %s", cmd) logmsg(logging.INFO, "Running: %s", cmd) - rc, output = runcmd(cmd) + rc = runcmd(cmd) if rc: exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "output:\n%s", output) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py index 3a5e0e61..b00a6738 100644 --- a/devices/inception/__init__.py +++ b/devices/inception/__init__.py @@ -16,14 +16,13 @@ import logging import multiprocessing -import subprocess import yaml import guacamole import snappy_device_agents from devices.inception.inception import Inception -from snappy_device_agents import logmsg +from snappy_device_agents import logmsg, runcmd from devices import (Catch, RecoveryError) device_name = "inception" @@ -93,14 +92,10 @@ def invoked(self, ctx): logmsg(logging.ERROR, "Unable to format command: %s", cmd) logmsg(logging.INFO, "Running: %s", cmd) - proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - rc = proc.wait() - output, _ = proc.communicate() + rc = runcmd(cmd) if rc: exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "output:\n%s", output) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index b29fc572..0f3f4a45 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -16,14 +16,14 @@ import logging import multiprocessing -import subprocess import yaml import guacamole import snappy_device_agents from devices.netboot.netboot import Netboot -from snappy_device_agents import logmsg +from snappy_device_agents import logmsg, runcmd + from devices import (Catch, RecoveryError) device_name = "netboot" @@ -93,14 +93,10 @@ def invoked(self, ctx): logmsg(logging.ERROR, "Unable to format command: %s", cmd) logmsg(logging.INFO, "Running: %s", cmd) - proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - rc = proc.wait() - output, _ = proc.communicate() + rc = runcmd(cmd) if rc: exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "output:\n%s", output) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index a00ccb80..0420fd0d 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -56,7 +56,7 @@ def setboot(self, mode): for cmd in setboot_script: logger.info("Running %s", cmd) try: - rc, output = runcmd(cmd, timeout=60) + rc = runcmd(cmd, timeout=60) except TimeoutError: raise ProvisioningError("timeout reaching control host!") if rc: diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py index 1a5084b7..72a526a9 100644 --- a/devices/rpi2/__init__.py +++ b/devices/rpi2/__init__.py @@ -16,14 +16,13 @@ import logging import multiprocessing -import subprocess import yaml import guacamole import snappy_device_agents from devices.rpi2.rpi2 import RaspberryPi2 -from snappy_device_agents import logmsg +from snappy_device_agents import logmsg, runcmd from devices import (Catch, RecoveryError) @@ -90,14 +89,10 @@ def invoked(self, ctx): logmsg(logging.ERROR, "Unable to format command: %s", cmd) logmsg(logging.INFO, "Running: %s", cmd) - proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - rc = proc.wait() - output, _ = proc.communicate() + rc = runcmd(cmd) if rc: exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "output:\n%s", output) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/touch/__init__.py b/devices/touch/__init__.py index 440d811a..68a513d4 100644 --- a/devices/touch/__init__.py +++ b/devices/touch/__init__.py @@ -71,11 +71,10 @@ def invoked(self, ctx): env['ANDROID_SERIAL'] = config.get('serial') for cmd in test_cmds: logmsg(logging.INFO, "Running: %s", cmd) - rc, output = runcmd(cmd, env=env) + rc = runcmd(cmd, env=env) if rc: exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "output:\n%s", output) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/touch/touch.py b/devices/touch/touch.py index 41dc3a59..6d4c81e4 100644 --- a/devices/touch/touch.py +++ b/devices/touch/touch.py @@ -39,9 +39,8 @@ def recover(self): recovery_script = self.config.get('recovery_script') for cmd in recovery_script: logger.info("Running %s", cmd) - rc, output = runcmd(cmd) + rc = runcmd(cmd) if rc: - logger.error('output: {}'.format(output)) raise RecoveryError("Device recovery failed!") def provision(self): @@ -66,9 +65,8 @@ def provision(self): p.get('channel'), self.config.get('device_type'), password)) logger.info('Running ubuntu-device-flash') - rc, output = runcmd(cmd) + rc = runcmd(cmd) if rc: - logger.error('output: {}'.format(output)) raise ProvisioningError("Flashing new image failed!") self.adb_wait_for_device() self.handle_welcome_wizard() @@ -82,7 +80,7 @@ def configure_network(self): logger.warning('No network settings specified in the config') return logger.info('Configuring the network') - rc, out = runcmd('phablet-config -s {} network --write "{}"'.format( + rc = runcmd('phablet-config -s {} network --write "{}"'.format( serial, netspec)) if rc: logger.error('Error configuring network') @@ -98,9 +96,8 @@ def handle_welcome_wizard(self): serial = self.config.get('serial') cmd = ('phablet-config -s {} welcome-wizard ' '--disable'.format(serial)) - rc, output = runcmd(cmd) + rc = runcmd(cmd) if rc: - logger.error('output: {}'.format(output)) raise ProvisioningError("Disable welcome wizard failed!") self.adb_wait_for_device() @@ -115,27 +112,24 @@ def handle_edges_intro(self): serial = self.config.get('serial') cmd = ('phablet-config -s {} edges-intro ' '--disable'.format(serial)) - rc, output = runcmd(cmd) + rc = runcmd(cmd) if rc: - logger.error('output: {}'.format(output)) raise ProvisioningError("Disable edges intro failed!") self.adb_wait_for_device() def adb_reboot_bootloader(self): serial = self.config.get('serial') cmd = 'adb -s {} reboot-bootloader'.format(serial) - rc, output = runcmd(cmd) + rc = runcmd(cmd) if rc: - logger.error('output: {}'.format(output)) raise RecoveryError("Reboot to bootloader failed!") # FIXME: we should probably attempt hard-recovery here def adb_wait_for_device(self): serial = self.config.get('serial') cmd = 'adb -s {} wait-for-device'.format(serial) - rc, output = runcmd(cmd) + rc = runcmd(cmd) if rc: - logger.error('output: {}'.format(output)) raise ProvisioningError("Wait for device failed!") def get_recovery_image(self): diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 4392cbdf..155566ab 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -329,11 +329,8 @@ def runcmd(cmd, env=None, timeout=None): Seconds after which we should timeout :return returncode: Return value from running the command - :return output: - Output of stderr and stdout from running the command """ - output = "" if timeout: deadline = time.time() + timeout else: @@ -348,9 +345,7 @@ def runcmd(cmd, env=None, timeout=None): line = process.stdout.readline() if line: sys.stdout.write(line.decode()) - output += line.decode() line = process.stdout.read() if line: sys.stdout.write(line.decode()) - output += line.decode() - return process.returncode, output + return process.returncode From 240893d75cf940f750268bf928825d280f94b30b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 Jan 2017 13:50:34 -0600 Subject: [PATCH 084/569] Add support for provisioning maas nodes --- devices/maas/__init__.py | 102 ++++++++++++++++++++++++++++++++++++++ devices/maas/maas.py | 104 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 devices/maas/__init__.py create mode 100644 devices/maas/maas.py diff --git a/devices/maas/__init__.py b/devices/maas/__init__.py new file mode 100644 index 00000000..7256c53e --- /dev/null +++ b/devices/maas/__init__.py @@ -0,0 +1,102 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu Maas support code.""" + +import logging +import os +import yaml + +import guacamole + +import snappy_device_agents +from devices.maas.maas import Maas +from snappy_device_agents import logmsg, runcmd +from devices import (Catch, RecoveryError) + +device_name = "maas" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + @Catch(RecoveryError, 46) + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = Maas(ctx.args.config, ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Recovering device") + device.recover() + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = 0 + for cmd in test_cmds: + # Settings from the device yaml configfile like device_ip can be + # formatted in test commands like "foo {device_ip}" + try: + cmd = cmd.format(**config) + except: + exitcode = 20 + logmsg(logging.ERROR, "Unable to format command: %s", cmd) + + logmsg(logging.INFO, "Running: %s", cmd) + rc = runcmd(cmd) + if rc: + exitcode = 4 + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Ubuntu Maas.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/maas/maas.py b/devices/maas/maas.py new file mode 100644 index 00000000..b09908ce --- /dev/null +++ b/devices/maas/maas.py @@ -0,0 +1,104 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu Maas support code.""" + +import json +import logging +import subprocess +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) + +logger = logging.getLogger() + + +class Maas: + + """Device Agent for Maas.""" + + def __init__(self, config, job_data): + with open(config) as configfile: + self.config = yaml.load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) + + def recover(self): + agent_name = self.config.get('agent_name') + logger.info("Releasing node %s", agent_name) + self.node_release() + + def provision(self): + maas_user = self.config.get('maas_user') + node_id = self.config.get('node_id') + agent_name = self.config.get('agent_name') + provision_data = self.job_data.get('provision_data') + # Default to a safe LTS if no distro is specified + distro = provision_data.get('distro', 'xenial') + logger.info('Acquiring node') + cmd = ['maas', maas_user, 'nodes', 'acquire', + 'nodes={}'.format(node_id)] + # Do not use runcmd for this - we need the output, not the end user + subprocess.check_call(cmd) + logger.info( + 'Starting node %s with distro %s', agent_name, distro) + cmd = ['maas', maas_user, 'node', 'start', node_id, + 'distro_series={}'.format(distro)] + output = subprocess.check_output(cmd) + # Make sure the device is available before returning + for timeout in range(0, 10): + time.sleep(60) + status = self.node_status() + if status == 'Deployed': + return + logger.error('Device %s still in "%s" state, deployment failed!', + agent_name, status) + logger.error(output) + raise ProvisioningError("Provisioning failed!") + + def node_status(self): + """Return status of the node according to maas: + + Not in deployment: node is not deployed + Deploying: Deployment in progress + Deployed: Node is provisioned and ready for use + """ + maas_user = self.config.get('maas_user') + node_id = self.config.get('node_id') + cmd = ['maas', maas_user, 'nodes', 'deployment-status', + 'nodes={}'.format(node_id)] + # Do not use runcmd for this - we need the output, not the end user + output = subprocess.check_output(cmd) + data = json.loads(output.decode()) + return data.get(node_id) + + def node_release(self): + """Release the node to make it available again""" + maas_user = self.config.get('maas_user') + node_id = self.config.get('node_id') + cmd = ['maas', maas_user, 'nodes', 'release', + 'nodes={}'.format(node_id)] + subprocess.check_call(cmd) + # Make sure the device is available before returning + for timeout in range(0, 10): + time.sleep(5) + status = self.node_status() + if status == 'Not in deployment': + return + agent_name = self.config.get('agent_name') + logger.error('Device %s still in "%s" state, could not recover!', + agent_name, status) + raise RecoveryError("Device recovery failed!") From 892516b89c56ca46ba3b21a8309d884b7c220a36 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 10 Jan 2017 21:50:15 -0600 Subject: [PATCH 085/569] fix auto-import.assert name --- devices/dragonboard/dragonboard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index 621f7253..c92ca608 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -289,7 +289,7 @@ def write_system_user_file(self): cmd = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', self.config['user_assertion'], - 'linaro@{}:/tmp/autoimport.assert'.format( + 'linaro@{}:/tmp/auto-import.assert'.format( self.config['device_ip'])] try: subprocess.check_call(cmd, timeout=60) @@ -298,7 +298,7 @@ def write_system_user_file(self): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'linaro@{}'.format(self.config['device_ip']), - 'sudo cp /tmp/autoimport.assert /mnt'] + 'sudo cp /tmp/auto-import.assert /mnt'] try: subprocess.check_call(cmd, timeout=60) except: From d30fd8839174bd24080685bf64435bba7ed36d5d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 11 Jan 2017 09:00:29 -0600 Subject: [PATCH 086/569] [dragonboard] unmount the test device specifically --- devices/dragonboard/dragonboard.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index c92ca608..1487e09c 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -235,11 +235,11 @@ def flash_test_image(self, server_ip, server_port): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'linaro@{}'.format(self.config['device_ip']), - 'sudo umount /mnt'] + 'sudo umount {}*'.format(self.config['test_device'])] try: - subprocess.check_call(cmd, timeout=60) + subprocess.check_call(cmd, timeout=30) except: - pass + raise ProvisioningError("Error unmounting test device") cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'linaro@{}'.format(self.config['device_ip']), From 7152719b6eb6e0f9be478f721851d64afbe68493 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 11 Jan 2017 10:09:59 -0600 Subject: [PATCH 087/569] Check that the device is fully booted and available in maas before proceeding to the test phase --- devices/maas/maas.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/devices/maas/maas.py b/devices/maas/maas.py index b09908ce..effd03aa 100644 --- a/devices/maas/maas.py +++ b/devices/maas/maas.py @@ -63,12 +63,27 @@ def provision(self): time.sleep(60) status = self.node_status() if status == 'Deployed': - return + if self.check_test_image_booted(): + return logger.error('Device %s still in "%s" state, deployment failed!', agent_name, status) logger.error(output) raise ProvisioningError("Provisioning failed!") + def check_test_image_booted(self): + logger.info("Checking if test image booted.") + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'snap -h'] + try: + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + except: + return False + # If we get here, then the above command proved we are booted + return True + def node_status(self): """Return status of the node according to maas: From cccbc077890cce38a5bf32e7f3dfe8ae0f3c9ad4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Jan 2017 11:46:34 -0600 Subject: [PATCH 088/569] init repository --- README.rst | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..e69de29b From 14643390a28bb234110d5f7c8d1a2d3d09e55ae2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Jan 2017 11:49:43 -0600 Subject: [PATCH 089/569] Add content to the README --- README.rst | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.rst b/README.rst index e69de29b..b59ef3e5 100644 --- a/README.rst +++ b/README.rst @@ -0,0 +1,64 @@ +=============== +Testflinger CLI +=============== + +Overview +-------- + +The testflinger-cli tool is used for interacting with testflinger +server. It can be used for things like submitting jobs, checking +the status of them, and getting results. + +Installation +------------ + +You can either run testflinger-cli from a checkout of the code, or +install it like any other python project. + +To run it from a checkout, please make sure to first install python3-click +and python3-requests + +To install it in a virtual environment: + +.. code-block:: console + + $ virtualenv -p python3 env + $ . env/bin/activate + $ ./setup install + + +Usage +----- + +After installing testflinger-cli, you can get help by just running +'testflinger-cli' on its own, or by using the '--help' parameter. + +To specify a different server to use, you can use the '--server' +parameter, otherwise it will default to the one running on +http://testflinger.canonical.com + +To submit a new test job, first create a json file containing the job +definition. Then run: +.. code-block:: console + + $ testflinger-cli submit mytest.json + +If successful, this will return the job_id of the test job you submitted. +You can check on the status of that job by running: +.. code-block:: console + + $ testflinger-cli status + +To watch the output from the job as it runs, you can use the 'poll' +subcommand. This will display output in 10s chunks and exit when the +job is complete. +.. code-block:: console + + $ testflinger-cli poll + +Finally, to get the full json results from the job when it is done running, +you can use the 'results' subcommand: +.. code-block:: console + + $ testflinger-cli results + From 8be3a50a03f7c95d8c7ac9213ee3c9bfc0195339 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Jan 2017 11:52:21 -0600 Subject: [PATCH 090/569] Working prototype of the cli --- testflinger-cli | 132 ++++++++++++++++++++++++++++++++++++ testflinger_cli/__init__.py | 104 ++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100755 testflinger-cli create mode 100644 testflinger_cli/__init__.py diff --git a/testflinger-cli b/testflinger-cli new file mode 100755 index 00000000..52ca5351 --- /dev/null +++ b/testflinger-cli @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +import click +import json +import os +import sys +import time + +import testflinger_cli + + +# Make it easier to run from a checkout +basedir = os.path.abspath(os.path.join(__file__, '..')) +if os.path.exists(os.path.join(basedir, 'setup.py')): + sys.path.insert(0, basedir) + + +@click.group() +@click.option('--server', default='http://testflinger.canonical.com', + help='Testflinger server to use') +@click.pass_context +def cli(ctx, server): + ctx.obj['conn'] = testflinger_cli.Client(server) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def status(ctx, job_id): + conn = ctx.obj['conn'] + try: + job_state = conn.get_status(job_id) + except testflinger_cli.HTTPError as e: + if e.status == 204: + print('No data found for that job id. Check the job id to be sure ' + 'it is correct') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + sys.exit(1) + print(job_state) + + +@cli.command() +@click.argument('filename', nargs=1) +@click.pass_context +def submit(ctx, filename): + conn = ctx.obj['conn'] + with open(filename) as f: + data = f.read() + try: + job_id = conn.submit_job(data) + except testflinger_cli.HTTPError as e: + if e.status == 400: + print('The job you submitted contained bad data or bad ' + 'formatting, or did not specify a job_queue.') + else: + # This shouldn't happen, so let's get the full trace + print('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) + print('Job submitted successfully!') + print('job_id: {}'.format(job_id)) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def results(ctx, job_id): + conn = ctx.obj['conn'] + try: + results = conn.get_results(job_id) + except testflinger_cli.HTTPError as e: + if e.status == 204: + print('No results found for that job id.') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + sys.exit(1) + + print(json.dumps(results, sort_keys=True, indent=4)) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def poll(ctx, job_id): + conn = ctx.obj['conn'] + try: + job_state = conn.get_status(job_id) + except testflinger_cli.HTTPError as e: + if e.status == 204: + print('No data found for that job id. Check the job id to be sure ' + 'it is correct') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + sys.exit(1) + while True: + output = '' + try: + output = conn.get_output(job_id) + except testflinger_cli.HTTPError as e: + if e.status == 204: + # We are still waiting for the job to start + pass + if output: + print(output, end='') + job_state = conn.get_status(job_id) + if job_state == 'complete': + break + time.sleep(10) + print(job_state) + + +if __name__ == '__main__': + cli(obj={}) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py new file mode 100644 index 00000000..1d98424d --- /dev/null +++ b/testflinger_cli/__init__.py @@ -0,0 +1,104 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import json +import requests +import urllib.parse + + +class HTTPError(Exception): + def __init__(self, status): + self.status = status + + +class Client(): + """Testflinger connection client""" + def __init__(self, server): + self.server = server + + def get(self, uri_frag): + """Submit a GET request to the server + :param uri_frag: + endpoint for the GET request + :return: + String containing the response from the server + """ + uri = urllib.parse.urljoin(self.server, uri_frag) + req = requests.get(uri) + if req.status_code != 200: + raise HTTPError(req.status_code) + return req.text + + def put(self, uri_frag, data): + """Submit a POST request to the server + :param uri_frag: + endpoint for the POST request + :return: + String containing the response from the server + """ + uri = urllib.parse.urljoin(self.server, uri_frag) + req = requests.post(uri, json=data) + if req.status_code != 200: + raise HTTPError(req.status_code) + return req.text + + def get_status(self, job_id): + """Get the status of a test job + + :param job_id: + ID for the test job + :return: + String containing the job_state for the specified ID + (waiting, setup, provision, test, complete) + """ + endpoint = '/v1/result/{}'.format(job_id) + data = json.loads(self.get(endpoint)) + return data.get('job_state') + + def submit_job(self, json_data): + """Submit a test job to the testflinger server + + :param json_data: + String containing json data for the job to submit + :return: + ID for the test job + """ + endpoint = '/v1/job' + data = json.loads(json_data) + response = self.put(endpoint, data) + return json.loads(response).get('job_id') + + def get_results(self, job_id): + """Get results for a specified test job + + :param job_id: + ID for the test job + :return: + Dict containing the results returned from the server + """ + endpoint = '/v1/result/{}'.format(job_id) + return json.loads(self.get(endpoint)) + + def get_output(self, job_id): + """Get the latest output for a specified test job + + :param job_id: + ID for the test job + :return: + String containing the latest output from the job + """ + endpoint = '/v1/result/{}/output'.format(job_id) + return self.get(endpoint) From e10203a00dc6246d42ed5b7d1321a37caa3802b6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Jan 2017 11:55:42 -0600 Subject: [PATCH 091/569] Add a setup.py for the project --- setup.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100755 setup.py diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..9e459081 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from setuptools import setup + +INSTALL_REQUIRES = ['click', 'requests'] +TEST_REQUIRES = [] + +setup( + name='testflinger-cli', + version='0.1', + description='CLI tool for working with testflinger', + packages=['testflinger_cli'], + zip_safe=False, + install_requires=INSTALL_REQUIRES, + test_suite='testflinger_cli.tests', + tests_require=TEST_REQUIRES, + scripts=['testflinger-cli'], +) + From 1f46d97f4611d9b5e7bf4999703e39a0caa919f0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 18 Jan 2017 13:46:34 -0600 Subject: [PATCH 092/569] Send live output to the testflinger server There is a small amount of buffering (10s) to keep from pounding the server if the output is too frequent or large --- testflinger_agent/client.py | 74 ++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 8fb65f0b..c911b2ea 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -12,10 +12,12 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import fcntl import logging import json import os import requests +import select import shutil import subprocess import sys @@ -58,7 +60,7 @@ def process_jobs(): post_result(job_id, {'job_state': phase}) except TFServerError: pass - exitcode = run_test_phase(phase, rundir) + exitcode = run_test_phase(job_id, phase, rundir) # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline if exitcode == 46: @@ -123,9 +125,11 @@ def check_jobs(): time.sleep(60) -def run_test_phase(phase, rundir): +def run_test_phase(job_id, phase, rundir): """Run the specified test phase in rundir + :param job_id: + id for the test job :param phase: Name of the test phase (setup, provision, test, ...) :param rundir: @@ -142,7 +146,7 @@ def run_test_phase(phase, rundir): # Set the exitcode to some failed status in case we get interrupted exitcode = 99 try: - exitcode = run_with_log(cmd, phase_log, rundir) + exitcode = run_with_log(job_id, cmd, phase_log, rundir) finally: # Save the output log in the json file no matter what with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: @@ -235,9 +239,16 @@ def transmit_job_outcome(rundir): shutil.rmtree(rundir) -def run_with_log(cmd, logfile, cwd=None): +def set_nonblock(fd): + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + + +def run_with_log(job_id, cmd, logfile, cwd=None): """Execute command in a subprocess and log the output + :param job_id: + id for the job :param cmd: Command to run :param logfile: @@ -248,17 +259,54 @@ def run_with_log(cmd, logfile, cwd=None): returncode from the process """ with open(logfile, 'w') as f: + live_output_buffer = '' + readpoll = select.poll() + buffer_timeout = time.time() process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=cwd) + set_nonblock(process.stdout.fileno()) + readpoll.register(process.stdout, select.POLLIN) while process.poll() is None: - line = process.stdout.readline() - if line: - sys.stdout.write(line.decode()) - f.write(line.decode()) - f.flush() - line = process.stdout.read() - if line: - sys.stdout.write(line.decode()) - f.write(line.decode()) + # Check if there's any new data, timeout after 10s + data_ready = readpoll.poll(10000) + if data_ready: + buf = process.stdout.read().decode(sys.stdout.encoding) + if buf: + sys.stdout.write(buf) + live_output_buffer += buf + # Don't spam the server, only flush the buffer if there + # is output and it's been more than 10s + if time.time() - buffer_timeout > 10: + buffer_timeout = time.time() + # Try to stream output, if we can't connect, then + # keep buffer for the next pass through this + if post_live_output(job_id, live_output_buffer): + live_output_buffer = '' + f.write(buf) + f.flush() + buf = process.stdout.read().decode(sys.stdout.encoding) + if buf: + sys.stdout.write(buf) + live_output_buffer += buf + post_live_output(job_id, live_output_buffer) + f.write(buf) return process.returncode + + +def post_live_output(job_id, data): + """Post output data to the testflinger server for this job + + :param job_id: + id for the job on which we want to post results + :param data: + string with latest output data + """ + server = testflinger_agent.config.get('server_address') + if not server.lower().startswith('http'): + server = 'http://' + server + output_uri = urljoin(server, '/v1/result/{}/output'.format(job_id)) + job_request = requests.post(output_uri, data=data) + if job_request.status_code != 200: + return False + return True From 204ed155c762112a8339fce4a17c05a10f6aa4ca Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 23 Jan 2017 13:26:15 -0600 Subject: [PATCH 093/569] Support for downloading a tarball of the artifacts if they exist --- README.rst | 10 ++++++++-- testflinger-cli | 19 +++++++++++++++++++ testflinger_cli/__init__.py | 17 +++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index b59ef3e5..21b590b2 100644 --- a/README.rst +++ b/README.rst @@ -56,9 +56,15 @@ job is complete. $ testflinger-cli poll -Finally, to get the full json results from the job when it is done running, -you can use the 'results' subcommand: +To get the full json results from the job when it is done running, you can +use the 'results' subcommand: .. code-block:: console $ testflinger-cli results +Finally, to download the artifact tarball from the job, you can use the +'artifact' subcommand: +.. code-block:: console + + $ testflinger-cli artifact [--filename ] + diff --git a/testflinger-cli b/testflinger-cli index 52ca5351..cb0e4b85 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -96,6 +96,25 @@ def results(ctx, job_id): print(json.dumps(results, sort_keys=True, indent=4)) +@cli.command() +@click.argument('job_id', nargs=1) +@click.option('--filename', default='artifacts.tgz') +@click.pass_context +def artifacts(ctx, job_id, filename): + conn = ctx.obj['conn'] + print('Downloading artifacts tarball...') + try: + conn.get_artifact(job_id, filename) + except testflinger_cli.HTTPError as e: + if e.status == 204: + print('No artifacts tarball found for that job id.') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + sys.exit(1) + print('Artifacts downloaded to {}'.format(filename)) + + @cli.command() @click.argument('job_id', nargs=1) @click.pass_context diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 1d98424d..56a1f90b 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -92,6 +92,23 @@ def get_results(self, job_id): endpoint = '/v1/result/{}'.format(job_id) return json.loads(self.get(endpoint)) + def get_artifact(self, job_id, path): + """Get results for a specified test job + + :param job_id: + ID for the test job + :param path: + Path and filename for the artifact file + """ + endpoint = '/v1/result/{}/artifact'.format(job_id) + uri = urllib.parse.urljoin(self.server, endpoint) + req = requests.get(uri) + if req.status_code != 200: + raise HTTPError(req.status_code) + with open(path, 'wb') as artifact: + for chunk in req.iter_content(chunk_size=4096): + artifact.write(chunk) + def get_output(self, job_id): """Get the latest output for a specified test job From d36d09908315dd2bc43e1deca539850780e9ada3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 25 Jan 2017 10:44:16 -0600 Subject: [PATCH 094/569] Add a quiet flag for submit --- testflinger-cli | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/testflinger-cli b/testflinger-cli index cb0e4b85..b5e2b5e6 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -59,8 +59,9 @@ def status(ctx, job_id): @cli.command() @click.argument('filename', nargs=1) +@click.option('--quiet', '-q', is_flag=True) @click.pass_context -def submit(ctx, filename): +def submit(ctx, filename, quiet): conn = ctx.obj['conn'] with open(filename) as f: data = f.read() @@ -74,8 +75,11 @@ def submit(ctx, filename): # This shouldn't happen, so let's get the full trace print('Unexpected error status from testflinger ' 'server: {}'.format(e.status)) - print('Job submitted successfully!') - print('job_id: {}'.format(job_id)) + if quiet: + print(job_id) + else: + print('Job submitted successfully!') + print('job_id: {}'.format(job_id)) @cli.command() From 7be1fd32a3535d06c0b86b27b46ce5c9b6342f20 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 25 Jan 2017 11:18:43 -0600 Subject: [PATCH 095/569] don't buffer cli output --- testflinger-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger-cli b/testflinger-cli index b5e2b5e6..126332e7 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3 -u # Copyright (C) 2017 Canonical # # This program is free software: you can redistribute it and/or modify From e9dbf338a481db8665140b539648a5b33868d948 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 26 Jan 2017 13:04:11 -0600 Subject: [PATCH 096/569] encode output data before sending it to requests --- testflinger_agent/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index c911b2ea..f4ef8360 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -306,7 +306,7 @@ def post_live_output(job_id, data): if not server.lower().startswith('http'): server = 'http://' + server output_uri = urljoin(server, '/v1/result/{}/output'.format(job_id)) - job_request = requests.post(output_uri, data=data) + job_request = requests.post(output_uri, data=data.encode('utf-8')) if job_request.status_code != 200: return False return True From 47360d4e78aaa40b957c3b24a2585c19c5ad3136 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 26 Jan 2017 13:10:58 -0600 Subject: [PATCH 097/569] Log exceptions if we hit a problem in run_with_log --- testflinger_agent/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index f4ef8360..fb8ea3c0 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -147,6 +147,8 @@ def run_test_phase(job_id, phase, rundir): exitcode = 99 try: exitcode = run_with_log(job_id, cmd, phase_log, rundir) + except Exception as e: + logger.exception(e) finally: # Save the output log in the json file no matter what with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: From a6cf092a8d9729bf6a77cae66097d716e315e0e1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 30 Jan 2017 14:50:59 -0600 Subject: [PATCH 098/569] Don't get a new job if we are now marked offline --- testflinger_agent/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index fb8ea3c0..71d3d5f9 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -81,6 +81,9 @@ def process_jobs(): results_basedir = testflinger_agent.config.get('results_basedir') shutil.move(rundir, results_basedir) + if testflinger_agent.check_offline(): + # Don't get a new job if we are now marked offline + break job_data = check_jobs() From 72f8513c923561fcb304adc98155e4443a861843 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 1 Feb 2017 13:41:32 -0600 Subject: [PATCH 099/569] support for submitting yaml in addition to json --- README.rst | 4 ++-- setup.py | 2 +- testflinger_cli/__init__.py | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 21b590b2..4ab1f4bc 100644 --- a/README.rst +++ b/README.rst @@ -37,8 +37,8 @@ To specify a different server to use, you can use the '--server' parameter, otherwise it will default to the one running on http://testflinger.canonical.com -To submit a new test job, first create a json file containing the job -definition. Then run: +To submit a new test job, first create a yaml or json file containing +the job definition. Then run: .. code-block:: console $ testflinger-cli submit mytest.json diff --git a/setup.py b/setup.py index 9e459081..c188f994 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ # from setuptools import setup -INSTALL_REQUIRES = ['click', 'requests'] +INSTALL_REQUIRES = ['click', 'pyyaml', 'requests'] TEST_REQUIRES = [] setup( diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 56a1f90b..842a62d4 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -17,6 +17,7 @@ import json import requests import urllib.parse +import yaml class HTTPError(Exception): @@ -68,16 +69,16 @@ def get_status(self, job_id): data = json.loads(self.get(endpoint)) return data.get('job_state') - def submit_job(self, json_data): + def submit_job(self, job_data): """Submit a test job to the testflinger server - :param json_data: - String containing json data for the job to submit + :param job_data: + String containing json or yaml data for the job to submit :return: ID for the test job """ endpoint = '/v1/job' - data = json.loads(json_data) + data = yaml.load(job_data) response = self.put(endpoint, data) return json.loads(response).get('job_id') From dffb7f7eec4decb0a76071bf1001663e98f7bb83 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Feb 2017 10:10:07 -0600 Subject: [PATCH 100/569] We are only really concerned about unbuffered output on poll --- testflinger-cli | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testflinger-cli b/testflinger-cli index 126332e7..b0e8f0ee 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 -u +#!/usr/bin/env python3 # Copyright (C) 2017 Canonical # # This program is free software: you can redistribute it and/or modify @@ -143,7 +143,7 @@ def poll(ctx, job_id): # We are still waiting for the job to start pass if output: - print(output, end='') + print(output, end='', flush=True) job_state = conn.get_status(job_id) if job_state == 'complete': break From c5f690f982b7e3f5335142c62c5b1728792edd72 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Feb 2017 10:34:32 -0600 Subject: [PATCH 101/569] Handle more types of errors when communicating with the server --- testflinger-cli | 16 ++++++++++++++++ testflinger_cli/__init__.py | 13 +++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/testflinger-cli b/testflinger-cli index b0e8f0ee..a8451297 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -53,6 +53,9 @@ def status(ctx, job_id): elif e.status == 400: print('Invalid job id specified. Check the job id to be sure it ' 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') sys.exit(1) print(job_state) @@ -71,10 +74,14 @@ def submit(ctx, filename, quiet): if e.status == 400: print('The job you submitted contained bad data or bad ' 'formatting, or did not specify a job_queue.') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') else: # This shouldn't happen, so let's get the full trace print('Unexpected error status from testflinger ' 'server: {}'.format(e.status)) + sys.exit(1) if quiet: print(job_id) else: @@ -95,6 +102,9 @@ def results(ctx, job_id): elif e.status == 400: print('Invalid job id specified. Check the job id to be sure it ' 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') sys.exit(1) print(json.dumps(results, sort_keys=True, indent=4)) @@ -115,6 +125,9 @@ def artifacts(ctx, job_id, filename): elif e.status == 400: print('Invalid job id specified. Check the job id to be sure it ' 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') sys.exit(1) print('Artifacts downloaded to {}'.format(filename)) @@ -133,6 +146,9 @@ def poll(ctx, job_id): elif e.status == 400: print('Invalid job id specified. Check the job id to be sure it ' 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') sys.exit(1) while True: output = '' diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 842a62d4..ae4c51a1 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -16,6 +16,7 @@ import json import requests +import sys import urllib.parse import yaml @@ -38,7 +39,11 @@ def get(self, uri_frag): String containing the response from the server """ uri = urllib.parse.urljoin(self.server, uri_frag) - req = requests.get(uri) + try: + req = requests.get(uri) + except requests.exceptions.ConnectionError as e: + print('Unable to communicate with specified server.') + sys.exit(1) if req.status_code != 200: raise HTTPError(req.status_code) return req.text @@ -51,7 +56,11 @@ def put(self, uri_frag, data): String containing the response from the server """ uri = urllib.parse.urljoin(self.server, uri_frag) - req = requests.post(uri, json=data) + try: + req = requests.post(uri, json=data) + except requests.exceptions.ConnectionError as e: + print('Unable to communicate with specified server.') + sys.exit(1) if req.status_code != 200: raise HTTPError(req.status_code) return req.text From 58759ab1940eb1d288e70bcb2f3fb7a10987893b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Feb 2017 10:45:27 -0600 Subject: [PATCH 102/569] Set reasonable timeouts when trying to communicate with the testflinger server --- testflinger_cli/__init__.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index ae4c51a1..a5ba9d7b 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -31,7 +31,7 @@ class Client(): def __init__(self, server): self.server = server - def get(self, uri_frag): + def get(self, uri_frag, timeout=5): """Submit a GET request to the server :param uri_frag: endpoint for the GET request @@ -40,7 +40,10 @@ def get(self, uri_frag): """ uri = urllib.parse.urljoin(self.server, uri_frag) try: - req = requests.get(uri) + req = requests.get(uri, timeout=timeout) + except requests.exceptions.ConnectTimeout as e: + print('Timout while trying to communicate with the server.') + sys.exit(1) except requests.exceptions.ConnectionError as e: print('Unable to communicate with specified server.') sys.exit(1) @@ -48,7 +51,7 @@ def get(self, uri_frag): raise HTTPError(req.status_code) return req.text - def put(self, uri_frag, data): + def put(self, uri_frag, data, timeout=5): """Submit a POST request to the server :param uri_frag: endpoint for the POST request @@ -57,7 +60,10 @@ def put(self, uri_frag, data): """ uri = urllib.parse.urljoin(self.server, uri_frag) try: - req = requests.post(uri, json=data) + req = requests.post(uri, json=data, timeout=timeout) + except requests.exceptions.ConnectTimeout as e: + print('Timout while trying to communicate with the server.') + sys.exit(1) except requests.exceptions.ConnectionError as e: print('Unable to communicate with specified server.') sys.exit(1) @@ -112,7 +118,7 @@ def get_artifact(self, job_id, path): """ endpoint = '/v1/result/{}/artifact'.format(job_id) uri = urllib.parse.urljoin(self.server, endpoint) - req = requests.get(uri) + req = requests.get(uri, timeout=5) if req.status_code != 200: raise HTTPError(req.status_code) with open(path, 'wb') as artifact: From 806f4d3769d575d3d9a004eba3061f543bd72863 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Feb 2017 22:22:49 -0600 Subject: [PATCH 103/569] Don't fail if we can't unmount the test device. This is usually expected --- devices/dragonboard/dragonboard.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index 1487e09c..f78ee03a 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -238,8 +238,9 @@ def flash_test_image(self, server_ip, server_port): 'sudo umount {}*'.format(self.config['test_device'])] try: subprocess.check_call(cmd, timeout=30) - except: - raise ProvisioningError("Error unmounting test device") + except subprocess.CalledProcessError: + # We might not be mounted, so expect this to fail sometimes + pass cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'linaro@{}'.format(self.config['device_ip']), From 38f15326e80fcf745a8c4b4dd18f5116baed3209 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 7 Feb 2017 09:31:24 -0600 Subject: [PATCH 104/569] Add snapcraft.yaml --- snapcraft.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 snapcraft.yaml diff --git a/snapcraft.yaml b/snapcraft.yaml new file mode 100644 index 00000000..9d0db772 --- /dev/null +++ b/snapcraft.yaml @@ -0,0 +1,25 @@ +name: testflinger-cli +version: 0.1 +summary: testflinger-cli +description: | + The testflinger-cli tool is used for interacting with the testflinger + server for submitting test jobs, checking status, getting results, and + streaming output. +confinement: strict + +apps: + testflinger-cli: + command: bin/testflinger-cli.wrapper + plugs: + - home + - network + +parts: + launcher: + plugin: dump + source: . + organize: + testflinger-cli.wrapper: bin/testflinger-cli.wrapper + testflinger-cli: + plugin: python + source: . From 0dad9d9c9d00ff16c2686e11c89c2ee220e53fe2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 7 Feb 2017 13:57:07 -0600 Subject: [PATCH 105/569] Add missing wrapper --- testflinger-cli.wrapper | 4 ++++ 1 file changed, 4 insertions(+) create mode 100755 testflinger-cli.wrapper diff --git a/testflinger-cli.wrapper b/testflinger-cli.wrapper new file mode 100755 index 00000000..92788620 --- /dev/null +++ b/testflinger-cli.wrapper @@ -0,0 +1,4 @@ +#!/bin/sh +export LC_ALL=C.UTF-8 +export LANG=C.UTF-8 +exec testflinger-cli $@ From e63bea117e7be7feeca25f2498879a4387fee7e4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Mar 2017 15:59:42 -0600 Subject: [PATCH 106/569] Refactor testflinger_agent --- testflinger_agent/__init__.py | 42 +-- testflinger_agent/agent.py | 113 +++++++ testflinger_agent/client.py | 391 +++++++------------------ testflinger_agent/job.py | 133 +++++++++ testflinger_agent/tests/test_agent.py | 233 +++++++++++++++ testflinger_agent/tests/test_client.py | 226 +------------- testflinger_agent/tests/test_config.py | 4 +- 7 files changed, 613 insertions(+), 529 deletions(-) create mode 100644 testflinger_agent/agent.py create mode 100644 testflinger_agent/job.py create mode 100644 testflinger_agent/tests/test_agent.py diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 8aeaa962..a76f8dab 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2017 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -19,28 +19,31 @@ import time import yaml -from testflinger_agent import (client, schema) +from testflinger_agent import schema +from testflinger_agent.agent import TestflingerAgent +from testflinger_agent.client import TestflingerClient logger = logging.getLogger() -config = dict() - def main(): args = parse_args() - load_config(args.config) - configure_logging() + config = load_config(args.config) + configure_logging(config) check_interval = config.get('polling_interval') + client = TestflingerClient(config) + agent = TestflingerAgent(client) while True: try: - if check_offline(): + if agent.check_offline(): logger.error("Agent %s is offline, not processing jobs! " "Remove %s to resume processing" % - (config.get('agent_id'), get_offline_file())) - while check_offline(): + (config.get('agent_id'), + agent.get_offline_file())) + while agent.check_offline(): time.sleep(check_interval) logger.info("Checking jobs") - client.process_jobs() + agent.process_jobs() logger.info("Sleeping for {}".format(check_interval)) time.sleep(check_interval) except KeyboardInterrupt: @@ -48,29 +51,14 @@ def main(): sys.exit(0) -def get_offline_file(): - return os.path.join( - '/tmp', 'TESTFLINGER-DEVICE-OFFLINE-{}'.format(config.get('agent_id'))) - - -def check_offline(): - return os.path.exists(get_offline_file()) - - -def mark_device_offline(): - # Create the offline file, this should work even if it exists - open(get_offline_file(), 'w').close() - - def load_config(configfile): - global config with open(configfile) as f: config = yaml.safe_load(f) config = schema.validate(config) + return config -def configure_logging(): - global config +def configure_logging(config): # Create these at the beginning so we fail early if there are # permission problems os.makedirs(config.get('logging_basedir'), exist_ok=True) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py new file mode 100644 index 00000000..89a16026 --- /dev/null +++ b/testflinger_agent/agent.py @@ -0,0 +1,113 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see + +import json +import logging +import os +import shutil + +from testflinger_agent.job import TestflingerJob +from testflinger_agent.errors import TFServerError + +logger = logging.getLogger() + + +class TestflingerAgent: + def __init__(self, client): + self.client = client + + def get_offline_file(self): + return os.path.join( + '/tmp', + 'TESTFLINGER-DEVICE-OFFLINE-{}'.format( + self.client.config.get('agent_id'))) + + def check_offline(self): + return os.path.exists(self.get_offline_file()) + + def mark_device_offline(self): + # Create the offline file, this should work even if it exists + open(self.get_offline_file(), 'w').close() + + def process_jobs(self): + """Coordinate checking for new jobs and handling them if they exists""" + TEST_PHASES = ['setup', 'provision', 'test'] + + # First, see if we have any old results that we couldn't send last time + self.retry_old_results() + + job_data = self.client.check_jobs() + while job_data: + job = TestflingerJob(job_data, self.client) + logger.info("Starting job %s", job.job_id) + rundir = os.path.join( + self.client.config.get('execution_basedir'), job.job_id) + os.makedirs(rundir) + # Dump the job data to testflinger.json in our execution directory + with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: + json.dump(job_data, f) + # Create json outcome file where phases will store their output + with open(os.path.join(rundir, 'testflinger-outcome.json'), + 'w') as f: + json.dump({}, f) + + for phase in TEST_PHASES: + # Try to update the job_state on the testflinger server + try: + self.client.post_result(job.job_id, {'job_state': phase}) + except TFServerError: + pass + exitcode = job.run_test_phase(phase, rundir) + # exit code 46 is our indication that recovery failed! + # In this case, we need to mark the device offline + if exitcode == 46: + self.mark_device_offline() + self.client.repost_job(job_data) + shutil.rmtree(rundir) + # Return NOW so we don't keep trying to process jobs + return + if exitcode: + logger.debug('Phase %s failed, aborting job' % phase) + break + try: + self.client.transmit_job_outcome(rundir) + except Exception as e: + # TFServerError will happen if we get other-than-good status + # Other errors can happen too for things like connection + # problems + logger.exception(e) + results_basedir = self.client.config.get('results_basedir') + shutil.move(rundir, results_basedir) + + if self.check_offline(): + # Don't get a new job if we are now marked offline + break + job_data = self.client.check_jobs() + + def retry_old_results(self): + """Retry sending results that we previously failed to send""" + + results_dir = self.client.config.get('results_basedir') + # List all the directories in 'results_basedir', where we store the + # results that we couldn't transmit before + old_results = [os.path.join(results_dir, d) + for d in os.listdir(results_dir) + if os.path.isdir(os.path.join(results_dir, d))] + for result in old_results: + try: + logger.info('Attempting to send result: %s' % result) + self.client.transmit_job_outcome(result) + except TFServerError: + # Problems still, better luck next time? + pass diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 71d3d5f9..eee624d0 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -12,306 +12,133 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import fcntl import logging import json import os import requests -import select import shutil -import subprocess -import sys import tempfile import time from urllib.parse import urljoin -import testflinger_agent from testflinger_agent.errors import TFServerError logger = logging.getLogger() -def process_jobs(): - """Coordinate checking for new jobs and handling them if they exists""" - TEST_PHASES = ['setup', 'provision', 'test'] - - # First, see if we have any old results that we couldn't send last time - retry_old_results() +class TestflingerClient: + def __init__(self, config): + self.config = config + server = self.config.get('server_address') + if not server.lower().startswith('http'): + self.config['server'] = 'http://' + server - job_data = check_jobs() - while job_data: - job_id = job_data.get('job_id') - logger.info("Starting job %s", job_id) - rundir = os.path.join( - testflinger_agent.config.get('execution_basedir'), job_id) - os.makedirs(rundir) - # Dump the job data to testflinger.json in our execution directory - with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: - json.dump(job_data, f) - # Create json outcome file where phases will store their output - with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: - json.dump({}, f) + def check_jobs(self): + """Check for new jobs for on the Testflinger server - for phase in TEST_PHASES: - # Try to update the job_state on the testflinger server - try: - post_result(job_id, {'job_state': phase}) - except TFServerError: - pass - exitcode = run_test_phase(job_id, phase, rundir) - # exit code 46 is our indication that recovery failed! - # In this case, we need to mark the device offline - if exitcode == 46: - testflinger_agent.mark_device_offline() - repost_job(job_data) - shutil.rmtree(rundir) - # Return NOW so we don't keep trying to process jobs - return - if exitcode: - logger.debug('Phase %s failed, aborting job' % phase) - break + :return: Dict with job data, or None if no job found + """ try: - transmit_job_outcome(rundir) + job_uri = urljoin(self.config.get('server'), '/v1/job') + queue_list = self.config.get('job_queues') + logger.debug("Requesting a job") + job_request = requests.get(job_uri, params={'queue': queue_list}) + if job_request.content: + return job_request.json() + else: + return None except Exception as e: - # TFServerError will happen if we get other-than-good status - # Other errors can happen too for things like connection problems logger.exception(e) - results_basedir = testflinger_agent.config.get('results_basedir') - shutil.move(rundir, results_basedir) - - if testflinger_agent.check_offline(): - # Don't get a new job if we are now marked offline - break - job_data = check_jobs() - - -def retry_old_results(): - """Retry sending results that we previously failed to send""" - - results_dir = testflinger_agent.config.get('results_basedir') - # List all the directories in 'results_basedir', where we store the - # results that we couldn't transmit before - old_results = [os.path.join(results_dir, d) - for d in os.listdir(results_dir) - if os.path.isdir(os.path.join(results_dir, d))] - for result in old_results: - try: - logger.info('Attempting to send result: %s' % result) - transmit_job_outcome(result) - except TFServerError: - # Problems still, better luck next time? - pass - - -def check_jobs(): - """Check for new jobs for on the Testflinger server - - :return: Dict with job data, or None if no job found - """ - try: - server = testflinger_agent.config.get('server_address') - if not server.lower().startswith('http'): - server = 'http://' + server - job_uri = urljoin(server, '/v1/job') - queue_list = testflinger_agent.config.get('job_queues') - logger.debug("Requesting a job") - job_request = requests.get(job_uri, params={'queue': queue_list}) - if job_request.content: - return job_request.json() - else: - return None - except Exception as e: - logger.exception(e) - # Wait a little extra before trying again - time.sleep(60) - - -def run_test_phase(job_id, phase, rundir): - """Run the specified test phase in rundir - - :param job_id: - id for the test job - :param phase: - Name of the test phase (setup, provision, test, ...) - :param rundir: - Directory in which to run the command defined for the phase - :return: - Returncode from the command that was executed, 0 will be returned - if there was no command to run - """ - cmd = testflinger_agent.config.get(phase+'_command') - if not cmd: - return 0 - phase_log = os.path.join(rundir, phase+'.log') - logger.info('Running %s_command: %s' % (phase, cmd)) - # Set the exitcode to some failed status in case we get interrupted - exitcode = 99 - try: - exitcode = run_with_log(job_id, cmd, phase_log, rundir) - except Exception as e: - logger.exception(e) - finally: - # Save the output log in the json file no matter what - with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: - outcome_data = json.load(f) - if os.path.exists(phase_log): - with open(phase_log) as f: - outcome_data[phase+'_output'] = f.read() - outcome_data[phase+'_status'] = exitcode - with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w') as f: - json.dump(outcome_data, f) - return exitcode - - -def repost_job(job_data): - server = testflinger_agent.config.get('server_address') - if not server.lower().startswith('http'): - server = 'http://' + server - job_uri = urljoin(server, '/v1/job') - logger.info('Resubmitting job for job: %s' % job_data.get('job_id')) - job_request = requests.post(job_uri, json=job_data) - if job_request.status_code != 200: - logging.error('Unable to re-post job to: %s (error: %s)' % - (job_uri, job_request.status_code)) - raise TFServerError(job_request.status_code) - - -def post_result(job_id, data): - """Post data to the testflinger server result for this job - - :param job_id: - id for the job on which we want to post results - :param data: - dict with data to be posted in json - """ - server = testflinger_agent.config.get('server_address') - if not server.lower().startswith('http'): - server = 'http://' + server - result_uri = urljoin(server, '/v1/result/') - result_uri = urljoin(result_uri, job_id) - job_request = requests.post(result_uri, json=data) - if job_request.status_code != 200: - logging.error('Unable to post results to: %s (error: %s)' % - (result_uri, job_request.status_code)) - raise TFServerError(job_request.status_code) - - -def transmit_job_outcome(rundir): - """Post job outcome json data to the testflinger server - - :param rundir: - Execution dir where the results can be found - """ - server = testflinger_agent.config.get('server_address') - if not server.lower().startswith('http'): - server = 'http://' + server - # Create uri for API: /v1/result/ - with open(os.path.join(rundir, 'testflinger.json')) as f: - job_data = json.load(f) - job_id = job_data.get('job_id') - # Do not retransmit outcome if it's already been done and removed - outcome_file = os.path.join(rundir, 'testflinger-outcome.json') - if os.path.exists(outcome_file): - logger.info('Submitting job outcome for job: %s' % job_id) - with open(outcome_file) as f: - data = json.load(f) - data['job_state'] = 'complete' - post_result(job_id, data) - # Remove the outcome file so we don't retransmit - os.unlink(outcome_file) - artifacts_dir = os.path.join(rundir, 'artifacts') - # If we find an 'artifacts' dir under rundir, archive it, and transmit it - # to the Testflinger server - if os.path.exists(artifacts_dir): - with tempfile.TemporaryDirectory() as tmpdir: - artifact_file = os.path.join(tmpdir, 'artifacts') - shutil.make_archive(artifact_file, format='gztar', - root_dir=rundir, base_dir='artifacts') - artifact_uri = urljoin( - server, '/v1/result/{}/artifact'.format(job_id)) - with open(artifact_file+'.tar.gz', 'rb') as tarball: - file_upload = {'file': ('file', tarball, 'application/x-gzip')} - artifact_request = requests.post( - artifact_uri, files=file_upload) - if artifact_request.status_code != 200: - logging.error('Unable to post results to: %s (error: %s)' % - (artifact_uri, artifact_request.status_code)) - raise TFServerError(artifact_request.status_code) - else: - shutil.rmtree(artifacts_dir) - shutil.rmtree(rundir) - - -def set_nonblock(fd): - fl = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - - -def run_with_log(job_id, cmd, logfile, cwd=None): - """Execute command in a subprocess and log the output - - :param job_id: - id for the job - :param cmd: - Command to run - :param logfile: - Filename to save the output in - :param cwd: - Path to run the command from - :return: - returncode from the process - """ - with open(logfile, 'w') as f: - live_output_buffer = '' - readpoll = select.poll() - buffer_timeout = time.time() - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - shell=True, cwd=cwd) - set_nonblock(process.stdout.fileno()) - readpoll.register(process.stdout, select.POLLIN) - while process.poll() is None: - # Check if there's any new data, timeout after 10s - data_ready = readpoll.poll(10000) - if data_ready: - buf = process.stdout.read().decode(sys.stdout.encoding) - if buf: - sys.stdout.write(buf) - live_output_buffer += buf - # Don't spam the server, only flush the buffer if there - # is output and it's been more than 10s - if time.time() - buffer_timeout > 10: - buffer_timeout = time.time() - # Try to stream output, if we can't connect, then - # keep buffer for the next pass through this - if post_live_output(job_id, live_output_buffer): - live_output_buffer = '' - f.write(buf) - f.flush() - buf = process.stdout.read().decode(sys.stdout.encoding) - if buf: - sys.stdout.write(buf) - live_output_buffer += buf - post_live_output(job_id, live_output_buffer) - f.write(buf) - return process.returncode - - -def post_live_output(job_id, data): - """Post output data to the testflinger server for this job - - :param job_id: - id for the job on which we want to post results - :param data: - string with latest output data - """ - server = testflinger_agent.config.get('server_address') - if not server.lower().startswith('http'): - server = 'http://' + server - output_uri = urljoin(server, '/v1/result/{}/output'.format(job_id)) - job_request = requests.post(output_uri, data=data.encode('utf-8')) - if job_request.status_code != 200: - return False - return True + # Wait a little extra before trying again + time.sleep(60) + + def repost_job(self, job_data): + """"Resubmit the job to the testflinger server with the same id + + :param job_id: + id for the job on which we want to post results + """ + job_uri = urljoin(self.config.get('server'), '/v1/job') + logger.info('Resubmitting job for job: %s' % job_data.get('job_id')) + job_request = requests.post(job_uri, json=job_data) + if job_request.status_code != 200: + logging.error('Unable to re-post job to: %s (error: %s)' % + (job_uri, job_request.status_code)) + raise TFServerError(job_request.status_code) + + def post_result(self, job_id, data): + """Post data to the testflinger server result for this job + + :param job_id: + id for the job on which we want to post results + :param data: + dict with data to be posted in json + """ + result_uri = urljoin(self.config.get('server'), '/v1/result/') + result_uri = urljoin(result_uri, job_id) + job_request = requests.post(result_uri, json=data) + if job_request.status_code != 200: + logging.error('Unable to post results to: %s (error: %s)' % + (result_uri, job_request.status_code)) + raise TFServerError(job_request.status_code) + + def transmit_job_outcome(self, rundir): + """Post job outcome json data to the testflinger server + + :param rundir: + Execution dir where the results can be found + """ + with open(os.path.join(rundir, 'testflinger.json')) as f: + job_data = json.load(f) + job_id = job_data.get('job_id') + # Do not retransmit outcome if it's already been done and removed + outcome_file = os.path.join(rundir, 'testflinger-outcome.json') + if os.path.exists(outcome_file): + logger.info('Submitting job outcome for job: %s' % job_id) + with open(outcome_file) as f: + data = json.load(f) + data['job_state'] = 'complete' + self.post_result(job_id, data) + # Remove the outcome file so we don't retransmit + os.unlink(outcome_file) + artifacts_dir = os.path.join(rundir, 'artifacts') + # If we find an 'artifacts' dir under rundir, archive it, and transmit + # it to the Testflinger server + if os.path.exists(artifacts_dir): + with tempfile.TemporaryDirectory() as tmpdir: + artifact_file = os.path.join(tmpdir, 'artifacts') + shutil.make_archive(artifact_file, format='gztar', + root_dir=rundir, base_dir='artifacts') + # Create uri for API: /v1/result/ + artifact_uri = urljoin( + self.config.get('server'), + '/v1/result/{}/artifact'.format(job_id)) + with open(artifact_file+'.tar.gz', 'rb') as tarball: + file_upload = { + 'file': ('file', tarball, 'application/x-gzip')} + artifact_request = requests.post( + artifact_uri, files=file_upload) + if artifact_request.status_code != 200: + logging.error('Unable to post results to: %s (error: %s)' % + (artifact_uri, artifact_request.status_code)) + raise TFServerError(artifact_request.status_code) + else: + shutil.rmtree(artifacts_dir) + shutil.rmtree(rundir) + + def post_live_output(self, job_id, data): + """Post output data to the testflinger server for this job + + :param job_id: + id for the job on which we want to post results + :param data: + string with latest output data + """ + output_uri = urljoin(self.config.get('server'), + '/v1/result/{}/output'.format(job_id)) + job_request = requests.post(output_uri, data=data.encode('utf-8')) + if job_request.status_code != 200: + return False + return True diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py new file mode 100644 index 00000000..1b332e2a --- /dev/null +++ b/testflinger_agent/job.py @@ -0,0 +1,133 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see + +import fcntl +import json +import logging +import os +import select +import sys +import subprocess +import time + +logger = logging.getLogger() + + +class TestflingerJob: + def __init__(self, job_data, client): + """ + :param job_data: + Dictionary containing data for the test job_data + :param client: + Testflinger client object for communicating with the server + """ + self.client = client + self.job_data = job_data + self.job_id = job_data.get('job_id') + + def run_test_phase(self, phase, rundir): + """Run the specified test phase in rundir + + :param phase: + Name of the test phase (setup, provision, test, ...) + :param rundir: + Directory in which to run the command defined for the phase + :return: + Returncode from the command that was executed, 0 will be returned + if there was no command to run + """ + cmd = self.client.config.get(phase+'_command') + if not cmd: + return 0 + phase_log = os.path.join(rundir, phase+'.log') + logger.info('Running %s_command: %s' % (phase, cmd)) + # Set the exitcode to some failed status in case we get interrupted + exitcode = 99 + try: + exitcode = self.run_with_log(cmd, phase_log, rundir) + except Exception as e: + logger.exception(e) + finally: + # Save the output log in the json file no matter what + with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: + outcome_data = json.load(f) + if os.path.exists(phase_log): + with open(phase_log) as f: + outcome_data[phase+'_output'] = f.read() + outcome_data[phase+'_status'] = exitcode + with open(os.path.join(rundir, 'testflinger-outcome.json'), + 'w') as f: + json.dump(outcome_data, f) + return exitcode + + def run_with_log(self, cmd, logfile, cwd=None): + """Execute command in a subprocess and log the output + + :param cmd: + Command to run + :param logfile: + Filename to save the output in + :param cwd: + Path to run the command from + :return: + returncode from the process + """ + with open(logfile, 'w') as f: + live_output_buffer = '' + readpoll = select.poll() + buffer_timeout = time.time() + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=True, cwd=cwd) + set_nonblock(process.stdout.fileno()) + readpoll.register(process.stdout, select.POLLIN) + while process.poll() is None: + # Check if there's any new data, timeout after 10s + data_ready = readpoll.poll(10000) + if data_ready: + buf = process.stdout.read().decode(sys.stdout.encoding) + if buf: + sys.stdout.write(buf) + live_output_buffer += buf + # Don't spam the server, only flush the buffer if there + # is output and it's been more than 10s + if time.time() - buffer_timeout > 10: + buffer_timeout = time.time() + # Try to stream output, if we can't connect, then + # keep buffer for the next pass through this + if self.client.post_live_output( + self.job_id, live_output_buffer): + live_output_buffer = '' + f.write(buf) + f.flush() + buf = process.stdout.read().decode(sys.stdout.encoding) + if buf: + sys.stdout.write(buf) + live_output_buffer += buf + self.client.post_live_output(self.job_id, live_output_buffer) + f.write(buf) + return process.returncode + + +def set_nonblock(fd): + """Set the specified fd to nonblocking output + + :param fd: + File descriptor that should be set to nonblocking mode + """ + + # XXX: This is only used in one place right now, may want to consider + # moving it if it gets wider use in the future + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py new file mode 100644 index 00000000..84defc28 --- /dev/null +++ b/testflinger_agent/tests/test_agent.py @@ -0,0 +1,233 @@ +import json +import os +import requests +import shutil +import tempfile +import uuid + +from mock import (patch, MagicMock) +from unittest import TestCase + +import testflinger_agent +from testflinger_agent.errors import TFServerError +from testflinger_agent.client import TestflingerClient +from testflinger_agent.agent import TestflingerAgent + + +class ClientRunTests(TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.config = {'agent_id': 'test01', + 'polling_interval': '2', + 'server_address': '127.0.0.1:8000', + 'job_queues': ['test'], + 'execution_basedir': self.tmpdir, + 'logging_basedir': self.tmpdir, + 'results_basedir': os.path.join(self.tmpdir, 'results') + } + testflinger_agent.configure_logging(self.config) + + def get_agent(self): + client = TestflingerClient(self.config) + return TestflingerAgent(client) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + @patch('shutil.rmtree') + @patch('requests.post') + @patch('requests.get') + def test_check_and_run_setup(self, mock_requests_get, mock_requests_post, + mock_rmtree): + self.config['setup_command'] = 'echo setup1' + agent = self.get_agent() + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.return_value = MagicMock(status_code=200) + agent.process_jobs() + setuplog = open(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'setup.log')).read() + self.assertEqual('setup1', setuplog.strip()) + + @patch('shutil.rmtree') + @patch('requests.post') + @patch('requests.get') + def test_check_and_run_provision(self, mock_requests_get, + mock_requests_post, mock_rmtree): + self.config['provision_command'] = 'echo provision1' + agent = self.get_agent() + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.return_value = MagicMock(status_code=200) + agent.process_jobs() + provisionlog = open(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'provision.log')).read() + self.assertEqual('provision1', provisionlog.strip()) + + @patch('shutil.rmtree') + @patch('requests.post') + @patch('requests.get') + def test_check_and_run_test(self, mock_requests_get, mock_requests_post, + mock_rmtree): + self.config['test_command'] = 'echo test1' + agent = self.get_agent() + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.return_value = MagicMock(status_code=200) + agent.process_jobs() + testlog = open(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'test.log')).read() + self.assertEqual('test1', testlog.strip()) + + @patch('testflinger_agent.client.os.unlink') + @patch('shutil.rmtree') + @patch('requests.post') + @patch('requests.get') + def test_phase_failed(self, mock_requests_get, mock_requests_post, + mock_rmtree, mock_unlink): + """Make sure we stop running after a failed phase""" + self.config['provision_command'] = '/bin/false' + self.config['test_command'] = 'echo test1' + agent = self.get_agent() + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] + # Make sure we return good status when posting the outcome + # shutil.rmtree is mocked so that we avoid removing the files + # before finishing the test + mock_requests_post.return_value = MagicMock(status_code=200) + agent.process_jobs() + outcome_file = os.path.join(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'testflinger-outcome.json')) + with open(outcome_file) as f: + outcome_data = json.load(f) + self.assertEqual(1, outcome_data.get('provision_status')) + self.assertEqual(None, outcome_data.get('test_status')) + + @patch('testflinger_agent.client.logger.exception') + @patch.object(testflinger_agent.client.TestflingerClient, + 'transmit_job_outcome') + @patch('requests.get') + @patch('requests.post') + def test_retry_transmit(self, mock_requests_post, mock_requests_get, + mock_transmit_job_outcome, + mock_logger_exception): + """Make sure we retry sending test results""" + self.config['provision_command'] = '/bin/false' + self.config['test_command'] = 'echo test1' + agent = self.get_agent() + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + # Send an extra terminator since we will be calling get 3 times + mock_requests_get.side_effect = [fake_response, terminator, terminator] + # Make sure we fail the first time when transmitting the results + mock_transmit_job_outcome.side_effect = [TFServerError(404), + terminator, terminator] + mock_requests_post.return_value = MagicMock(status_code=200) + agent.process_jobs() + first_dir = os.path.join( + self.config.get('execution_basedir'), + fake_job_data.get('job_id')) + mock_transmit_job_outcome.assert_called_with(first_dir) + # Try processing the jobs again, now it should be in results_basedir + agent.process_jobs() + retry_dir = os.path.join( + self.config.get('results_basedir'), + fake_job_data.get('job_id')) + mock_transmit_job_outcome.assert_called_with(retry_dir) + + @patch('testflinger_agent.client.logger.exception') + @patch('requests.post') + @patch('requests.get') + def test_post_artifact(self, mock_requests_get, + mock_requests_post, + mock_logger_exception): + """Test posting files from the artifact directory""" + # Create an artifact as part of the test process + self.config['test_command'] = ('mkdir artifacts && ' + 'echo test1 > artifacts/t') + agent = self.get_agent() + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + # Send an extra terminator since we will be calling get 3 times + mock_requests_get.side_effect = [fake_response, terminator, terminator] + # Make sure we fail the first time when transmitting the results + mock_requests_post.return_value = MagicMock(status_code=200) + agent.process_jobs() + # Ok, I know this is weird. The fifth time post is called when we + # have an artifact, it will be sending the artifact and there + # should be a 'files' key in the call arguments. Replicating all + # the args is not feasible or useful + self.assertTrue('files' in str(mock_requests_post.mock_calls[4])) + + @patch('shutil.rmtree') + @patch('requests.post') + @patch('requests.get') + def test_recovery_failed(self, mock_requests_get, mock_requests_post, + mock_rmtree): + """Make sure we stop processing jobs after a device recovery error""" + OFFLINE_FILE = '/tmp/TESTFLINGER-DEVICE-OFFLINE-test001' + if os.path.exists(OFFLINE_FILE): + os.unlink(OFFLINE_FILE) + self.config['agent_id'] = 'test001' + self.config['provision_command'] = 'exit 46' + self.config['test_command'] = 'echo test1' + agent = self.get_agent() + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test'} + fake_response = requests.Response() + fake_response._content = json.dumps(fake_job_data).encode() + terminator = requests.Response() + terminator._content = {} + mock_requests_get.side_effect = [fake_response, terminator] + # In this case we are making sure that the repost job request + # gets good status + mock_requests_post.return_value = MagicMock(status_code=200) + agent.process_jobs() + self.assertEqual(True, agent.check_offline()) + # These are the args we would expect when it reposts the job + repost_args = ('http://127.0.0.1:8000/v1/job') + repost_kwargs = dict(json=fake_job_data) + mock_requests_post.assert_called_with(repost_args, **repost_kwargs) + if os.path.exists(OFFLINE_FILE): + os.unlink(OFFLINE_FILE) diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 57691e08..2d0d8cc2 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -14,240 +14,30 @@ import json import requests -import tempfile -import os -import shutil -import uuid -import testflinger_agent -from testflinger_agent.errors import TFServerError +import uuid -from mock import (patch, MagicMock) +from mock import patch from unittest import TestCase +from testflinger_agent.client import TestflingerClient + class ClientTest(TestCase): @patch('requests.get') def test_check_jobs_empty(self, mock_requests_get): + client = TestflingerClient({'server_address': ''}) mock_requests_get.return_value = requests.Response() - job_data = testflinger_agent.client.check_jobs() + job_data = client.check_jobs() self.assertEqual(job_data, None) @patch('requests.get') def test_check_jobs_with_job(self, mock_requests_get): + client = TestflingerClient({'server_address': ''}) fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test_queue'} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() mock_requests_get.return_value = fake_response - job_data = testflinger_agent.client.check_jobs() + job_data = client.check_jobs() self.assertEqual(job_data, fake_job_data) - - -class ClientRunTests(TestCase): - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - testflinger_agent.config = {'agent_id': 'test01', - 'polling_interval': '2', - 'server_address': '127.0.0.1:8000', - 'job_queues': ['test'], - 'execution_basedir': self.tmpdir, - 'logging_basedir': self.tmpdir, - 'results_basedir': os.path.join( - self.tmpdir, - 'results') - } - testflinger_agent.configure_logging() - - def tearDown(self): - shutil.rmtree(self.tmpdir) - - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_check_and_run_setup(self, mock_requests_get, mock_requests_post, - mock_rmtree): - testflinger_agent.config['setup_command'] = 'echo setup1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test - mock_requests_post.return_value = MagicMock(status_code=200) - testflinger_agent.client.process_jobs() - setuplog = open(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'setup.log')).read() - self.assertEqual('setup1', setuplog.strip()) - - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_check_and_run_provision(self, mock_requests_get, - mock_requests_post, mock_rmtree): - testflinger_agent.config['provision_command'] = 'echo provision1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test - mock_requests_post.return_value = MagicMock(status_code=200) - testflinger_agent.client.process_jobs() - provisionlog = open(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'provision.log')).read() - self.assertEqual('provision1', provisionlog.strip()) - - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_check_and_run_test(self, mock_requests_get, mock_requests_post, - mock_rmtree): - testflinger_agent.config['test_command'] = 'echo test1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test - mock_requests_post.return_value = MagicMock(status_code=200) - testflinger_agent.client.process_jobs() - testlog = open(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'test.log')).read() - self.assertEqual('test1', testlog.strip()) - - @patch('testflinger_agent.client.os.unlink') - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_phase_failed(self, mock_requests_get, mock_requests_post, - mock_rmtree, mock_unlink): - """Make sure we stop running after a failed phase""" - testflinger_agent.config['provision_command'] = '/bin/false' - testflinger_agent.config['test_command'] = 'echo test1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test - mock_requests_post.return_value = MagicMock(status_code=200) - testflinger_agent.client.process_jobs() - outcome_file = os.path.join(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'testflinger-outcome.json')) - with open(outcome_file) as f: - outcome_data = json.load(f) - self.assertEqual(1, outcome_data.get('provision_status')) - self.assertEqual(None, outcome_data.get('test_status')) - - @patch('testflinger_agent.client.logger.exception') - @patch('testflinger_agent.client.transmit_job_outcome') - @patch('requests.get') - @patch('requests.post') - def test_retry_transmit(self, mock_requests_post, mock_requests_get, - mock_transmit_job_outcome, - mock_logger_exception): - """Make sure we retry sending test results""" - testflinger_agent.config['provision_command'] = '/bin/false' - testflinger_agent.config['test_command'] = 'echo test1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - # Send an extra terminator since we will be calling get 3 times - mock_requests_get.side_effect = [fake_response, terminator, terminator] - # Make sure we fail the first time when transmitting the results - mock_transmit_job_outcome.side_effect = [TFServerError(404), - terminator, terminator] - mock_requests_post.return_value = MagicMock(status_code=200) - testflinger_agent.client.process_jobs() - first_dir = os.path.join( - testflinger_agent.config.get('execution_basedir'), - fake_job_data.get('job_id')) - mock_transmit_job_outcome.assert_called_with(first_dir) - # Try processing the jobs again, now it should be in results_basedir - testflinger_agent.client.process_jobs() - retry_dir = os.path.join( - testflinger_agent.config.get('results_basedir'), - fake_job_data.get('job_id')) - mock_transmit_job_outcome.assert_called_with(retry_dir) - - @patch('testflinger_agent.client.logger.exception') - @patch('requests.post') - @patch('requests.get') - def test_post_artifact(self, mock_requests_get, - mock_requests_post, - mock_logger_exception): - """Test posting files from the artifact directory""" - # Create an artifact as part of the test process - testflinger_agent.config['test_command'] = ('mkdir artifacts && ' - 'echo test1 > artifacts/t') - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - # Send an extra terminator since we will be calling get 3 times - mock_requests_get.side_effect = [fake_response, terminator, terminator] - # Make sure we fail the first time when transmitting the results - mock_requests_post.return_value = MagicMock(status_code=200) - testflinger_agent.client.process_jobs() - # Ok, I know this is weird. The fifth time post is called when we - # have an artifact, it will be sending the artifact and there - # should be a 'files' key in the call arguments. Replicating all - # the args is not feasible or useful - self.assertTrue('files' in str(mock_requests_post.mock_calls[4])) - - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_recovery_failed(self, mock_requests_get, mock_requests_post, - mock_rmtree): - """Make sure we stop processing jobs after a device recovery error""" - OFFLINE_FILE = '/tmp/TESTFLINGER-DEVICE-OFFLINE-test001' - if os.path.exists(OFFLINE_FILE): - os.unlink(OFFLINE_FILE) - testflinger_agent.config['agent_id'] = 'test001' - testflinger_agent.config['provision_command'] = 'exit 46' - testflinger_agent.config['test_command'] = 'echo test1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # In this case we are making sure that the repost job request - # gets good status - mock_requests_post.return_value = MagicMock(status_code=200) - testflinger_agent.client.process_jobs() - self.assertEqual(True, testflinger_agent.check_offline()) - # These are the args we would expect when it reposts the job - repost_args = ('http://127.0.0.1:8000/v1/job') - repost_kwargs = dict(json=fake_job_data) - mock_requests_post.assert_called_with(repost_args, **repost_kwargs) - if os.path.exists(OFFLINE_FILE): - os.unlink(OFFLINE_FILE) diff --git a/testflinger_agent/tests/test_config.py b/testflinger_agent/tests/test_config.py index a71cf0a9..b575d9c4 100644 --- a/testflinger_agent/tests/test_config.py +++ b/testflinger_agent/tests/test_config.py @@ -43,8 +43,8 @@ def tearDown(self): def test_config_good(self): with open(self.configfile, 'w') as config: config.write(GOOD_CONFIG) - testflinger_agent.load_config(self.configfile) - self.assertEqual('test01', testflinger_agent.config.get('agent_id')) + config = testflinger_agent.load_config(self.configfile) + self.assertEqual('test01', config.get('agent_id')) def test_config_bad(self): with open(self.configfile, 'w') as config: From 99a967d321ca2db1c359896c64ffc9df7e28336c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 13 Mar 2017 15:47:31 -0500 Subject: [PATCH 107/569] Add a show command that allows for inspecting the contents of a submitted job --- testflinger-cli | 20 ++++++++++++++++++++ testflinger_cli/__init__.py | 11 +++++++++++ 2 files changed, 31 insertions(+) diff --git a/testflinger-cli b/testflinger-cli index a8451297..e28a0d5b 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -89,6 +89,26 @@ def submit(ctx, filename, quiet): print('job_id: {}'.format(job_id)) +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def show(ctx, job_id): + conn = ctx.obj['conn'] + try: + results = conn.show_job(job_id) + except testflinger_cli.HTTPError as e: + if e.status == 204: + print('No data found for that job id.') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + print(json.dumps(results, sort_keys=True, indent=4)) + + @cli.command() @click.argument('job_id', nargs=1) @click.pass_context diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index a5ba9d7b..2f32dfa7 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -97,6 +97,17 @@ def submit_job(self, job_data): response = self.put(endpoint, data) return json.loads(response).get('job_id') + def show_job(self, job_id): + """Show the JSON job definition for the specified ID + + :param job_id: + ID for the test job + :return: + JSON job definition for the specified ID + """ + endpoint = '/v1/job/{}'.format(job_id) + return json.loads(self.get(endpoint)) + def get_results(self, job_id): """Get results for a specified test job From c3e5693f554ff7d62f2f4742c21e5fdf60389e22 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 16 Mar 2017 11:03:29 -0500 Subject: [PATCH 108/569] Fix maas acquiring random nodes --- devices/maas/maas.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/devices/maas/maas.py b/devices/maas/maas.py index effd03aa..f1d8f725 100644 --- a/devices/maas/maas.py +++ b/devices/maas/maas.py @@ -44,13 +44,14 @@ def recover(self): def provision(self): maas_user = self.config.get('maas_user') node_id = self.config.get('node_id') + node_name = self.config.get('node_name') agent_name = self.config.get('agent_name') provision_data = self.job_data.get('provision_data') # Default to a safe LTS if no distro is specified distro = provision_data.get('distro', 'xenial') logger.info('Acquiring node') cmd = ['maas', maas_user, 'nodes', 'acquire', - 'nodes={}'.format(node_id)] + 'name={}'.format(node_name)] # Do not use runcmd for this - we need the output, not the end user subprocess.check_call(cmd) logger.info( From f8c2383e7bc4850d892a4a25ca7006ff846709f5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 17 Mar 2017 16:11:42 -0500 Subject: [PATCH 109/569] Add post_flash_cmds support for netboot --- devices/netboot/netboot.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 0420fd0d..34d39165 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -48,12 +48,25 @@ def setboot(self, mode): This method sets the snappy boot method to the specified value. """ if mode == 'master': - setboot_script = self.config['select_master_script'] + setboot_script = self.config.get('select_master_script') elif mode == 'test': - setboot_script = self.config['select_test_script'] + setboot_script = self.config.get('select_test_script') else: - raise KeyError - for cmd in setboot_script: + raise ProvisioningError("Attempted to set boot mode to '{}' - " + "only 'master' or 'test' are supported " + "modes!".format(mode)) + self._run_cmd_list(setboot_script) + + def _run_cmd_list(self, cmdlist): + """ + Run a list of commands + + :param cmdlist: + List of commands to run + """ + if not cmdlist: + return + for cmd in cmdlist: logger.info("Running %s", cmd) try: rc = runcmd(cmd, timeout=60) @@ -220,6 +233,10 @@ def flash_test_image(self, server_ip, server_port): logger.info("Image write output:") logger.info(str(req.read())) + # Run post-flash hooks + post_flash_cmds = self.config.get('post_flash_cmds') + self._run_cmd_list(post_flash_cmds) + # Now reboot the target system url = 'http://{}:8989/reboot'.format(self.config['device_ip']) try: From d5d1c7b2e61dcfd02a8d95affba3576daee21141 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 17 Mar 2017 16:11:42 -0500 Subject: [PATCH 110/569] Add post_flash_cmds support for netboot --- devices/maas/__init__.py | 1 - devices/netboot/netboot.py | 25 +++++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/devices/maas/__init__.py b/devices/maas/__init__.py index 7256c53e..938c3f29 100644 --- a/devices/maas/__init__.py +++ b/devices/maas/__init__.py @@ -15,7 +15,6 @@ """Ubuntu Maas support code.""" import logging -import os import yaml import guacamole diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 0420fd0d..34d39165 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -48,12 +48,25 @@ def setboot(self, mode): This method sets the snappy boot method to the specified value. """ if mode == 'master': - setboot_script = self.config['select_master_script'] + setboot_script = self.config.get('select_master_script') elif mode == 'test': - setboot_script = self.config['select_test_script'] + setboot_script = self.config.get('select_test_script') else: - raise KeyError - for cmd in setboot_script: + raise ProvisioningError("Attempted to set boot mode to '{}' - " + "only 'master' or 'test' are supported " + "modes!".format(mode)) + self._run_cmd_list(setboot_script) + + def _run_cmd_list(self, cmdlist): + """ + Run a list of commands + + :param cmdlist: + List of commands to run + """ + if not cmdlist: + return + for cmd in cmdlist: logger.info("Running %s", cmd) try: rc = runcmd(cmd, timeout=60) @@ -220,6 +233,10 @@ def flash_test_image(self, server_ip, server_port): logger.info("Image write output:") logger.info(str(req.read())) + # Run post-flash hooks + post_flash_cmds = self.config.get('post_flash_cmds') + self._run_cmd_list(post_flash_cmds) + # Now reboot the target system url = 'http://{}:8989/reboot'.format(self.config['device_ip']) try: From 27399176c60b74458e325baafcf15399e1d8302d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 22 Mar 2017 23:10:38 -0500 Subject: [PATCH 111/569] Add a noprovision device type for devices that are pre-configured --- devices/noprovision/__init__.py | 101 +++++++++++++++++++++++++++++ devices/noprovision/noprovision.py | 94 +++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 devices/noprovision/__init__.py create mode 100644 devices/noprovision/noprovision.py diff --git a/devices/noprovision/__init__.py b/devices/noprovision/__init__.py new file mode 100644 index 00000000..77d84eed --- /dev/null +++ b/devices/noprovision/__init__.py @@ -0,0 +1,101 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Noprovision support code.""" + +import logging +import yaml + +import guacamole + +import snappy_device_agents +from devices.noprovision.noprovision import Noprovision +from snappy_device_agents import logmsg, runcmd + +from devices import (Catch, RecoveryError) + +device_name = "noprovision" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + @Catch(RecoveryError, 46) + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = Noprovision(ctx.args.config) + test_username = snappy_device_agents.get_test_username( + ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + device.ensure_test_image(test_username) + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = 0 + for cmd in test_cmds: + # Settings from the device yaml configfile like device_ip can be + # formatted in test commands like "foo {device_ip}" + try: + cmd = cmd.format(**config) + except: + exitcode = 20 + logmsg(logging.ERROR, "Unable to format command: %s", cmd) + + logmsg(logging.INFO, "Running: %s", cmd) + rc = runcmd(cmd) + if rc: + exitcode = 4 + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Noprovision.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/noprovision/noprovision.py b/devices/noprovision/noprovision.py new file mode 100644 index 00000000..500d3dac --- /dev/null +++ b/devices/noprovision/noprovision.py @@ -0,0 +1,94 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Noprovision support code.""" + +import logging +import subprocess +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) + +logger = logging.getLogger() + + +class Noprovision: + + """Snappy Device Agent for Noprovision.""" + + def __init__(self, config): + with open(config) as configfile: + self.config = yaml.load(configfile) + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config['reboot_script']: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise RecoveryError("timeout reaching control host!") + + def ensure_test_image(self, test_username): + """ + Actively switch the device to boot the test image. + + :param test_username: + Username of the default user in the test image + :param test_password: + Password of the default user in the test image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + logger.info("Booting the test image") + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip']), + '/bin/true'] + try: + subprocess.check_call(cmd) + return + except: + pass + + self.hardreset() + time.sleep(60) + + started = time.time() + # Retry for a while since we might still be rebooting + while time.time() - started < 300: + try: + time.sleep(10) + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', + 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip']), + '/bin/true'] + subprocess.check_call(cmd) + break + except: + # keep going if we aren't booted yet + pass + # If we got here, then it never booted to the test image + raise ProvisioningError("Failed to boot test image!") From b43d4201f322694248ac47b9f461843721b5f7c5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 28 Mar 2017 10:36:41 -0500 Subject: [PATCH 112/569] Increase request timeout a bit, some requests seem to timeout periodically --- testflinger_cli/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 2f32dfa7..97edf276 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -31,7 +31,7 @@ class Client(): def __init__(self, server): self.server = server - def get(self, uri_frag, timeout=5): + def get(self, uri_frag, timeout=15): """Submit a GET request to the server :param uri_frag: endpoint for the GET request @@ -51,7 +51,7 @@ def get(self, uri_frag, timeout=5): raise HTTPError(req.status_code) return req.text - def put(self, uri_frag, data, timeout=5): + def put(self, uri_frag, data, timeout=15): """Submit a POST request to the server :param uri_frag: endpoint for the POST request @@ -129,7 +129,7 @@ def get_artifact(self, job_id, path): """ endpoint = '/v1/result/{}/artifact'.format(job_id) uri = urllib.parse.urljoin(self.server, endpoint) - req = requests.get(uri, timeout=5) + req = requests.get(uri, timeout=15) if req.status_code != 200: raise HTTPError(req.status_code) with open(path, 'wb') as artifact: From ee46868cc237e22196623b7b0b0d606475dc62d4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 31 Mar 2017 14:00:12 -0500 Subject: [PATCH 113/569] Catch possible exceptions during live output streaming and handle them better, plus a few other logging cleanups --- testflinger_agent/client.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index eee624d0..cd400507 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -64,8 +64,8 @@ def repost_job(self, job_data): logger.info('Resubmitting job for job: %s' % job_data.get('job_id')) job_request = requests.post(job_uri, json=job_data) if job_request.status_code != 200: - logging.error('Unable to re-post job to: %s (error: %s)' % - (job_uri, job_request.status_code)) + logger.error('Unable to re-post job to: %s (error: %s)' % + (job_uri, job_request.status_code)) raise TFServerError(job_request.status_code) def post_result(self, job_id, data): @@ -80,8 +80,8 @@ def post_result(self, job_id, data): result_uri = urljoin(result_uri, job_id) job_request = requests.post(result_uri, json=data) if job_request.status_code != 200: - logging.error('Unable to post results to: %s (error: %s)' % - (result_uri, job_request.status_code)) + logger.error('Unable to post results to: %s (error: %s)' % + (result_uri, job_request.status_code)) raise TFServerError(job_request.status_code) def transmit_job_outcome(self, rundir): @@ -121,8 +121,8 @@ def transmit_job_outcome(self, rundir): artifact_request = requests.post( artifact_uri, files=file_upload) if artifact_request.status_code != 200: - logging.error('Unable to post results to: %s (error: %s)' % - (artifact_uri, artifact_request.status_code)) + logger.error('Unable to post results to: %s (error: %s)' % + (artifact_uri, artifact_request.status_code)) raise TFServerError(artifact_request.status_code) else: shutil.rmtree(artifacts_dir) @@ -138,7 +138,12 @@ def post_live_output(self, job_id, data): """ output_uri = urljoin(self.config.get('server'), '/v1/result/{}/output'.format(job_id)) - job_request = requests.post(output_uri, data=data.encode('utf-8')) + try: + job_request = requests.post( + output_uri, data=data.encode('utf-8')) + except Exception as e: + logger.exception(e) + return False if job_request.status_code != 200: return False return True From a0160dbabc43fbb794372e355bbd2506c0189045 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 4 Apr 2017 13:42:58 -0500 Subject: [PATCH 114/569] Fix for end output sometimes not available in live streaming --- testflinger_agent/job.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 1b332e2a..8c9c233e 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -115,8 +115,9 @@ def run_with_log(self, cmd, logfile, cwd=None): if buf: sys.stdout.write(buf) live_output_buffer += buf - self.client.post_live_output(self.job_id, live_output_buffer) f.write(buf) + if live_output_buffer: + self.client.post_live_output(self.job_id, live_output_buffer) return process.returncode From 01703c189d9fd90cc5830b2c9adcb8b099648911 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 3 Apr 2017 12:47:05 -0500 Subject: [PATCH 115/569] Consolidate the code to run cmds from a list into snappy_device_agents --- devices/dragonboard/__init__.py | 18 ++--------- devices/maas/__init__.py | 18 ++--------- devices/netboot/__init__.py | 18 ++--------- devices/noprovision/__init__.py | 18 ++--------- devices/touch/__init__.py | 9 ++---- snappy_device_agents/__init__.py | 51 ++++++++++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 71 deletions(-) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 6d8c42fa..eba83be5 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -21,7 +21,7 @@ import snappy_device_agents from devices.dragonboard.dragonboard import Dragonboard -from snappy_device_agents import logmsg, runcmd +from snappy_device_agents import logmsg, run_test_cmds from devices import (Catch, RecoveryError) device_name = "dragonboard" @@ -64,21 +64,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = 0 - for cmd in test_cmds: - # Settings from the device yaml configfile like device_ip can be - # formatted in test commands like "foo {device_ip}" - try: - cmd = cmd.format(**config) - except: - exitcode = 20 - logmsg(logging.ERROR, "Unable to format command: %s", cmd) - - logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd) - if rc: - exitcode = 4 - logmsg(logging.WARNING, "Command failed, rc=%d", rc) + exitcode = run_test_cmds(test_cmds, config) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/maas/__init__.py b/devices/maas/__init__.py index 938c3f29..cd98b540 100644 --- a/devices/maas/__init__.py +++ b/devices/maas/__init__.py @@ -21,7 +21,7 @@ import snappy_device_agents from devices.maas.maas import Maas -from snappy_device_agents import logmsg, runcmd +from snappy_device_agents import logmsg, run_test_cmds from devices import (Catch, RecoveryError) device_name = "maas" @@ -66,21 +66,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = 0 - for cmd in test_cmds: - # Settings from the device yaml configfile like device_ip can be - # formatted in test commands like "foo {device_ip}" - try: - cmd = cmd.format(**config) - except: - exitcode = 20 - logmsg(logging.ERROR, "Unable to format command: %s", cmd) - - logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd) - if rc: - exitcode = 4 - logmsg(logging.WARNING, "Command failed, rc=%d", rc) + exitcode = run_test_cmds(test_cmds, config) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 0f3f4a45..48270694 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -22,7 +22,7 @@ import snappy_device_agents from devices.netboot.netboot import Netboot -from snappy_device_agents import logmsg, runcmd +from snappy_device_agents import logmsg, run_test_cmds from devices import (Catch, RecoveryError) @@ -82,21 +82,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = 0 - for cmd in test_cmds: - # Settings from the device yaml configfile like device_ip can be - # formatted in test commands like "foo {device_ip}" - try: - cmd = cmd.format(**config) - except: - exitcode = 20 - logmsg(logging.ERROR, "Unable to format command: %s", cmd) - - logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd) - if rc: - exitcode = 4 - logmsg(logging.WARNING, "Command failed, rc=%d", rc) + exitcode = run_test_cmds(test_cmds, config) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/noprovision/__init__.py b/devices/noprovision/__init__.py index 77d84eed..cc46fe5f 100644 --- a/devices/noprovision/__init__.py +++ b/devices/noprovision/__init__.py @@ -21,7 +21,7 @@ import snappy_device_agents from devices.noprovision.noprovision import Noprovision -from snappy_device_agents import logmsg, runcmd +from snappy_device_agents import logmsg, run_test_cmds from devices import (Catch, RecoveryError) @@ -66,21 +66,7 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = 0 - for cmd in test_cmds: - # Settings from the device yaml configfile like device_ip can be - # formatted in test commands like "foo {device_ip}" - try: - cmd = cmd.format(**config) - except: - exitcode = 20 - logmsg(logging.ERROR, "Unable to format command: %s", cmd) - - logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd) - if rc: - exitcode = 4 - logmsg(logging.WARNING, "Command failed, rc=%d", rc) + exitcode = run_test_cmds(test_cmds, config) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/touch/__init__.py b/devices/touch/__init__.py index 68a513d4..ecdf9119 100644 --- a/devices/touch/__init__.py +++ b/devices/touch/__init__.py @@ -22,7 +22,7 @@ import snappy_device_agents from devices.touch.touch import Touch -from snappy_device_agents import logmsg, runcmd +from snappy_device_agents import logmsg, run_test_cmds from devices import (Catch, RecoveryError) device_name = "touch" @@ -69,12 +69,7 @@ def invoked(self, ctx): exitcode = 0 env = os.environ.copy() env['ANDROID_SERIAL'] = config.get('serial') - for cmd in test_cmds: - logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd, env=env) - if rc: - exitcode = 4 - logmsg(logging.WARNING, "Command failed, rc=%d", rc) + exitcode = run_test_cmds(test_cmds, config, env) logmsg(logging.INFO, "END testrun") return exitcode diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 155566ab..5e77777e 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -349,3 +349,54 @@ def runcmd(cmd, env=None, timeout=None): if line: sys.stdout.write(line.decode()) return process.returncode + + +def run_test_cmds(cmds, config=None, env=None): + """ + Run the test commands provided + This is just a frontend to determine the type of cmds we + were passed and do the right thing with it + + :param cmds: + Commands to run as a string or list of strings + :param config: + Config data for the device which can be used for filling templates + :param env: + Environment to pass when running the commands + """ + + if type(cmds) is list: + _run_test_cmds_list(cmds, config, env) + + +def _run_test_cmds_list(cmds, config=None, env=None): + """ + Run the test commands provided + + :param cmds: + Commands to run as a list of strings + :param config: + Config data for the device which can be used for filling templates + :param env: + Environment to pass when running the commands + :return returncode: + Return 0 if everything succeeded, 4 if any command in the list + failed, or 20 if there was a formatting error + """ + + exitcode = 0 + for cmd in cmds: + # Settings from the device yaml configfile like device_ip can be + # formatted in test commands like "foo {device_ip}" + try: + cmd = cmd.format(**config) + except: + exitcode = 20 + logmsg(logging.ERROR, "Unable to format command: %s", cmd) + + logmsg(logging.INFO, "Running: %s", cmd) + rc = runcmd(cmd, env) + if rc: + exitcode = 4 + logmsg(logging.WARNING, "Command failed, rc=%d", rc) + return exitcode From bc54fd31863ec0464a84f1c70a9f18565f207be5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 4 Apr 2017 16:24:15 -0500 Subject: [PATCH 116/569] Add _run_test_cmds_str() for processing test_cmds as a string --- snappy_device_agents/__init__.py | 37 ++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 5e77777e..aecb7667 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -192,8 +192,8 @@ def get_image(job_data='testflinger.json'): image = delayretry(udf_create_image, [udf_params], max_retries=3, delay=60) else: - logging.error('provision_data needs to contain "url" for the image ' - 'or "udf-params"') + logger.error('provision_data needs to contain "url" for the image ' + 'or "udf-params"') return compress_file(image) @@ -367,6 +367,11 @@ def run_test_cmds(cmds, config=None, env=None): if type(cmds) is list: _run_test_cmds_list(cmds, config, env) + elif type(cmds) is str: + _run_test_cmds_str(cmds, config, env) + else: + logmsg(logging.ERROR, "test_cmds field must be a list or string") + return 1 def _run_test_cmds_list(cmds, config=None, env=None): @@ -400,3 +405,31 @@ def _run_test_cmds_list(cmds, config=None, env=None): exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) return exitcode + + +def _run_test_cmds_str(cmds, config=None, env=None): + """ + Run the test commands provided + + :param cmds: + Commands to run as a string + :param config: + Config data for the device which can be used for filling templates + :param env: + Environment to pass when running the commands + :return returncode: + Return the value of the return code from the script, or 20 if there + was an error formatting the script + """ + try: + cmds = cmds.format(**config) + except KeyError as e: + logmsg(logging.ERROR, "Unable to format key: %s", e.args[0]) + return 20 + with open('tf_cmd_script', mode='w', encoding='utf-8') as tf_cmd_script: + tf_cmd_script.write(cmds) + os.chmod('tf_cmd_script', 0o775) + rc = runcmd('./tf_cmd_script', env) + if rc: + logmsg(logging.WARNING, "Tests failed, rc=%d", rc) + return rc From 0adb82aee24ce282852dfc41f6f6a31595c6bffc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 26 Apr 2017 15:23:02 -0500 Subject: [PATCH 117/569] Add support for maas 2.x cli --- devices/maas2/__init__.py | 87 ++++++++++++++++++++++++++++ devices/maas2/maas2.py | 119 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 devices/maas2/__init__.py create mode 100644 devices/maas2/maas2.py diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py new file mode 100644 index 00000000..e4cb3e8f --- /dev/null +++ b/devices/maas2/__init__.py @@ -0,0 +1,87 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu MaaS 2.x CLI support code.""" + +import logging +import yaml + +import guacamole + +import snappy_device_agents +from devices.maas2.maas2 import Maas2 +from snappy_device_agents import logmsg, run_test_cmds +from devices import (Catch, RecoveryError) + +device_name = "maas2" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + @Catch(RecoveryError, 46) + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = Maas2(ctx.args.config, ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Recovering device") + device.recover() + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = run_test_cmds(test_cmds, config) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Ubuntu MaaS 2.0 CLI.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py new file mode 100644 index 00000000..e609c1da --- /dev/null +++ b/devices/maas2/maas2.py @@ -0,0 +1,119 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu MaaS 2.x CLI support code.""" + +import json +import logging +import subprocess +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) + +logger = logging.getLogger() + + +class Maas2: + + """Device Agent for Maas2.""" + + def __init__(self, config, job_data): + with open(config) as configfile: + self.config = yaml.load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) + + def recover(self): + agent_name = self.config.get('agent_name') + logger.info("Releasing node %s", agent_name) + self.node_release() + + def provision(self): + maas_user = self.config.get('maas_user') + node_id = self.config.get('node_id') + node_name = self.config.get('node_name') + agent_name = self.config.get('agent_name') + provision_data = self.job_data.get('provision_data') + # Default to a safe LTS if no distro is specified + distro = provision_data.get('distro', 'xenial') + logger.info('Acquiring node') + cmd = ['maas', maas_user, 'machines', 'allocate', + 'system_id={}'.format(node_id)] + # Do not use runcmd for this - we need the output, not the end user + subprocess.check_call(cmd) + logger.info( + 'Starting node %s with distro %s', agent_name, distro) + cmd = ['maas', maas_user, 'machine', 'deploy', node_id, + 'distro_series={}'.format(distro)] + output = subprocess.check_output(cmd) + # Make sure the device is available before returning + for timeout in range(0, 10): + time.sleep(60) + status = self.node_status() + if status == 'Deployed': + if self.check_test_image_booted(): + return + logger.error('Device %s still in "%s" state, deployment failed!', + agent_name, status) + logger.error(output) + raise ProvisioningError("Provisioning failed!") + + def check_test_image_booted(self): + logger.info("Checking if test image booted.") + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'snap -h'] + try: + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + except: + return False + # If we get here, then the above command proved we are booted + return True + + def node_status(self): + """Return status of the node according to maas: + + Ready: Node is unused + Allocated: Node is allocated + Deploying: Deployment in progress + Deployed: Node is provisioned and ready for use + """ + maas_user = self.config.get('maas_user') + node_id = self.config.get('node_id') + cmd = ['maas', maas_user, 'machine', 'read', node_id] + # Do not use runcmd for this - we need the output, not the end user + output = subprocess.check_output(cmd) + data = json.loads(output.decode()) + return data.get('status_name') + + def node_release(self): + """Release the node to make it available again""" + maas_user = self.config.get('maas_user') + node_id = self.config.get('node_id') + cmd = ['maas', maas_user, 'machine', 'release', node_id] + subprocess.check_call(cmd) + # Make sure the device is available before returning + for timeout in range(0, 10): + time.sleep(5) + status = self.node_status() + if status == 'Ready': + return + agent_name = self.config.get('agent_name') + logger.error('Device %s still in "%s" state, could not recover!', + agent_name, status) + raise RecoveryError("Device recovery failed!") From 190b13b840bfc7a0ccdfd591de93ab5a4552d247 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 27 Apr 2017 22:58:35 -0500 Subject: [PATCH 118/569] flake8 cleanup on maas2 device type --- devices/maas2/maas2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index e609c1da..ed5dc90e 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -44,7 +44,6 @@ def recover(self): def provision(self): maas_user = self.config.get('maas_user') node_id = self.config.get('node_id') - node_name = self.config.get('node_name') agent_name = self.config.get('agent_name') provision_data = self.job_data.get('provision_data') # Default to a safe LTS if no distro is specified From 7139f3e7922cc6d09de2da7bf0207d2ed823e462 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 3 May 2017 10:25:59 -0500 Subject: [PATCH 119/569] Expose environment variables at runtime if they are in the env section of our device config --- snappy_device_agents/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index aecb7667..3472d2a2 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -365,6 +365,10 @@ def run_test_cmds(cmds, config=None, env=None): Environment to pass when running the commands """ + if not env: + env = os.environ.copy() + config_env = config.get('env', {}) + env.update(config_env) if type(cmds) is list: _run_test_cmds_list(cmds, config, env) elif type(cmds) is str: From f209d11c74147f2e966f304b9c2c7e57d9fc43c4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 8 May 2017 10:27:36 -0500 Subject: [PATCH 120/569] cleanup a few spots where we should specify the encoding --- testflinger_agent/job.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 8c9c233e..a9eed0ca 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -63,11 +63,11 @@ def run_test_phase(self, phase, rundir): with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: outcome_data = json.load(f) if os.path.exists(phase_log): - with open(phase_log) as f: + with open(phase_log, encoding='utf-8') as f: outcome_data[phase+'_output'] = f.read() outcome_data[phase+'_status'] = exitcode with open(os.path.join(rundir, 'testflinger-outcome.json'), - 'w') as f: + 'w', encoding='utf-8') as f: json.dump(outcome_data, f) return exitcode @@ -83,7 +83,7 @@ def run_with_log(self, cmd, logfile, cwd=None): :return: returncode from the process """ - with open(logfile, 'w') as f: + with open(logfile, 'w', encoding='utf-8') as f: live_output_buffer = '' readpoll = select.poll() buffer_timeout = time.time() From 4f341850aeef7a750618e801acafbaebaf7189ee Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 11 May 2017 23:33:50 -0500 Subject: [PATCH 121/569] Print a banner at the start of each test phase stating the phase we are in a nd the name of the agent it's running on --- testflinger_agent/job.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index a9eed0ca..5210789d 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -48,12 +48,15 @@ def run_test_phase(self, phase, rundir): if there was no command to run """ cmd = self.client.config.get(phase+'_command') + node = self.client.config.get('agent_id') if not cmd: return 0 phase_log = os.path.join(rundir, phase+'.log') logger.info('Running %s_command: %s' % (phase, cmd)) # Set the exitcode to some failed status in case we get interrupted exitcode = 99 + for line in self.banner('Starting {} phase on {}'.format(phase, node)): + self.run_with_log("echo '{}'".format(line), phase_log, rundir) try: exitcode = self.run_with_log(cmd, phase_log, rundir) except Exception as e: @@ -83,7 +86,7 @@ def run_with_log(self, cmd, logfile, cwd=None): :return: returncode from the process """ - with open(logfile, 'w', encoding='utf-8') as f: + with open(logfile, 'a', encoding='utf-8') as f: live_output_buffer = '' readpoll = select.poll() buffer_timeout = time.time() @@ -120,6 +123,16 @@ def run_with_log(self, cmd, logfile, cwd=None): self.client.post_live_output(self.job_id, live_output_buffer) return process.returncode + def banner(self, line): + """Yield text lines to print a banner around a sting + + :param line: + Line of text to print a banner around + """ + yield '*' * (len(line) + 4) + yield '* {} *'.format(line) + yield '*' * (len(line) + 4) + def set_nonblock(fd): """Set the specified fd to nonblocking output From 1a8195118910fc6252d8bf1d42fa47639c7b56a2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 12 May 2017 14:14:09 -0500 Subject: [PATCH 122/569] Catch exceptions more broadly when things are running so that they can be logged, and not leave orphan test jobs behind --- testflinger_agent/__init__.py | 2 ++ testflinger_agent/agent.py | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index a76f8dab..b956df97 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -49,6 +49,8 @@ def main(): except KeyboardInterrupt: logger.info('Caught interrupt, exiting!') sys.exit(0) + except Exception as e: + logger.exception(e) def load_config(configfile): diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 89a16026..b50aeac0 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -68,7 +68,15 @@ def process_jobs(self): self.client.post_result(job.job_id, {'job_state': phase}) except TFServerError: pass - exitcode = job.run_test_phase(phase, rundir) + try: + exitcode = job.run_test_phase(phase, rundir) + except Exception as e: + # If we hit some unknown exception, preserve results, + # log the exception, and stop execution + logger.exception(e) + results_basedir = self.client.config.get('results_basedir') + shutil.move(rundir, results_basedir) + return # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline if exitcode == 46: From 7af5e3b102ded0288411e8f468a410d7809e3b63 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 12 May 2017 14:47:01 -0500 Subject: [PATCH 123/569] Fix tests since we altered the output with the banner --- testflinger_agent/tests/test_agent.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 84defc28..f255dd5d 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -56,7 +56,7 @@ def test_check_and_run_setup(self, mock_requests_get, mock_requests_post, setuplog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'setup.log')).read() - self.assertEqual('setup1', setuplog.strip()) + self.assertEqual('setup1', setuplog.splitlines()[-1].strip()) @patch('shutil.rmtree') @patch('requests.post') @@ -80,7 +80,7 @@ def test_check_and_run_provision(self, mock_requests_get, provisionlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'provision.log')).read() - self.assertEqual('provision1', provisionlog.strip()) + self.assertEqual('provision1', provisionlog.splitlines()[-1].strip()) @patch('shutil.rmtree') @patch('requests.post') @@ -104,7 +104,7 @@ def test_check_and_run_test(self, mock_requests_get, mock_requests_post, testlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'test.log')).read() - self.assertEqual('test1', testlog.strip()) + self.assertEqual('test1', testlog.splitlines()[-1].strip()) @patch('testflinger_agent.client.os.unlink') @patch('shutil.rmtree') @@ -194,11 +194,8 @@ def test_post_artifact(self, mock_requests_get, # Make sure we fail the first time when transmitting the results mock_requests_post.return_value = MagicMock(status_code=200) agent.process_jobs() - # Ok, I know this is weird. The fifth time post is called when we - # have an artifact, it will be sending the artifact and there - # should be a 'files' key in the call arguments. Replicating all - # the args is not feasible or useful - self.assertTrue('files' in str(mock_requests_post.mock_calls[4])) + # The last request should have the 'files' value we are looking for + self.assertTrue('files' in str(mock_requests_post.mock_calls[-1])) @patch('shutil.rmtree') @patch('requests.post') From 474568d932c7fdb98d17a9e88c714edf63ec88d3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 16 May 2017 22:45:02 -0500 Subject: [PATCH 124/569] Add global timeout for test runs --- testflinger_agent/job.py | 23 +++++++++++- testflinger_agent/schema.py | 1 + testflinger_agent/tests/test_job.py | 54 +++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 testflinger_agent/tests/test_job.py diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 5210789d..6ac9c5b6 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -86,6 +86,8 @@ def run_with_log(self, cmd, logfile, cwd=None): :return: returncode from the process """ + global_timeout = self.get_global_timeout() + start_time = time.time() with open(logfile, 'a', encoding='utf-8') as f: live_output_buffer = '' readpoll = select.poll() @@ -114,8 +116,16 @@ def run_with_log(self, cmd, logfile, cwd=None): live_output_buffer = '' f.write(buf) f.flush() - buf = process.stdout.read().decode(sys.stdout.encoding) + if time.time() - start_time > global_timeout: + buf = '\nERROR: Global timeout reached! ({}s)\n'.format( + global_timeout) + live_output_buffer += buf + f.write(buf) + process.terminate() + break + buf = process.stdout.read() if buf: + buf = buf.decode(sys.stdout.encoding) sys.stdout.write(buf) live_output_buffer += buf f.write(buf) @@ -123,6 +133,17 @@ def run_with_log(self, cmd, logfile, cwd=None): self.client.post_live_output(self.job_id, live_output_buffer) return process.returncode + def get_global_timeout(self): + """Get the global timeout for the test run in seconds + """ + # Default timeout is 4 hours + default_timeout = 4 * 60 * 60 + + # Don't exceed the maximum timeout configured for the device! + return min( + self.job_data.get('global_timeout', default_timeout), + self.client.config.get('global_timeout', default_timeout)) + def banner(self, line): """Yield text lines to print a banner around a sting diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index 4932a9e7..700a1289 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -30,6 +30,7 @@ voluptuous.Required('setup_command', default=''): str, voluptuous.Required('provision_command', default=''): str, voluptuous.Required('test_command', default=''): str, + voluptuous.Optional('global_timeout'): int, } diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py new file mode 100644 index 00000000..354f1b46 --- /dev/null +++ b/testflinger_agent/tests/test_job.py @@ -0,0 +1,54 @@ +import os +import shutil +import tempfile + +from mock import patch +from unittest import TestCase + +import testflinger_agent +from testflinger_agent.client import TestflingerClient +from testflinger_agent.job import TestflingerJob + + +class JobTests(TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.config = {'agent_id': 'test01', + 'polling_interval': '2', + 'server_address': '127.0.0.1:8000', + 'job_queues': ['test'], + 'execution_basedir': self.tmpdir, + 'logging_basedir': self.tmpdir, + 'results_basedir': os.path.join(self.tmpdir, 'results') + } + testflinger_agent.configure_logging(self.config) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_job_global_timeout(self): + """Test that timeout from job_data is respected""" + timeout_str = '\nERROR: Global timeout reached! (1s)\n' + logfile = os.path.join(self.tmpdir, 'testlog') + client = TestflingerClient(self.config) + fake_job_data = {'global_timeout': 1} + patch('client.post_live_output') + job = TestflingerJob(fake_job_data, client) + job.run_with_log('sleep 3', logfile) + with open(logfile) as log: + log_data = log.read() + self.assertEqual(timeout_str, log_data) + + def test_config_global_timeout(self): + """Test that timeout from device config is preferred""" + timeout_str = '\nERROR: Global timeout reached! (1s)\n' + logfile = os.path.join(self.tmpdir, 'testlog') + self.config['global_timeout'] = 1 + client = TestflingerClient(self.config) + fake_job_data = {'global_timeout': 3} + patch('client.post_live_output') + job = TestflingerJob(fake_job_data, client) + job.run_with_log('sleep 3', logfile) + with open(logfile) as log: + log_data = log.read() + self.assertEqual(timeout_str, log_data) From bec0995aac589ca48272aea8ed4174ed122573be Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 17 May 2017 14:16:04 -0500 Subject: [PATCH 125/569] Retry when polling instead of timing out --- testflinger-cli | 11 +++++++++++ testflinger_cli/__init__.py | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/testflinger-cli b/testflinger-cli index e28a0d5b..3c071f53 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -57,6 +57,9 @@ def status(ctx, job_id): print('Received 404 error from server. Are you sure this ' 'is a testflinger server?') sys.exit(1) + except: + print('Error communicating with server, check connection and retry') + sys.exit(1) print(job_state) @@ -126,6 +129,9 @@ def results(ctx, job_id): print('Received 404 error from server. Are you sure this ' 'is a testflinger server?') sys.exit(1) + except: + print('Error communicating with server, check connection and retry') + sys.exit(1) print(json.dumps(results, sort_keys=True, indent=4)) @@ -149,6 +155,9 @@ def artifacts(ctx, job_id, filename): print('Received 404 error from server. Are you sure this ' 'is a testflinger server?') sys.exit(1) + except: + print('Error communicating with server, check connection and retry') + sys.exit(1) print('Artifacts downloaded to {}'.format(filename)) @@ -178,6 +187,8 @@ def poll(ctx, job_id): if e.status == 204: # We are still waiting for the job to start pass + except: + continue if output: print(output, end='', flush=True) job_state = conn.get_status(job_id) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 97edf276..c1d807fb 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -42,11 +42,11 @@ def get(self, uri_frag, timeout=15): try: req = requests.get(uri, timeout=timeout) except requests.exceptions.ConnectTimeout as e: - print('Timout while trying to communicate with the server.') - sys.exit(1) + print('Timeout while trying to communicate with the server.') + raise except requests.exceptions.ConnectionError as e: print('Unable to communicate with specified server.') - sys.exit(1) + raise if req.status_code != 200: raise HTTPError(req.status_code) return req.text From 72ac21dd139fd9a32bc0a9649fb514216b2144d4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 18 May 2017 09:52:37 -0500 Subject: [PATCH 126/569] use kill() instead of terminate() on timeout --- testflinger_agent/job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 6ac9c5b6..4eb05d22 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -121,7 +121,7 @@ def run_with_log(self, cmd, logfile, cwd=None): global_timeout) live_output_buffer += buf f.write(buf) - process.terminate() + process.kill() break buf = process.stdout.read() if buf: From 83061cf3de09539623cd1a5ccc304e1a9161b689 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 9 Jun 2017 13:34:00 -0500 Subject: [PATCH 127/569] Allow server to be specified with environment variable --- README.rst | 3 +++ testflinger-cli | 3 +++ 2 files changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 4ab1f4bc..58ad977d 100644 --- a/README.rst +++ b/README.rst @@ -36,6 +36,9 @@ After installing testflinger-cli, you can get help by just running To specify a different server to use, you can use the '--server' parameter, otherwise it will default to the one running on http://testflinger.canonical.com +You may also set the environment variable 'TESTFLINGER_SERVER' to +the URI of your server, and it will prefer that over the default +or the string specified by --server. To submit a new test job, first create a yaml or json file containing the job definition. Then run: diff --git a/testflinger-cli b/testflinger-cli index 3c071f53..d51b4f2b 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -36,6 +36,9 @@ if os.path.exists(os.path.join(basedir, 'setup.py')): help='Testflinger server to use') @click.pass_context def cli(ctx, server): + env_server = os.environ.get('TESTFLINGER_SERVER') + if env_server: + server = env_server ctx.obj['conn'] = testflinger_cli.Client(server) From 67eeed39e6b33cae7757c97a7ed4431e7de8d0a9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 13 Jun 2017 13:24:54 -0500 Subject: [PATCH 128/569] Add support for output timeout (default 15 minutes) --- testflinger_agent/job.py | 20 +++++++++++++++++++ testflinger_agent/schema.py | 1 + testflinger_agent/tests/test_job.py | 31 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 4eb05d22..9aa044cc 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -87,6 +87,7 @@ def run_with_log(self, cmd, logfile, cwd=None): returncode from the process """ global_timeout = self.get_global_timeout() + output_timeout = self.get_output_timeout() start_time = time.time() with open(logfile, 'a', encoding='utf-8') as f: live_output_buffer = '' @@ -116,6 +117,14 @@ def run_with_log(self, cmd, logfile, cwd=None): live_output_buffer = '' f.write(buf) f.flush() + else: + if time.time() - buffer_timeout > output_timeout: + buf = ('\nERROR: Output timeout reached! ' + '({}s)\n'.format(output_timeout)) + live_output_buffer += buf + f.write(buf) + process.kill() + break if time.time() - start_time > global_timeout: buf = '\nERROR: Global timeout reached! ({}s)\n'.format( global_timeout) @@ -144,6 +153,17 @@ def get_global_timeout(self): self.job_data.get('global_timeout', default_timeout), self.client.config.get('global_timeout', default_timeout)) + def get_output_timeout(self): + """Get the output timeout for the test run in seconds + """ + # Default timeout is 15 minutes + default_timeout = 15 * 60 + + # Don't exceed the maximum timeout configured for the device! + return min( + self.job_data.get('output_timeout', default_timeout), + self.client.config.get('output_timeout', default_timeout)) + def banner(self, line): """Yield text lines to print a banner around a sting diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index 700a1289..fe94afd1 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -31,6 +31,7 @@ voluptuous.Required('provision_command', default=''): str, voluptuous.Required('test_command', default=''): str, voluptuous.Optional('global_timeout'): int, + voluptuous.Optional('output_timeout'): int, } diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 354f1b46..3751a6f0 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -52,3 +52,34 @@ def test_config_global_timeout(self): with open(logfile) as log: log_data = log.read() self.assertEqual(timeout_str, log_data) + + def test_job_output_timeout(self): + """Test that output timeout from job_data is respected""" + timeout_str = '\nERROR: Output timeout reached! (1s)\n' + logfile = os.path.join(self.tmpdir, 'testlog') + client = TestflingerClient(self.config) + fake_job_data = {'output_timeout': 1} + patch('client.post_live_output') + job = TestflingerJob(fake_job_data, client) + # unfortunately, we need to sleep for longer that 10 seconds here + # or else we fall under the polling time + job.run_with_log('sleep 12', logfile) + with open(logfile) as log: + log_data = log.read() + self.assertEqual(timeout_str, log_data) + + def test_config_output_timeout(self): + """Test that output timeout from device config is preferred""" + timeout_str = '\nERROR: Output timeout reached! (1s)\n' + logfile = os.path.join(self.tmpdir, 'testlog') + self.config['output_timeout'] = 1 + client = TestflingerClient(self.config) + fake_job_data = {'output_timeout': 3} + patch('client.post_live_output') + job = TestflingerJob(fake_job_data, client) + # unfortunately, we need to sleep for longer that 10 seconds here + # or else we fall under the polling time + job.run_with_log('sleep 12', logfile) + with open(logfile) as log: + log_data = log.read() + self.assertEqual(timeout_str, log_data) From abb96aba852715ab419b56a64ac339337564fb99 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 14 Jun 2017 14:17:00 -0500 Subject: [PATCH 129/569] Fix type in output_timeout unit test --- testflinger_agent/tests/test_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 3751a6f0..dee44ba6 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -74,7 +74,7 @@ def test_config_output_timeout(self): logfile = os.path.join(self.tmpdir, 'testlog') self.config['output_timeout'] = 1 client = TestflingerClient(self.config) - fake_job_data = {'output_timeout': 3} + fake_job_data = {'output_timeout': 30} patch('client.post_live_output') job = TestflingerJob(fake_job_data, client) # unfortunately, we need to sleep for longer that 10 seconds here From d37d4a8a34dcfdc8ad51397f75f3cd56b6a280a5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 14 Jun 2017 14:41:48 -0500 Subject: [PATCH 130/569] Only deal with output timeout during the test phase --- testflinger_agent/job.py | 5 ++++- testflinger_agent/tests/test_job.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 9aa044cc..c7d44725 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -35,6 +35,7 @@ def __init__(self, job_data, client): self.client = client self.job_data = job_data self.job_id = job_data.get('job_id') + self.phase = 'unknown' def run_test_phase(self, phase, rundir): """Run the specified test phase in rundir @@ -47,6 +48,7 @@ def run_test_phase(self, phase, rundir): Returncode from the command that was executed, 0 will be returned if there was no command to run """ + self.phase = phase cmd = self.client.config.get(phase+'_command') node = self.client.config.get('agent_id') if not cmd: @@ -118,7 +120,8 @@ def run_with_log(self, cmd, logfile, cwd=None): f.write(buf) f.flush() else: - if time.time() - buffer_timeout > output_timeout: + if (self.phase == 'test' and + time.time() - buffer_timeout > output_timeout): buf = ('\nERROR: Output timeout reached! ' '({}s)\n'.format(output_timeout)) live_output_buffer += buf diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index dee44ba6..c4a143b2 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -34,6 +34,7 @@ def test_job_global_timeout(self): fake_job_data = {'global_timeout': 1} patch('client.post_live_output') job = TestflingerJob(fake_job_data, client) + job.phase = 'test' job.run_with_log('sleep 3', logfile) with open(logfile) as log: log_data = log.read() @@ -48,6 +49,7 @@ def test_config_global_timeout(self): fake_job_data = {'global_timeout': 3} patch('client.post_live_output') job = TestflingerJob(fake_job_data, client) + job.phase = 'test' job.run_with_log('sleep 3', logfile) with open(logfile) as log: log_data = log.read() @@ -61,6 +63,7 @@ def test_job_output_timeout(self): fake_job_data = {'output_timeout': 1} patch('client.post_live_output') job = TestflingerJob(fake_job_data, client) + job.phase = 'test' # unfortunately, we need to sleep for longer that 10 seconds here # or else we fall under the polling time job.run_with_log('sleep 12', logfile) @@ -77,9 +80,26 @@ def test_config_output_timeout(self): fake_job_data = {'output_timeout': 30} patch('client.post_live_output') job = TestflingerJob(fake_job_data, client) + job.phase = 'test' # unfortunately, we need to sleep for longer that 10 seconds here # or else we fall under the polling time job.run_with_log('sleep 12', logfile) with open(logfile) as log: log_data = log.read() self.assertEqual(timeout_str, log_data) + + def test_no_output_timeout_in_provision(self): + """Test that output timeout is ignored when not in test phase""" + timeout_str = 'complete\n' + logfile = os.path.join(self.tmpdir, 'testlog') + client = TestflingerClient(self.config) + fake_job_data = {'output_timeout': 1} + patch('client.post_live_output') + job = TestflingerJob(fake_job_data, client) + job.phase = 'provision' + # unfortunately, we need to sleep for longer that 10 seconds here + # or else we fall under the polling time + job.run_with_log('sleep 12 && echo complete', logfile) + with open(logfile) as log: + log_data = log.read() + self.assertEqual(timeout_str, log_data) From 6f37b0e0a414762d5538b37cc6ffbc6ec4550fda Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 16 Jun 2017 14:23:39 -0500 Subject: [PATCH 131/569] Fix returning status for execution of test_cmds --- snappy_device_agents/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3472d2a2..f7e9b1b3 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -370,9 +370,9 @@ def run_test_cmds(cmds, config=None, env=None): config_env = config.get('env', {}) env.update(config_env) if type(cmds) is list: - _run_test_cmds_list(cmds, config, env) + return _run_test_cmds_list(cmds, config, env) elif type(cmds) is str: - _run_test_cmds_str(cmds, config, env) + return _run_test_cmds_str(cmds, config, env) else: logmsg(logging.ERROR, "test_cmds field must be a list or string") return 1 From 4e7397004276f7ab5cc03f078cd9c51e8afa4a16 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 22 Jun 2017 15:24:56 -0500 Subject: [PATCH 132/569] Add support for a cleanup phase --- README.rst | 12 ++++++++---- testflinger_agent/agent.py | 2 +- testflinger_agent/schema.py | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 7f6ccb52..58a26d80 100644 --- a/README.rst +++ b/README.rst @@ -85,14 +85,18 @@ The following configuration options are supported: - Command to run for the testing phase +- **cleanup_command**: + + - Command to run for the cleanup phase + Usage ----- When running testflinger, your output will be automatically accumulated -for each stage (setup, provision, test) and sent to the testflinger server, -along with an exit status for each stage. If any stage encounters a non-zero -exit code, no further stages will be executed, but the outcome will still -be sent. +for each stage (setup, provision, test, cleanup) and sent to the testflinger +server, along with an exit status for each stage. If any stage encounters a +non-zero exit code, no further stages will be executed, but the outcome will +still be sent. If you have additional artifacts that you would like to save along with the output, you can create a 'artifacts' directory from your test command. diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index b50aeac0..7189631b 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -42,7 +42,7 @@ def mark_device_offline(self): def process_jobs(self): """Coordinate checking for new jobs and handling them if they exists""" - TEST_PHASES = ['setup', 'provision', 'test'] + TEST_PHASES = ['setup', 'provision', 'test', 'cleanup'] # First, see if we have any old results that we couldn't send last time self.retry_old_results() diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index fe94afd1..e5c43373 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -30,6 +30,7 @@ voluptuous.Required('setup_command', default=''): str, voluptuous.Required('provision_command', default=''): str, voluptuous.Required('test_command', default=''): str, + voluptuous.Required('cleanup_command', default=''): str, voluptuous.Optional('global_timeout'): int, voluptuous.Optional('output_timeout'): int, } From 6c1a552069f6ba63b70232f3741dcf267d27ab89 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 29 Jun 2017 08:49:14 -0500 Subject: [PATCH 133/569] Prevent poll from crashing if we get a timeout when trying to read the completion status --- testflinger-cli | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/testflinger-cli b/testflinger-cli index d51b4f2b..4625ac57 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -194,7 +194,12 @@ def poll(ctx, job_id): continue if output: print(output, end='', flush=True) - job_state = conn.get_status(job_id) + try: + job_state = conn.get_status(job_id) + except: + # If something breaks here, just retry so we don't affect + # a running test monitor that relies on poll + continue if job_state == 'complete': break time.sleep(10) From bb3f35afe75880e368fb69631f8123daa9ba9bf7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 29 Jun 2017 16:49:23 -0500 Subject: [PATCH 134/569] Eliminate some more cases where poll can even fail to get the job_state --- testflinger-cli | 49 ++++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/testflinger-cli b/testflinger-cli index 4625ac57..181fc064 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -169,20 +169,10 @@ def artifacts(ctx, job_id, filename): @click.pass_context def poll(ctx, job_id): conn = ctx.obj['conn'] - try: - job_state = conn.get_status(job_id) - except testflinger_cli.HTTPError as e: - if e.status == 204: - print('No data found for that job id. Check the job id to be sure ' - 'it is correct') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') - if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) - while True: + job_state = get_job_state(conn, job_id) + while job_state != 'complete': + print('sleeping, job state was {}'.format(job_state)) + time.sleep(10) output = '' try: output = conn.get_output(job_id) @@ -194,17 +184,30 @@ def poll(ctx, job_id): continue if output: print(output, end='', flush=True) - try: - job_state = conn.get_status(job_id) - except: - # If something breaks here, just retry so we don't affect - # a running test monitor that relies on poll - continue - if job_state == 'complete': - break - time.sleep(10) + job_state = get_job_state(conn, job_id) print(job_state) +def get_job_state(conn, job_id): + try: + return conn.get_status(job_id) + except testflinger_cli.HTTPError as e: + if e.status == 204: + print('No data found for that job id. Check the job id to be sure ' + 'it is correct') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + except: + # If we fail to get the job_state here, it could be because of timeout + # but we can keep going and retrying + pass + return 'unknown' + + if __name__ == '__main__': cli(obj={}) From 9d304974bf89a134f540c5d4e91984392510bce1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 14 Jul 2017 14:10:13 -0500 Subject: [PATCH 135/569] Display a message warning the end user when polling that there will be no further output while the job is in waiting state --- testflinger-cli | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testflinger-cli b/testflinger-cli index 181fc064..c13361f6 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -170,8 +170,10 @@ def artifacts(ctx, job_id, filename): def poll(ctx, job_id): conn = ctx.obj['conn'] job_state = get_job_state(conn, job_id) + if job_state == 'waiting': + print('This job is currently waiting on a node to become available. ' + 'You will see no further output until the job starts running.') while job_state != 'complete': - print('sleeping, job state was {}'.format(job_state)) time.sleep(10) output = '' try: From e663ff1d41e6c3d482abac9c8060cdc28bd7cc58 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 20 Sep 2017 13:54:42 -0500 Subject: [PATCH 136/569] If test_cmds doesn't specify an interpreter, pick a safe default --- snappy_device_agents/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index f7e9b1b3..661aba67 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -425,6 +425,11 @@ def _run_test_cmds_str(cmds, config=None, env=None): Return the value of the return code from the script, or 20 if there was an error formatting the script """ + + # If cmds doesn't specify an interpreter, pick a safe default + if not cmds.startswith('#!'): + cmds = "#!/bin/bash\n" + cmds + try: cmds = cmds.format(**config) except KeyError as e: From 3ecbb3cf8f31b53c58c40852b8acc42dfe0714a1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sat, 30 Sep 2017 21:26:20 -0500 Subject: [PATCH 137/569] Add device support for cm3 --- devices/cm3/__init__.py | 85 ++++++++++++++++++++++++++ devices/cm3/cm3.py | 131 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 devices/cm3/__init__.py create mode 100644 devices/cm3/cm3.py diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py new file mode 100644 index 00000000..350ba6b2 --- /dev/null +++ b/devices/cm3/__init__.py @@ -0,0 +1,85 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu Raspberry PI CM3 support code.""" + +import logging +import yaml + +import guacamole + +import snappy_device_agents +from devices.cm3.cm3 import CM3 +from snappy_device_agents import logmsg, run_test_cmds +from devices import (Catch, RecoveryError) + +device_name = "cm3" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + @Catch(RecoveryError, 46) + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = CM3(ctx.args.config, ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = run_test_cmds(test_cmds, config) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Ubuntu Raspberry PI cm3""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py new file mode 100644 index 00000000..c490d6f6 --- /dev/null +++ b/devices/cm3/cm3.py @@ -0,0 +1,131 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu Raspberry PI cm3 support code.""" + +import json +import logging +import subprocess +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) + +logger = logging.getLogger() + + +class CM3: + + """Device Agent for CM3.""" + + def __init__(self, config, job_data): + with open(config) as configfile: + self.config = yaml.load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) + + def _run_control(self, cmd, timeout=60): + """ + Run a command on the control host over ssh + + :param cmd: + Command to run + :param timeout: + Timeout (default 60) + :returns: + Return output from the command, if any + """ + control_host = self.config.get('control_host') + control_user = self.config.get('control_user', 'ubuntu') + ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(control_user, control_host), + cmd] + try: + output = subprocess.check_output( + ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout) + except subprocess.CalledProcessError as e: + raise ProvisioningError(e.output) + return output + + def provision(self): + try: + url = self.job_data['provision_data']['url'] + except KeyError: + raise ProvisioningError('You must specify a "url" value in ' + 'the "provision_data" section of ' + 'your job_data') + self._run_control('sudo pi3gpio set high 16') + time.sleep(5) + self.hardreset() + logger.info('Flashing image') + out = self._run_control('sudo cm3-installer {}'.format(url), + timeout=900) + logger.info(out) + self._run_control('sudo sync') + time.sleep(5) + out = self._run_control('sudo udisksctl power-off -b /dev/sda ') + logger.info(out) + time.sleep(5) + self._run_control('sudo pi3gpio set low 16') + time.sleep(5) + self.hardreset() + if self.check_test_image_booted(): + return + agent_name = self.config.get('agent_name') + logger.error('Device %s unreachable after provisioning, deployment ' + 'failed!', agent_name) + raise ProvisioningError("Provisioning failed!") + + def check_test_image_booted(self): + logger.info("Checking if test image booted.") + started = time.time() + # Retry for a while since we might still be rebooting + test_username = self.job_data.get( + 'test_data').get('test_username', 'ubuntu') + test_password = self.job_data.get( + 'test_data').get('test_password', 'ubuntu') + while time.time() - started < 300: + try: + time.sleep(10) + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip'])] + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + return True + except: + pass + # If we get here, then we didn't boot in time + raise ProvisioningError("Failed to boot test image!") + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config['reboot_script']: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise RecoveryError("timeout reaching control host!") From 4b19cca3477ef56aca49ad4fa4fcbeca53db5fcd Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 Oct 2017 14:08:23 -0500 Subject: [PATCH 138/569] Add support for user_data strings in maas2 provision_data --- devices/maas2/maas2.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index ed5dc90e..9275d0fe 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -14,6 +14,7 @@ """Ubuntu MaaS 2.x CLI support code.""" +import base64 import json import logging import subprocess @@ -57,6 +58,11 @@ def provision(self): 'Starting node %s with distro %s', agent_name, distro) cmd = ['maas', maas_user, 'machine', 'deploy', node_id, 'distro_series={}'.format(distro)] + print(self.job_data) + user_data = provision_data.get('user_data') + if user_data: + data = base64.b64encode(user_data.encode()).decode() + cmd.append('user_data={}'.format(data)) output = subprocess.check_output(cmd) # Make sure the device is available before returning for timeout in range(0, 10): From d94a481cfdae45921c316a9cff2b476e757f725f Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Wed, 11 Oct 2017 20:32:41 +0800 Subject: [PATCH 139/569] Check the booted status with another command for desktops --- devices/maas2/maas2.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index ed5dc90e..d1d8b63a 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -72,10 +72,18 @@ def provision(self): def check_test_image_booted(self): logger.info("Checking if test image booted.") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'snap -h'] + provision_data = self.job_data.get('provision_data') + distro = provision_data.get('distro', 'xenial') + if 'desktop' in distro: + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'lsb_release -a'] + else: + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'snap -h'] try: subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) From 1c57e6eaaac82408b22cd398551148872b84b04d Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Thu, 12 Oct 2017 17:14:52 +0800 Subject: [PATCH 140/569] Use true command to be a more universal check command --- devices/maas2/maas2.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index d1d8b63a..24937415 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -72,18 +72,10 @@ def provision(self): def check_test_image_booted(self): logger.info("Checking if test image booted.") - provision_data = self.job_data.get('provision_data') - distro = provision_data.get('distro', 'xenial') - if 'desktop' in distro: - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'lsb_release -a'] - else: - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'snap -h'] + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + '/bin/true'] try: subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) From edfa75454723857130cd9debbcb3d8bbb5f02653 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 24 Oct 2017 11:00:03 -0500 Subject: [PATCH 141/569] Also send resubmit message to live output --- testflinger_agent/client.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index cd400507..2aba1484 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -61,7 +61,13 @@ def repost_job(self, job_data): id for the job on which we want to post results """ job_uri = urljoin(self.config.get('server'), '/v1/job') - logger.info('Resubmitting job for job: %s' % job_data.get('job_id')) + job_id = job_data.get('job_id') + logger.info('Resubmitting job: %s', job_id) + job_output = """ + There was an unrecoverable error while running this stage. Your job + has been automatically resubmitted back to the queue. + Resubmitting job: {}\n""".format(job_id) + self.post_live_output(job_id, job_output) job_request = requests.post(job_uri, json=job_data) if job_request.status_code != 200: logger.error('Unable to re-post job to: %s (error: %s)' % From 3731d7637b7b23d05ff423eeef4e7d31c4748f40 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 30 Oct 2017 16:31:16 -0500 Subject: [PATCH 142/569] Dragonboard device support enhancements - Dragonboard can now take 'url' in provisioning data to use an official image. - Improved reliability by wiping the test_device if anything fails during provisio ning --- devices/dragonboard/dragonboard.py | 166 +++++++++++++++++++---------- 1 file changed, 111 insertions(+), 55 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index f78ee03a..b657d4f4 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -17,6 +17,7 @@ import json import logging import multiprocessing +import os import subprocess import time import yaml @@ -38,6 +39,28 @@ def __init__(self, config, job_data): with open(job_data) as j: self.job_data = json.load(j) + def _run_control(self, cmd, timeout=60): + """ + Run a command on the control host over ssh + + :param cmd: + Command to run + :param timeout: + Timeout (default 60) + :returns: + Return output from the command, if any + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'linaro@{}'.format(self.config['device_ip']), + cmd] + try: + output = subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=timeout) + except subprocess.CalledProcessError as e: + raise ProvisioningError(e.output) + return output + def setboot(self, mode): """ Set the boot mode of the device. @@ -93,12 +116,8 @@ def ensure_test_image(self, test_username, test_password): """ logger.info("Booting the test image") self.setboot('test') - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), - 'sudo /sbin/reboot'] try: - subprocess.check_call(cmd) + self._run_control('sudo /sbin/reboot') except: pass time.sleep(60) @@ -157,15 +176,10 @@ def is_master_image_booted(self): .. note:: The master image is used for writing a new image to local media """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), - 'cat /etc/issue'] # FIXME: come up with a better way of checking this logger.info("Checking if master image booted.") try: - output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) + output = self._run_control('cat /etc/issue') except: logger.info("Error checking device state. Forcing reboot...") return False @@ -232,60 +246,83 @@ def flash_test_image(self, server_ip, server_port): If the command times out or anything else fails. """ # First unmount, just in case - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), - 'sudo umount {}*'.format(self.config['test_device'])] try: - subprocess.check_call(cmd, timeout=30) - except subprocess.CalledProcessError: + self._run_control( + 'sudo umount {}*'.format(self.config['test_device']), + timeout=30) + except ProvisioningError: # We might not be mounted, so expect this to fail sometimes pass - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), - 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( - server_ip, server_port, self.config['test_device'])] + cmd = 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( + server_ip, server_port, self.config['test_device']) logger.info("Running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! - subprocess.check_call(cmd, timeout=1800) + self._run_control(cmd, timeout=1800) except: raise ProvisioningError("timeout reached while flashing image!") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), 'sync'] try: - subprocess.check_call(cmd, timeout=30) + self._run_control('sync') except: # Nothing should go wrong here, but let's sleep if it does logger.warn("Something went wrong with the sync, sleeping...") time.sleep(30) - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), - 'sudo hdparm -z {}'.format(self.config['test_device'])] try: - subprocess.check_call(cmd, timeout=30) + self._run_control( + 'sudo hdparm -z {}'.format(self.config['test_device']), + timeout=30) except: raise ProvisioningError("Unable to run hdparm to rescan " "partitions") - def write_system_user_file(self): - """Write the system-user assertion to the writable area""" + def mount_writable_partition(self): # Mount the writable partition - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), - 'sudo mount {} /mnt'.format( - self.config['snappy_writable_partition'])] try: - subprocess.check_call(cmd, timeout=60) + self._run_control('sudo mount {} /mnt'.format( + self.config['snappy_writable_partition'])) except: err = ("Error mounting writable partition on test image {}. " "Check device configuration".format( self.config['snappy_writable_partition'])) raise ProvisioningError(err) + + def create_extrausers(self): + """Create extrauser account for default ubuntu user""" + self.mount_writable_partition() + try: + self._run_control('sudo mkdir -p /mnt/user-data/ubuntu') + self._run_control('sudo chown 1000.1000 /mnt/user-data/ubuntu') + except: + raise ProvisioningError("Error creating user home dir") + try: + self._run_control('sudo mkdir -p /mnt/system-data/var/lib/') + except: + raise ProvisioningError("Error creating dir for user files") + userdata_path = os.path.normpath( + os.path.join(os.path.dirname(os.path.realpath(__file__)), + '..', '..', 'data', 'extrausers')) + cmd = ['scp', '-r', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', userdata_path, + 'linaro@{}:/tmp/'.format( + self.config['device_ip'])] + try: + subprocess.check_call(cmd, timeout=60) + self._run_control( + 'sudo cp -a /tmp/extrausers /mnt/system-data/var/lib/') + except: + raise ProvisioningError("Error writing user files") + + def setup_sudo(self): + sudo_data = 'ubuntu ALL=(ALL) NOPASSWD:ALL' + sudo_path = '/mnt/system-data/etc/sudoers.d/ubuntu' + self._run_control( + 'sudo mkdir -p {}'.format(os.path.dirname(sudo_path))) + self._run_control( + 'sudo bash -c "echo \'{}\' > {}"'.format(sudo_data, sudo_path)) + + def write_system_user_file(self): + """Write the system-user assertion to the writable area""" + self.mount_writable_partition() # Copy the system-user assertion to the device cmd = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', @@ -296,15 +333,28 @@ def write_system_user_file(self): subprocess.check_call(cmd, timeout=60) except: raise ProvisioningError("Error writing system-user assertion") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), - 'sudo cp /tmp/auto-import.assert /mnt'] try: - subprocess.check_call(cmd, timeout=60) + self._run_control('sudo cp /tmp/auto-import.assert /mnt') except: raise ProvisioningError("Error copying system-user assertion") + def wipe_test_device(self): + """Safety check - wipe the test drive if things go wrong + + This way if we reboot the sytem after a failed provision, it goes + back to the control boot image which we could use to provision + something else. + """ + try: + test_device = self.config['test_device'] + logger.error("Failed to write image, cleaning up...") + self._run_control( + 'sudo sgdisk -o {}'.format(test_device)) + except: + # This is an attempt to salvage a bad run, further tracebacks + # would just add to the noise + pass + def provision(self): """Provision the device""" url = self.job_data['provision_data'].get('url') @@ -338,14 +388,20 @@ def provision(self): file_server.start() server_port = serve_q.get() logger.info("Flashing Test Image") - self.flash_test_image(server_ip, server_port) - file_server.terminate() - if not url: - # If we didn't specify the url, we need to do this - # XXX: This is one of those cases where we hope the user did - # the right thing and included the assertion in the image! - logger.info("Creating Test User") - self.write_system_user_file() - logger.info("Booting Test Image") - self.ensure_test_image(test_username, test_password) + try: + self.flash_test_image(server_ip, server_port) + file_server.terminate() + if url: + self.create_extrausers() + self.setup_sudo() + else: + # If we didn't specify the url, we need to do this + logger.info("Creating Test User") + self.write_system_user_file() + logger.info("Booting Test Image") + self.ensure_test_image(test_username, test_password) + except: + # wipe out whatever we installed if things go badly + self.wipe_test_device() + raise logger.info("END provision") From 83d66c948c1a4d2ba8c09256c238cbdc52e2fd4c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 1 Nov 2017 21:40:16 -0500 Subject: [PATCH 143/569] Add extrausers data to create ubuntu/ubuntu account --- data/extrausers/group | 1 + data/extrausers/gshadow | 1 + data/extrausers/passwd | 1 + data/extrausers/shadow | 1 + data/extrausers/subgid | 1 + data/extrausers/subuid | 1 + 6 files changed, 6 insertions(+) create mode 100644 data/extrausers/group create mode 100644 data/extrausers/gshadow create mode 100644 data/extrausers/passwd create mode 100644 data/extrausers/shadow create mode 100644 data/extrausers/subgid create mode 100644 data/extrausers/subuid diff --git a/data/extrausers/group b/data/extrausers/group new file mode 100644 index 00000000..790c1873 --- /dev/null +++ b/data/extrausers/group @@ -0,0 +1 @@ +ubuntu:x:1000: diff --git a/data/extrausers/gshadow b/data/extrausers/gshadow new file mode 100644 index 00000000..9f0ddb53 --- /dev/null +++ b/data/extrausers/gshadow @@ -0,0 +1 @@ +ubuntu:!:: diff --git a/data/extrausers/passwd b/data/extrausers/passwd new file mode 100644 index 00000000..5cf2d2f7 --- /dev/null +++ b/data/extrausers/passwd @@ -0,0 +1 @@ +ubuntu:x:1000:1000:,,,:/home/ubuntu:/bin/bash diff --git a/data/extrausers/shadow b/data/extrausers/shadow new file mode 100644 index 00000000..b598e79d --- /dev/null +++ b/data/extrausers/shadow @@ -0,0 +1 @@ +ubuntu:$6$EAwWeC6X$6ENp0R4rM9SG3MWbc1x2kSWYOjuiy9Se1IMp2//FCVBV20hYSE2b7tPr7klemaLZZ3W7QJ4KRZ3C3dZ6I0Zx50:17303:0:99999:7::: diff --git a/data/extrausers/subgid b/data/extrausers/subgid new file mode 100644 index 00000000..fcd35d50 --- /dev/null +++ b/data/extrausers/subgid @@ -0,0 +1 @@ +ubuntu:100000:65536 diff --git a/data/extrausers/subuid b/data/extrausers/subuid new file mode 100644 index 00000000..fcd35d50 --- /dev/null +++ b/data/extrausers/subuid @@ -0,0 +1 @@ +ubuntu:100000:65536 From 7d69129f65acd2ff945057d72c29bf021c1df8ad Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Nov 2017 08:26:27 -0600 Subject: [PATCH 144/569] Include the data files when installing --- setup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e9e03b42..0ff475bc 100755 --- a/setup.py +++ b/setup.py @@ -13,17 +13,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import os import sys -assert sys.version_info >= (3,), 'Python 3 is required' from setuptools import ( find_packages, setup, ) +assert sys.version_info >= (3,), 'Python 3 is required' VERSION = '0.0.1' +datafiles = [(d, [os.path.join(d, f) for f in files]) + for d, folders, files in os.walk('data')] setup( name='snappy-device-agents', @@ -35,6 +38,7 @@ url='https://launchpad.net/snappy-device-agents', license='GPLv3', packages=find_packages(), + data_files=datafiles, install_requires=['guacamole >= 0.9', 'PyYAML>=3.11', 'netifaces>=0.10.4'], scripts=['snappy-device-agent'], From 723a7f49260e590b4647902c12cc28e870074b2d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Nov 2017 11:26:04 -0600 Subject: [PATCH 145/569] No more system-user assertions, only use extrausers in all modes --- devices/dragonboard/dragonboard.py | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index b657d4f4..e2782bb2 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -320,24 +320,6 @@ def setup_sudo(self): self._run_control( 'sudo bash -c "echo \'{}\' > {}"'.format(sudo_data, sudo_path)) - def write_system_user_file(self): - """Write the system-user assertion to the writable area""" - self.mount_writable_partition() - # Copy the system-user assertion to the device - cmd = ['scp', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - self.config['user_assertion'], - 'linaro@{}:/tmp/auto-import.assert'.format( - self.config['device_ip'])] - try: - subprocess.check_call(cmd, timeout=60) - except: - raise ProvisioningError("Error writing system-user assertion") - try: - self._run_control('sudo cp /tmp/auto-import.assert /mnt') - except: - raise ProvisioningError("Error copying system-user assertion") - def wipe_test_device(self): """Safety check - wipe the test drive if things go wrong @@ -391,13 +373,9 @@ def provision(self): try: self.flash_test_image(server_ip, server_port) file_server.terminate() - if url: - self.create_extrausers() - self.setup_sudo() - else: - # If we didn't specify the url, we need to do this - logger.info("Creating Test User") - self.write_system_user_file() + logger.info("Creating Test User") + self.create_extrausers() + self.setup_sudo() logger.info("Booting Test Image") self.ensure_test_image(test_username, test_password) except: From 72ee2d1f94b85bad97a4ffd45c7afbb56aadd598 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 27 Nov 2017 08:24:32 -0600 Subject: [PATCH 146/569] Automatically skip phases for which there is no data in the job definition --- testflinger_agent/job.py | 6 ++++- testflinger_agent/tests/test_agent.py | 20 ++++++++++----- testflinger_agent/tests/test_job.py | 37 +++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index c7d44725..9ede53a2 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -52,9 +52,13 @@ def run_test_phase(self, phase, rundir): cmd = self.client.config.get(phase+'_command') node = self.client.config.get('agent_id') if not cmd: + logger.info('No %s_command configured, skipping...', phase) + return 0 + if '{}_data'.format(phase) not in self.job_data: + logger.info('No %s_data defined in job data, skipping...', phase) return 0 phase_log = os.path.join(rundir, phase+'.log') - logger.info('Running %s_command: %s' % (phase, cmd)) + logger.info('Running %s_command: %s', phase, cmd) # Set the exitcode to some failed status in case we get interrupted exitcode = 99 for line in self.banner('Starting {} phase on {}'.format(phase, node)): diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index f255dd5d..742499c7 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -42,7 +42,8 @@ def test_check_and_run_setup(self, mock_requests_get, mock_requests_post, self.config['setup_command'] = 'echo setup1' agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} + 'job_queue': 'test', + 'setup_data': ''} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() terminator = requests.Response() @@ -66,7 +67,8 @@ def test_check_and_run_provision(self, mock_requests_get, self.config['provision_command'] = 'echo provision1' agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} + 'job_queue': 'test', + 'provision_data': ''} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() terminator = requests.Response() @@ -90,7 +92,8 @@ def test_check_and_run_test(self, mock_requests_get, mock_requests_post, self.config['test_command'] = 'echo test1' agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} + 'job_queue': 'test', + 'test_data': ''} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() terminator = requests.Response() @@ -117,7 +120,9 @@ def test_phase_failed(self, mock_requests_get, mock_requests_post, self.config['test_command'] = 'echo test1' agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} + 'job_queue': 'test', + 'provision_data': '', + 'test_data': ''} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() terminator = requests.Response() @@ -184,7 +189,8 @@ def test_post_artifact(self, mock_requests_get, 'echo test1 > artifacts/t') agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} + 'job_queue': 'test', + 'test_data': ''} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() terminator = requests.Response() @@ -211,7 +217,9 @@ def test_recovery_failed(self, mock_requests_get, mock_requests_post, self.config['test_command'] = 'echo test1' agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} + 'job_queue': 'test', + 'provision_data': '', + 'test_data': ''} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() terminator = requests.Response() diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index c4a143b2..b0c1d79a 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -26,6 +26,43 @@ def setUp(self): def tearDown(self): shutil.rmtree(self.tmpdir) + def test_skip_missing_setup_data(self): + """Test that setup phase is skipped when setup_data is absent""" + self.config['setup_command'] = '/bin/true' + client = TestflingerClient(self.config) + fake_job_data = {'global_timeout': 1} + job = TestflingerJob(fake_job_data, client) + job.run_test_phase('setup', None) + logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') + with open(logfile) as log: + log_output = log.read() + self.assertIn("No setup_data defined in job data", log_output) + + def test_skip_missing_provision_data(self): + """Test that provision phase is skipped when provision_data is absent + """ + self.config['provision_command'] = '/bin/true' + client = TestflingerClient(self.config) + fake_job_data = {'global_timeout': 1} + job = TestflingerJob(fake_job_data, client) + job.run_test_phase('provision', None) + logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') + with open(logfile) as log: + log_output = log.read() + self.assertIn("No provision_data defined in job data", log_output) + + def test_skip_missing_test_data(self): + """Test that test phase is skipped when test_data is absent""" + self.config['test_command'] = '/bin/true' + client = TestflingerClient(self.config) + fake_job_data = {'global_timeout': 1} + job = TestflingerJob(fake_job_data, client) + job.run_test_phase('test', None) + logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') + with open(logfile) as log: + log_output = log.read() + self.assertIn("No test_data defined in job data", log_output) + def test_job_global_timeout(self): """Test that timeout from job_data is respected""" timeout_str = '\nERROR: Global timeout reached! (1s)\n' From eb9be19d543b28c60ab68904699fd7fd87fe1ea5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 28 Nov 2017 02:12:50 -0600 Subject: [PATCH 147/569] Eliminate errors when running the unittests coming from python requests --- testflinger_agent/tests/test_job.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index b0c1d79a..375c0a91 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -2,7 +2,7 @@ import shutil import tempfile -from mock import patch +from mock import MagicMock from unittest import TestCase import testflinger_agent @@ -69,7 +69,7 @@ def test_job_global_timeout(self): logfile = os.path.join(self.tmpdir, 'testlog') client = TestflingerClient(self.config) fake_job_data = {'global_timeout': 1} - patch('client.post_live_output') + client.post_live_output = MagicMock() job = TestflingerJob(fake_job_data, client) job.phase = 'test' job.run_with_log('sleep 3', logfile) @@ -84,7 +84,7 @@ def test_config_global_timeout(self): self.config['global_timeout'] = 1 client = TestflingerClient(self.config) fake_job_data = {'global_timeout': 3} - patch('client.post_live_output') + client.post_live_output = MagicMock() job = TestflingerJob(fake_job_data, client) job.phase = 'test' job.run_with_log('sleep 3', logfile) @@ -98,7 +98,7 @@ def test_job_output_timeout(self): logfile = os.path.join(self.tmpdir, 'testlog') client = TestflingerClient(self.config) fake_job_data = {'output_timeout': 1} - patch('client.post_live_output') + client.post_live_output = MagicMock() job = TestflingerJob(fake_job_data, client) job.phase = 'test' # unfortunately, we need to sleep for longer that 10 seconds here @@ -115,7 +115,7 @@ def test_config_output_timeout(self): self.config['output_timeout'] = 1 client = TestflingerClient(self.config) fake_job_data = {'output_timeout': 30} - patch('client.post_live_output') + client.post_live_output = MagicMock() job = TestflingerJob(fake_job_data, client) job.phase = 'test' # unfortunately, we need to sleep for longer that 10 seconds here @@ -131,7 +131,7 @@ def test_no_output_timeout_in_provision(self): logfile = os.path.join(self.tmpdir, 'testlog') client = TestflingerClient(self.config) fake_job_data = {'output_timeout': 1} - patch('client.post_live_output') + client.post_live_output = MagicMock() job = TestflingerJob(fake_job_data, client) job.phase = 'provision' # unfortunately, we need to sleep for longer that 10 seconds here From 9e28c041758a23af424a4400926a06942d677024 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 29 Nov 2017 01:52:49 -0600 Subject: [PATCH 148/569] Restrict test phase skipping to the provision phase --- testflinger_agent/job.py | 4 ++-- testflinger_agent/tests/test_agent.py | 6 ++---- testflinger_agent/tests/test_job.py | 24 ------------------------ 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 9ede53a2..a710b413 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -54,8 +54,8 @@ def run_test_phase(self, phase, rundir): if not cmd: logger.info('No %s_command configured, skipping...', phase) return 0 - if '{}_data'.format(phase) not in self.job_data: - logger.info('No %s_data defined in job data, skipping...', phase) + if phase == 'provision' and 'provision_data' not in self.job_data: + logger.info('No provision_data defined in job data, skipping...') return 0 phase_log = os.path.join(rundir, phase+'.log') logger.info('Running %s_command: %s', phase, cmd) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 742499c7..e62550ef 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -42,8 +42,7 @@ def test_check_and_run_setup(self, mock_requests_get, mock_requests_post, self.config['setup_command'] = 'echo setup1' agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test', - 'setup_data': ''} + 'job_queue': 'test'} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() terminator = requests.Response() @@ -92,8 +91,7 @@ def test_check_and_run_test(self, mock_requests_get, mock_requests_post, self.config['test_command'] = 'echo test1' agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test', - 'test_data': ''} + 'job_queue': 'test'} fake_response = requests.Response() fake_response._content = json.dumps(fake_job_data).encode() terminator = requests.Response() diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index b0c1d79a..7b5a3f43 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -26,18 +26,6 @@ def setUp(self): def tearDown(self): shutil.rmtree(self.tmpdir) - def test_skip_missing_setup_data(self): - """Test that setup phase is skipped when setup_data is absent""" - self.config['setup_command'] = '/bin/true' - client = TestflingerClient(self.config) - fake_job_data = {'global_timeout': 1} - job = TestflingerJob(fake_job_data, client) - job.run_test_phase('setup', None) - logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') - with open(logfile) as log: - log_output = log.read() - self.assertIn("No setup_data defined in job data", log_output) - def test_skip_missing_provision_data(self): """Test that provision phase is skipped when provision_data is absent """ @@ -51,18 +39,6 @@ def test_skip_missing_provision_data(self): log_output = log.read() self.assertIn("No provision_data defined in job data", log_output) - def test_skip_missing_test_data(self): - """Test that test phase is skipped when test_data is absent""" - self.config['test_command'] = '/bin/true' - client = TestflingerClient(self.config) - fake_job_data = {'global_timeout': 1} - job = TestflingerJob(fake_job_data, client) - job.run_test_phase('test', None) - logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') - with open(logfile) as log: - log_output = log.read() - self.assertIn("No test_data defined in job data", log_output) - def test_job_global_timeout(self): """Test that timeout from job_data is respected""" timeout_str = '\nERROR: Global timeout reached! (1s)\n' From 1805fb4cb049024be30803d1ed354281936bede5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 7 Dec 2017 11:57:57 -0600 Subject: [PATCH 149/569] Add a timeout to requests.get() in check_jobs() to prevent agents from getting stuck --- testflinger_agent/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 2aba1484..1d634900 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -44,7 +44,8 @@ def check_jobs(self): job_uri = urljoin(self.config.get('server'), '/v1/job') queue_list = self.config.get('job_queues') logger.debug("Requesting a job") - job_request = requests.get(job_uri, params={'queue': queue_list}) + job_request = requests.get(job_uri, params={'queue': queue_list}, + timeout=10) if job_request.content: return job_request.json() else: From a01cf846add000bb942748d604a79fdb783977bf Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sat, 3 Feb 2018 07:22:53 -0600 Subject: [PATCH 150/569] Add a oem recovery device agent --- devices/oemrecovery/__init__.py | 85 ++++++++++++++++++ devices/oemrecovery/oemrecovery.py | 137 +++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 devices/oemrecovery/__init__.py create mode 100644 devices/oemrecovery/oemrecovery.py diff --git a/devices/oemrecovery/__init__.py b/devices/oemrecovery/__init__.py new file mode 100644 index 00000000..23d54a55 --- /dev/null +++ b/devices/oemrecovery/__init__.py @@ -0,0 +1,85 @@ +# Copyright (C) 2018 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu OEM Recovery provisioner support code.""" + +import logging +import yaml + +import guacamole + +import snappy_device_agents +from devices.oemrecovery.oemrecovery import OemRecovery +from snappy_device_agents import logmsg, run_test_cmds +from devices import (Catch, RecoveryError) + +device_name = "oemrecovery" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + @Catch(RecoveryError, 46) + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = OemRecovery(ctx.args.config, ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = run_test_cmds(test_cmds, config) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for OEM Recovery""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py new file mode 100644 index 00000000..b01ea636 --- /dev/null +++ b/devices/oemrecovery/oemrecovery.py @@ -0,0 +1,137 @@ +# Copyright (C) 2018 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu OEM Recovery Provisioner support code.""" + +import json +import logging +import subprocess +import time +import yaml + +from devices import (ProvisioningError, + RecoveryError) +from snappy_device_agents import TimeoutError + +logger = logging.getLogger() + + +class OemRecovery: + + """Device Agent for OEM Recovery.""" + + def __init__(self, config, job_data): + with open(config) as configfile: + self.config = yaml.load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) + + def _run_device(self, cmd, timeout=60): + """ + Run a command on the control host over ssh + + :param cmd: + Command to run + :param timeout: + Timeout (default 60) + :returns: + Return output from the command, if any + """ + device_ip = self.config.get('device_ip') + ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}'.format(device_ip), cmd] + try: + output = subprocess.check_output( + ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout) + except subprocess.CalledProcessError as e: + raise ProvisioningError(e.output) + return output + + def provision(self): + """Provision the device""" + + # First, ensure the device is online and reachable + try: + self.copy_ssh_id() + except subprocess.CalledProcessError: + self.hardreset() + self.check_device_booted() + + logger.info('Recovering OEM image') + recovery_cmds = self.config.get('recovery_cmds') + self._run_cmd_list(recovery_cmds) + self.check_device_booted() + + def copy_ssh_id(self): + test_password = self.job_data.get( + 'test_data').get('test_password', 'ubuntu') + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + self.config['device_ip']] + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + + def check_device_booted(self): + logger.info("Checking to see if the device is available.") + started = time.time() + # Wait for provisioning to complete - can take a very long time + while time.time() - started < 3600: + try: + time.sleep(10) + self.copy_ssh_id() + return True + except Exception: + pass + # If we get here, then we didn't boot in time + agent_name = self.config.get('agent_name') + logger.error('Device %s unreachable, provisioning' + 'failed!', agent_name) + raise ProvisioningError("Failed to boot test image!") + + def _run_cmd_list(self, cmdlist): + """ + Run a list of commands + + :param cmdlist: + List of commands to run + """ + if not cmdlist: + return + for cmd in cmdlist: + logger.info("Running %s", cmd) + try: + output = self._run_device(cmd, timeout=60) + except TimeoutError: + raise ProvisioningError("timeout reaching control host!") + logger.info(output) + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config['reboot_script']: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except Exception: + raise RecoveryError("timeout reaching control host!") From 25b6dc19ec8a39bf8021cd4ec980a7b4ed844cdb Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 22 Feb 2018 18:05:38 -0600 Subject: [PATCH 151/569] Be less aggressive about checking for the system to be up on oemrecovery --- devices/oemrecovery/oemrecovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index b01ea636..b08f28bd 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -90,7 +90,7 @@ def check_device_booted(self): # Wait for provisioning to complete - can take a very long time while time.time() - started < 3600: try: - time.sleep(10) + time.sleep(90) self.copy_ssh_id() return True except Exception: From 74c3679ab6f604564d372af60fffe0a9d992feb2 Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Thu, 1 Mar 2018 10:41:35 +0800 Subject: [PATCH 152/569] Extend SSH timeout time for OEM Trusty-4.4 --- devices/oemrecovery/oemrecovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index b08f28bd..16e5c818 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -113,7 +113,7 @@ def _run_cmd_list(self, cmdlist): for cmd in cmdlist: logger.info("Running %s", cmd) try: - output = self._run_device(cmd, timeout=60) + output = self._run_device(cmd, timeout=90) except TimeoutError: raise ProvisioningError("timeout reaching control host!") logger.info(output) From 5dbcdf44b7a3a71247ac6c1751812e5f480d8a0d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Apr 2018 14:16:40 -0500 Subject: [PATCH 153/569] If we can't decode with utf-8, replace the bad character --- snappy_device_agents/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 661aba67..3699a141 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -344,10 +344,10 @@ def runcmd(cmd, env=None, timeout=None): raise TimeoutError line = process.stdout.readline() if line: - sys.stdout.write(line.decode()) + sys.stdout.write(line.decode(errors='replace')) line = process.stdout.read() if line: - sys.stdout.write(line.decode()) + sys.stdout.write(line.decode(errors='replace')) return process.returncode From ee9715aa74eefd543ce0dcf27c281c09dd8066fb Mon Sep 17 00:00:00 2001 From: Gavin Lin Date: Mon, 14 May 2018 19:10:53 +0800 Subject: [PATCH 154/569] Get username from job configuration for oemrecovery agent --- devices/oemrecovery/oemrecovery.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 16e5c818..1d7e883c 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -49,9 +49,15 @@ def _run_device(self, cmd, timeout=60): Return output from the command, if any """ device_ip = self.config.get('device_ip') + try: + test_username = self.job_data.get( + 'test_data').get('test_username', 'ubuntu') + except: + test_username = 'ubuntu' ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', - '{}'.format(device_ip), cmd] + '{}@{}'.format(test_username, self.config['device_ip']), + cmd] try: output = subprocess.check_output( ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout) @@ -75,12 +81,18 @@ def provision(self): self.check_device_booted() def copy_ssh_id(self): - test_password = self.job_data.get( - 'test_data').get('test_password', 'ubuntu') + try: + test_username = self.job_data.get( + 'test_data').get('test_username', 'ubuntu') + test_password = self.job_data.get( + 'test_data').get('test_password', 'ubuntu') + except: + test_username = 'ubuntu' + test_password = 'ubuntu' cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', - self.config['device_ip']] + '{}@{}'.format(test_username, self.config['device_ip'])] subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) From f9728df573ea0138641c87b863812aa64f496b01 Mon Sep 17 00:00:00 2001 From: Gavin Lin Date: Mon, 21 May 2018 15:35:54 +0800 Subject: [PATCH 155/569] Remove unused variable --- devices/oemrecovery/oemrecovery.py | 1 - 1 file changed, 1 deletion(-) diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 1d7e883c..55dff2be 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -48,7 +48,6 @@ def _run_device(self, cmd, timeout=60): :returns: Return output from the command, if any """ - device_ip = self.config.get('device_ip') try: test_username = self.job_data.get( 'test_data').get('test_username', 'ubuntu') From 37392ed88d15a108f1ae8ba29900535fdbbf0457 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 1 Jun 2018 11:29:27 -0500 Subject: [PATCH 156/569] Allow extra time when provisioning on maas for larger images --- devices/maas2/maas2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index ff8662aa..4b0a417a 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -65,7 +65,7 @@ def provision(self): cmd.append('user_data={}'.format(data)) output = subprocess.check_output(cmd) # Make sure the device is available before returning - for timeout in range(0, 10): + for timeout in range(0, 30): time.sleep(60) status = self.node_status() if status == 'Deployed': From f5d488cc9a5409f7e0908eea63bbcd4f49e57abf Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 2 Jul 2018 18:07:01 -0500 Subject: [PATCH 157/569] Improve netboot device reliablity and recoverability --- devices/netboot/__init__.py | 19 ++++++-- devices/netboot/netboot.py | 88 +++++++++++++++++-------------------- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 48270694..6d19a273 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -24,7 +24,7 @@ from devices.netboot.netboot import Netboot from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) +from devices import (Catch, ProvisioningError, RecoveryError) device_name = "netboot" @@ -40,15 +40,26 @@ def invoked(self, ctx): config = yaml.load(configfile) snappy_device_agents.configure_logging(config) device = Netboot(ctx.args.config) - logmsg(logging.INFO, "BEGIN provision") - logmsg(logging.INFO, "Booting Master Image") - device.ensure_master_image() image = snappy_device_agents.get_image(ctx.args.job_data) server_ip = snappy_device_agents.get_local_ip_addr() test_username = snappy_device_agents.get_test_username( ctx.args.job_data) test_password = snappy_device_agents.get_test_password( ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Booting Master Image") + """Initial recovery process + If the netboot (master) image is already booted and we can get to then + URL for it, then just continue with provisioning. Otherwise, try to + force it into the test image first, recopy the ssh keys if necessary, + reboot if necessary, and get it into the netboot image before going on + """ + if not device.is_master_image_booted(): + try: + device.ensure_test_image(test_username, test_password) + device.ensure_master_image() + except ProvisioningError: + raise RecoveryError("Unable to put system in a usable state!") q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, args=(q, image,)) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 34d39165..c2818e78 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -91,7 +91,7 @@ def hardreset(self): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) - except: + except Exception: raise RecoveryError("timeout reaching control host!") def ensure_test_image(self, test_username, test_password): @@ -106,6 +106,8 @@ def ensure_test_image(self, test_username, test_password): If the command times out or anything else fails. """ logger.info("Booting the test image") + if self.is_test_image_booted(test_username, test_password): + return self.setboot('test') cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', @@ -113,49 +115,40 @@ def ensure_test_image(self, test_username, test_password): 'sudo /sbin/reboot'] try: subprocess.check_call(cmd) - except: - pass + except Exception: + self.hardreset() time.sleep(60) started = time.time() # Retry for a while since we might still be rebooting - test_image_booted = False - while time.time() - started < 300: - try: - time.sleep(10) - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', '-f', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] - subprocess.check_call(cmd) - test_image_booted = self.is_test_image_booted(test_username) - except: - pass - if test_image_booted: - break - # Check again if we are in the master image - if not test_image_booted: - raise ProvisioningError("Failed to boot test image!") + while time.time() - started < 400: + time.sleep(10) + if self.is_test_image_booted(test_username, test_password): + return + # If we got here, the test image never became available + raise ProvisioningError("Failed to boot test image!") - def is_test_image_booted(self, test_username): + def is_test_image_booted(self, test_username, test_password): """ - Check if the master image is booted. + Check if the test image is booted. :returns: True if the test image is currently booted, False otherwise. :param test_username: Username of the default user in the test image - :raises TimeoutError: - If the command times out - :raises CalledProcessError: - If the command fails + :param test_password: + Password of the default user in the test image + :returns: + True if the test image is currently booted, False otherwise. """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', '-f', + '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip']), - 'snap -h'] - subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) + '{}@{}'.format(test_username, self.config['device_ip'])] + try: + subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) + except Exception: + return False # If we get here, then the above command proved we are in snappy return True @@ -175,7 +168,7 @@ def is_master_image_booted(self): logger.info("Checking if master image booted: %s", check_url) with urllib.request.urlopen(check_url) as url: data = url.read() - except: + except Exception: # Any connection error will fail through the normal path pass if 'Snappy Test Device Imager' in str(data): @@ -191,22 +184,21 @@ def ensure_master_image(self): If the command times out or anything else fails. """ logger.info("Making sure the master image is booted") - master_booted = self.is_master_image_booted() + if self.is_master_image_booted(): + return - if not master_booted: - # We are not in the master image, so just hard reset - self.setboot('master') - self.hardreset() + self.setboot('master') + self.hardreset() - started = time.time() - while time.time() - started < 300: - time.sleep(10) - master_booted = self.is_master_image_booted() - if master_booted: - break - # Check again if we are in the master image - if not master_booted: - raise RecoveryError("Could not reboot to master!") + started = time.time() + while time.time() - started < 300: + time.sleep(10) + master_is_booted = self.is_master_image_booted() + if master_is_booted: + break + # Check again if we are in the master image + if not master_is_booted: + raise RecoveryError("Could not reboot to master image!") def flash_test_image(self, server_ip, server_port): """ @@ -227,7 +219,7 @@ def flash_test_image(self, server_ip, server_port): try: # XXX: I hope 30 min is enough? but maybe not! req = urllib.request.urlopen(url, timeout=1800) - except: + except Exception: raise ProvisioningError("Error while flashing image!") finally: logger.info("Image write output:") @@ -242,6 +234,6 @@ def flash_test_image(self, server_ip, server_port): try: logger.info("Rebooting target device: %s", url) urllib.request.urlopen(url, timeout=10) - except: + except Exception: # FIXME: This could fail to return right now due to a bug pass From e2f9e09864f0f6c3f4610ad6c6fae8f3fd0b7e9c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 2 Jul 2018 19:00:37 -0500 Subject: [PATCH 158/569] Add timeout to the initial netboot reboot cmd in case the device is unreachable --- devices/netboot/netboot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index c2818e78..afe7d66f 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -114,7 +114,7 @@ def ensure_test_image(self, test_username, test_password): '{}@{}'.format(test_username, self.config['device_ip']), 'sudo /sbin/reboot'] try: - subprocess.check_call(cmd) + subprocess.check_call(cmd, timeout=60) except Exception: self.hardreset() time.sleep(60) From 38cfbca1ecdf07cf5a991d83aa98a6e4d8c77346 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Jul 2018 13:59:56 -0500 Subject: [PATCH 159/569] Handle errors better if an invalid url is passed for provisioning --- devices/netboot/__init__.py | 2 ++ devices/rpi2/__init__.py | 4 +++- snappy_device_agents/__init__.py | 12 +++++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 6d19a273..7af2fc96 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -41,6 +41,8 @@ def invoked(self, ctx): snappy_device_agents.configure_logging(config) device = Netboot(ctx.args.config) image = snappy_device_agents.get_image(ctx.args.job_data) + if not image: + raise ProvisioningError('Error downloading image' server_ip = snappy_device_agents.get_local_ip_addr() test_username = snappy_device_agents.get_test_username( ctx.args.job_data) diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py index 72a526a9..791c7729 100644 --- a/devices/rpi2/__init__.py +++ b/devices/rpi2/__init__.py @@ -23,7 +23,7 @@ import snappy_device_agents from devices.rpi2.rpi2 import RaspberryPi2 from snappy_device_agents import logmsg, runcmd -from devices import (Catch, RecoveryError) +from devices import (Catch, RecoveryError, ProvisioningError) device_name = "rpi2" @@ -45,6 +45,8 @@ def invoked(self, ctx): logmsg(logging.INFO, "Booting Master Image") device.ensure_master_image() image = snappy_device_agents.get_image(ctx.args.job_data) + if not image: + raise ProvisioningError('Error downloading image') server_ip = snappy_device_agents.get_local_ip_addr() q = multiprocessing.Queue() file_server = multiprocessing.Process( diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3699a141..c49600de 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -176,7 +176,8 @@ def get_image(job_data='testflinger.json'): create the requested image. :return compressed_filename: - Returns the filename of the compressed image + Returns the filename of the compressed image, or empty string if + there was an error """ testflinger_data = get_test_opportunity(job_data) image_keys = testflinger_data.get('provision_data').keys() @@ -185,8 +186,12 @@ def get_image(job_data='testflinger.json'): 'provision_data').get('download_files'): download(url) if 'url' in image_keys: - image = download(testflinger_data.get('provision_data').get('url'), - IMAGEFILE) + try: + url = testflinger_data.get('provision_data').get('url') + image = download(url, IMAGEFILE) + except Exception as e: + logger.error('Error getting "%s": %s', url, e) + return '' elif 'udf-params' in image_keys: udf_params = testflinger_data.get('provision_data').get('udf-params') image = delayretry(udf_create_image, [udf_params], @@ -194,6 +199,7 @@ def get_image(job_data='testflinger.json'): else: logger.error('provision_data needs to contain "url" for the image ' 'or "udf-params"') + return '' return compress_file(image) From 76dc780716a767e01f3ff17832d1c5fcb5eb158e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 23 Jul 2018 07:42:55 -0500 Subject: [PATCH 160/569] typo --- devices/netboot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 7af2fc96..e62635b5 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -42,7 +42,7 @@ def invoked(self, ctx): device = Netboot(ctx.args.config) image = snappy_device_agents.get_image(ctx.args.job_data) if not image: - raise ProvisioningError('Error downloading image' + raise ProvisioningError('Error downloading image') server_ip = snappy_device_agents.get_local_ip_addr() test_username = snappy_device_agents.get_test_username( ctx.args.job_data) From 10bde4d54c2dd46f656b282fa5ad26af6ad3fc77 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 1 Aug 2018 10:46:34 -0500 Subject: [PATCH 161/569] Eliminate possible null values from environment settings --- snappy_device_agents/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index c49600de..52f06ed3 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -337,6 +337,9 @@ def runcmd(cmd, env=None, timeout=None): Return value from running the command """ + # Sanitize the environment, eliminate null values or Popen may choke + env={x:y for x,y in env.items() if y} + if timeout: deadline = time.time() + timeout else: From a7ba92a9d90697abee92e18424b7798565ab8324 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 2 Aug 2018 08:24:11 -0500 Subject: [PATCH 162/569] pep8 corrections --- snappy_device_agents/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 52f06ed3..3cad161a 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -338,7 +338,7 @@ def runcmd(cmd, env=None, timeout=None): """ # Sanitize the environment, eliminate null values or Popen may choke - env={x:y for x,y in env.items() if y} + env = {x: y for x, y in env.items() if y} if timeout: deadline = time.time() + timeout From ae054cf9a219e874c4759fbe21cb2c5a48f2ac31 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Aug 2018 08:45:23 -0500 Subject: [PATCH 163/569] Default env to an empty dict instead of None --- snappy_device_agents/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3cad161a..7b3cd164 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -323,7 +323,7 @@ def logmsg(level, msg, *args, **kwargs): logmsg(level, msg[4096:]) -def runcmd(cmd, env=None, timeout=None): +def runcmd(cmd, env={}, timeout=None): """ Run a command and stream the output to stdout @@ -360,7 +360,7 @@ def runcmd(cmd, env=None, timeout=None): return process.returncode -def run_test_cmds(cmds, config=None, env=None): +def run_test_cmds(cmds, config=None, env={}): """ Run the test commands provided This is just a frontend to determine the type of cmds we @@ -387,7 +387,7 @@ def run_test_cmds(cmds, config=None, env=None): return 1 -def _run_test_cmds_list(cmds, config=None, env=None): +def _run_test_cmds_list(cmds, config=None, env={}): """ Run the test commands provided @@ -420,7 +420,7 @@ def _run_test_cmds_list(cmds, config=None, env=None): return exitcode -def _run_test_cmds_str(cmds, config=None, env=None): +def _run_test_cmds_str(cmds, config=None, env={}): """ Run the test commands provided From 63de8136f8d942d96e3d7a4d3c184493054172b3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Aug 2018 13:46:01 -0500 Subject: [PATCH 164/569] Wait a up to 10 minutes for netboot provisioning when booting the test image --- devices/netboot/netboot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index afe7d66f..f8687f23 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -121,7 +121,7 @@ def ensure_test_image(self, test_username, test_password): started = time.time() # Retry for a while since we might still be rebooting - while time.time() - started < 400: + while time.time() - started < 600: time.sleep(10) if self.is_test_image_booted(test_username, test_password): return From 93144993b26e0a359ff5451c66c2d976517e74df Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Mon, 13 Aug 2018 19:45:12 +0800 Subject: [PATCH 165/569] Extend deployment timeout --- devices/maas2/maas2.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 4b0a417a..a4986943 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -65,11 +65,17 @@ def provision(self): cmd.append('user_data={}'.format(data)) output = subprocess.check_output(cmd) # Make sure the device is available before returning - for timeout in range(0, 30): + for timeout in range(0, 60): time.sleep(60) + passed_time = str(60 * (timeout + 1)) + print('{} sec passed since deployment.'.format(passed_time)) status = self.node_status() + if status == 'Failed deployment': + logger.error('MaaS reports Failed deployment') + return if status == 'Deployed': if self.check_test_image_booted(): + print('Deployed and booted.') return logger.error('Device %s still in "%s" state, deployment failed!', agent_name, status) From 960f16e3afd4c2d355f863e4ddb9d8bb27db14d2 Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Wed, 15 Aug 2018 09:05:05 +0800 Subject: [PATCH 166/569] Refacoring the for loop to be more readable --- devices/maas2/maas2.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index a4986943..3ba5a09e 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -65,9 +65,10 @@ def provision(self): cmd.append('user_data={}'.format(data)) output = subprocess.check_output(cmd) # Make sure the device is available before returning - for timeout in range(0, 60): + minutes_spent = 0 + while minutes_spent < 60: time.sleep(60) - passed_time = str(60 * (timeout + 1)) + passed_time = str(60 * (minutes_spent + 1)) print('{} sec passed since deployment.'.format(passed_time)) status = self.node_status() if status == 'Failed deployment': @@ -77,6 +78,7 @@ def provision(self): if self.check_test_image_booted(): print('Deployed and booted.') return + minutes_spent += 1 logger.error('Device %s still in "%s" state, deployment failed!', agent_name, status) logger.error(output) From b8c331f35e14df7632df7669402949f1aa2d8516 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 15 Aug 2018 02:14:38 -0500 Subject: [PATCH 167/569] Wait 15 minutes for install of newer images on netboot --- devices/netboot/netboot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index f8687f23..eeac2970 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -121,7 +121,7 @@ def ensure_test_image(self, test_username, test_password): started = time.time() # Retry for a while since we might still be rebooting - while time.time() - started < 600: + while time.time() - started < 900: time.sleep(10) if self.is_test_image_booted(test_username, test_password): return From 1ac53f8d64475bb23f08657e6858f1c2fc56b700 Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Fri, 17 Aug 2018 09:34:49 +0800 Subject: [PATCH 168/569] Show the passed time in minute and better logic --- devices/maas2/maas2.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 3ba5a09e..e6b3fc22 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -68,8 +68,8 @@ def provision(self): minutes_spent = 0 while minutes_spent < 60: time.sleep(60) - passed_time = str(60 * (minutes_spent + 1)) - print('{} sec passed since deployment.'.format(passed_time)) + minutes_spent += 1 + print('{} minutes passed since deployment.'.format(minutes_spent)) status = self.node_status() if status == 'Failed deployment': logger.error('MaaS reports Failed deployment') @@ -78,7 +78,6 @@ def provision(self): if self.check_test_image_booted(): print('Deployed and booted.') return - minutes_spent += 1 logger.error('Device %s still in "%s" state, deployment failed!', agent_name, status) logger.error(output) From 8e56adf206a14c2910faeefc13189783cfaa7f3e Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Sun, 19 Aug 2018 23:47:00 +0800 Subject: [PATCH 169/569] Add space to make the code more readable --- devices/maas2/maas2.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index e6b3fc22..bb648466 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -64,6 +64,7 @@ def provision(self): data = base64.b64encode(user_data.encode()).decode() cmd.append('user_data={}'.format(data)) output = subprocess.check_output(cmd) + # Make sure the device is available before returning minutes_spent = 0 while minutes_spent < 60: @@ -71,13 +72,16 @@ def provision(self): minutes_spent += 1 print('{} minutes passed since deployment.'.format(minutes_spent)) status = self.node_status() + if status == 'Failed deployment': logger.error('MaaS reports Failed deployment') return + if status == 'Deployed': if self.check_test_image_booted(): print('Deployed and booted.') return + logger.error('Device %s still in "%s" state, deployment failed!', agent_name, status) logger.error(output) From f06dc62f94e45aa0fd122542a7088ebbb6bb106d Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Sun, 19 Aug 2018 23:51:16 +0800 Subject: [PATCH 170/569] When provisioning fails, it should raise ProvisioningError instead of return --- devices/maas2/maas2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index bb648466..85dbbf49 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -75,7 +75,7 @@ def provision(self): if status == 'Failed deployment': logger.error('MaaS reports Failed deployment') - return + raise ProvisioningError("Provisioning failed!") if status == 'Deployed': if self.check_test_image_booted(): From d2ca020d1becf777db2e71a6e659f5ef9bbccea3 Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Thu, 13 Sep 2018 16:55:50 +0800 Subject: [PATCH 171/569] Add more explicit progress status messages --- devices/maas2/maas2.py | 55 ++++++++++++++++++++++---------- snappy_device_agents/__init__.py | 4 ++- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 85dbbf49..35958e58 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -37,9 +37,24 @@ def __init__(self, config, job_data): with open(job_data) as j: self.job_data = json.load(j) + def _logger_debug(self, message): + logger.debug("MAAS: {}".format(message)) + + def _logger_info(self, message): + logger.info("MAAS: {}".format(message)) + + def _logger_warning(self, message): + logger.warning("MAAS: {}".format(message)) + + def _logger_error(self, message): + logger.error("MAAS: {}".format(message)) + + def _logger_critical(self, message): + logger.critical("MAAS: {}".format(message)) + def recover(self): agent_name = self.config.get('agent_name') - logger.info("Releasing node %s", agent_name) + self._logger_info("Releasing node {}".format(agent_name)) self.node_release() def provision(self): @@ -49,13 +64,13 @@ def provision(self): provision_data = self.job_data.get('provision_data') # Default to a safe LTS if no distro is specified distro = provision_data.get('distro', 'xenial') - logger.info('Acquiring node') + self._logger_info('Acquiring node') cmd = ['maas', maas_user, 'machines', 'allocate', 'system_id={}'.format(node_id)] # Do not use runcmd for this - we need the output, not the end user subprocess.check_call(cmd) - logger.info( - 'Starting node %s with distro %s', agent_name, distro) + self._logger_info('Starting node {} ' + 'with distro {}'.format(agent_name, distro)) cmd = ['maas', maas_user, 'machine', 'deploy', node_id, 'distro_series={}'.format(distro)] print(self.job_data) @@ -67,28 +82,36 @@ def provision(self): # Make sure the device is available before returning minutes_spent = 0 - while minutes_spent < 60: + timeout_min = 60 + while minutes_spent < timeout_min: time.sleep(60) minutes_spent += 1 - print('{} minutes passed since deployment.'.format(minutes_spent)) + self._logger_info('{} minutes passed ' + 'since deployment.'.format(minutes_spent)) status = self.node_status() if status == 'Failed deployment': - logger.error('MaaS reports Failed deployment') - raise ProvisioningError("Provisioning failed!") + self._logger_error('MaaS reports Failed Deployment') + exception_msg = "Provisioning failed because " + \ + "MaaS got unexpected or " + \ + "deployment failure status signal." + raise ProvisioningError(exception_msg) if status == 'Deployed': if self.check_test_image_booted(): - print('Deployed and booted.') + self._logger_info('Deployed and booted.') return - logger.error('Device %s still in "%s" state, deployment failed!', - agent_name, status) - logger.error(output) - raise ProvisioningError("Provisioning failed!") + self._logger_error('Device {} still in "{}" state, ' + 'deployment failed!'.format(agent_name, status)) + self._logger_error(output) + exception_msg = "Provisioning failed because deployment timeout. " + \ + "Deploying for more than " + \ + "{} minutes.".format(timeout_min) + raise ProvisioningError(exception_msg) def check_test_image_booted(self): - logger.info("Checking if test image booted.") + self._logger_info("Checking if test image booted.") cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), @@ -130,6 +153,6 @@ def node_release(self): if status == 'Ready': return agent_name = self.config.get('agent_name') - logger.error('Device %s still in "%s" state, could not recover!', - agent_name, status) + self._logger_error('Device {} still in "{}" state, ' + 'could not recover!'.format(agent_name, status)) raise RecoveryError("Device recovery failed!") diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 7b3cd164..3893a18f 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -287,7 +287,9 @@ def filter(self, record): return True logging.basicConfig( - format='%(asctime)s %(agent_name)s %(levelname)s: %(message)s') + format='%(asctime)s %(agent_name)s %(levelname)s: ' + 'DEVICE AGENT: ' + '%(message)s') agent_name = config.get('agent_name', "") logger.addFilter(AgentFilter(agent_name)) logstash_host = config.get('logstash_host', None) From 1e4dfb0234a0c7a2e4a39486c4d4e847d6f139bf Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Thu, 13 Sep 2018 16:56:01 +0800 Subject: [PATCH 172/569] Add more explicit progress status messages --- testflinger_agent/job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index a710b413..79c1dbe5 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -61,7 +61,7 @@ def run_test_phase(self, phase, rundir): logger.info('Running %s_command: %s', phase, cmd) # Set the exitcode to some failed status in case we get interrupted exitcode = 99 - for line in self.banner('Starting {} phase on {}'.format(phase, node)): + for line in self.banner('Starting testflinger {} phase on {}'.format(phase, node)): self.run_with_log("echo '{}'".format(line), phase_log, rundir) try: exitcode = self.run_with_log(cmd, phase_log, rundir) From 81f0199228b6f68464ab200037435a740b2f0cd2 Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Mon, 19 Nov 2018 15:33:41 +0800 Subject: [PATCH 173/569] Catch maas-cli CalledProcessError --- devices/maas2/maas2.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 35958e58..bfea37da 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -78,7 +78,14 @@ def provision(self): if user_data: data = base64.b64encode(user_data.encode()).decode() cmd.append('user_data={}'.format(data)) - output = subprocess.check_output(cmd) + process = subprocess.run(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + try: + process.check_returncode() + except subprocess.CalledProcessError: + self._logger_error('maas-cli call failure happens.') + raise ProvisioningError(process.stdout.decode()) # Make sure the device is available before returning minutes_spent = 0 @@ -104,7 +111,7 @@ def provision(self): self._logger_error('Device {} still in "{}" state, ' 'deployment failed!'.format(agent_name, status)) - self._logger_error(output) + self._logger_error(process.stdout.decode()) exception_msg = "Provisioning failed because deployment timeout. " + \ "Deploying for more than " + \ "{} minutes.".format(timeout_min) From 3598c078d09fca35b017672b307483ca41912586 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 18 Jan 2019 09:54:39 -0600 Subject: [PATCH 174/569] Add RPI3 full provisioning support --- devices/rpi3/__init__.py | 85 +++++++++ devices/rpi3/rpi3.py | 370 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 devices/rpi3/__init__.py create mode 100644 devices/rpi3/rpi3.py diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py new file mode 100644 index 00000000..019c6c19 --- /dev/null +++ b/devices/rpi3/__init__.py @@ -0,0 +1,85 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Rpi3 support code.""" + +import logging +import yaml + +import guacamole + +import snappy_device_agents +from devices.rpi3.rpi3 import Rpi3 +from snappy_device_agents import logmsg, run_test_cmds +from devices import (Catch, RecoveryError) + +device_name = "rpi3" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + @Catch(RecoveryError, 46) + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = Rpi3(ctx.args.config, ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Booting Master Image") + device.ensure_master_image() + device.provision() + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = run_test_cmds(test_cmds, config) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Rpi3.""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py new file mode 100644 index 00000000..71f8a93c --- /dev/null +++ b/devices/rpi3/rpi3.py @@ -0,0 +1,370 @@ +# Copyright (C) 2016 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Rpi3 support code.""" + +import json +import logging +import multiprocessing +import os +import subprocess +import time +import yaml + +import snappy_device_agents +from devices import (ProvisioningError, + RecoveryError) + +logger = logging.getLogger() + + +class Rpi3: + + """Snappy Device Agent for Rpi3.""" + + def __init__(self, config, job_data): + with open(config) as configfile: + self.config = yaml.load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) + + def _run_control(self, cmd, timeout=60): + """ + Run a command on the control host over ssh + + :param cmd: + Command to run + :param timeout: + Timeout (default 60) + :returns: + Return output from the command, if any + """ + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'pi@{}'.format(self.config['device_ip']), + cmd] + try: + output = subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=timeout) + except subprocess.CalledProcessError as e: + raise ProvisioningError(e.output) + return output + + def setboot(self, mode): + """ + Set the boot mode of the device. + + :param mode: + One of 'master' or 'test' + :raises ProvisioningError: + If the command times out or anything else fails. + + This method sets the snappy boot method to the specified value. + """ + if mode == 'master': + setboot_script = self.config['select_master_script'] + elif mode == 'test': + setboot_script = self.config['select_test_script'] + else: + raise KeyError + for cmd in setboot_script: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise ProvisioningError("timeout reaching control host!") + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config['reboot_script']: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except: + raise RecoveryError("timeout reaching control host!") + + def ensure_test_image(self, test_username, test_password): + """ + Actively switch the device to boot the test image. + + :param test_username: + Username of the default user in the test image + :param test_password: + Password of the default user in the test image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + logger.info("Booting the test image") + self.setboot('test') + try: + self._run_control('sudo /sbin/reboot') + except: + pass + time.sleep(60) + + started = time.time() + # Retry for a while since we might still be rebooting + test_image_booted = False + while time.time() - started < 300: + try: + time.sleep(10) + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip'])] + subprocess.check_call(cmd) + test_image_booted = self.is_test_image_booted() + except: + pass + if test_image_booted: + break + # Check again if we are in the master image + if not test_image_booted: + raise ProvisioningError("Failed to boot test image!") + + def is_test_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the test image is currently booted, False otherwise. + :raises TimeoutError: + If the command times out + :raises CalledProcessError: + If the command fails + """ + logger.info("Checking if test image booted.") + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'snap -h'] + try: + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + except: + return False + # If we get here, then the above command proved we are in snappy + return True + + def is_master_image_booted(self): + """ + Check if the master image is booted. + + :returns: + True if the master image is currently booted, False otherwise. + + .. note:: + The master image is used for writing a new image to local media + """ + # FIXME: come up with a better way of checking this + logger.info("Checking if master image booted.") + try: + output = self._run_control('cat /etc/issue') + except: + logger.info("Error checking device state. Forcing reboot...") + return False + if 'GNU' in str(output): + return True + return False + + def ensure_master_image(self): + """ + Actively switch the device to boot the test image. + + :raises RecoveryError: + If the command times out or anything else fails. + """ + logger.info("Making sure the master image is booted") + + # most likely, we are still in a test image, check that first + test_booted = self.is_test_image_booted() + + if test_booted: + # We are not in the master image, so just hard reset + self.setboot('master') + self.hardreset() + + started = time.time() + while time.time() - started < 300: + time.sleep(10) + master_booted = self.is_master_image_booted() + if master_booted: + return + # Check again if we are in the master image + if not master_booted: + raise RecoveryError("Could not reboot to master!") + + master_booted = self.is_master_image_booted() + if not master_booted: + logging.warn( + "Device is in an unknown state, attempting to recover") + self.hardreset() + started = time.time() + while time.time() - started < 300: + time.sleep(10) + if self.is_master_image_booted(): + return + elif self.is_test_image_booted(): + # device was stuck, but booted to the test image + # So rerun ourselves to get to the master image + return self.ensure_master_image() + # timeout reached, this could be a dead device + raise RecoveryError( + "Device is in an unknown state, may require manual recovery!") + # If we get here, the master image was already booted, so just return + + def flash_test_image(self, server_ip, server_port): + """ + Flash the image at :image_url to the sd card. + + :param server_ip: + IP address of the image server. The image will be downloaded and + gunzipped over the SD card. + :param server_port: + TCP port to connect to on server_ip for downloading the image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + # First unmount, just in case + try: + self._run_control( + 'sudo umount {}*'.format(self.config['test_device']), + timeout=30) + except ProvisioningError: + # We might not be mounted, so expect this to fail sometimes + pass + cmd = 'nc.traditional {} {}| gunzip| sudo dd of={} bs=16M'.format( + server_ip, server_port, self.config['test_device']) + logger.info("Running: %s", cmd) + try: + # XXX: I hope 30 min is enough? but maybe not! + self._run_control(cmd, timeout=1800) + except: + raise ProvisioningError("timeout reached while flashing image!") + try: + self._run_control('sync') + except: + # Nothing should go wrong here, but let's sleep if it does + logger.warn("Something went wrong with the sync, sleeping...") + time.sleep(30) + try: + self._run_control( + 'sudo hdparm -z {}'.format(self.config['test_device']), + timeout=30) + except: + raise ProvisioningError("Unable to run hdparm to rescan " + "partitions") + + def mount_writable_partition(self): + # Mount the writable partition + try: + self._run_control('sudo mount {} /mnt'.format( + self.config['snappy_writable_partition'])) + except: + err = ("Error mounting writable partition on test image {}. " + "Check device configuration".format( + self.config['snappy_writable_partition'])) + raise ProvisioningError(err) + + def create_user(self): + """Create user account for default ubuntu user""" + self.mount_writable_partition() + metadata = 'instance_id: cloud-image' + userdata = ('#cloud-config\n' + 'password: ubuntu\n' + 'chpasswd:\n' + ' list:\n' + ' - ubuntu:ubuntu\n' + ' expire: False\n' + 'ssh_pwauth: True') + with open('meta-data', 'w') as mdata: + mdata.write(metadata) + with open('user-data', 'w') as udata: + udata.write(userdata) + try: + output = self._run_control('ls /mnt') + if 'system-data' in str(output): + base = '/mnt/system-data' + else: + base = '/mnt' + cloud_path = os.path.join( + base, 'var/lib/cloud/seed/nocloud-net') + self._run_control('sudo mkdir -p {}'.format(cloud_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(metadata, cloud_path, 'meta-data')) + self._run_control( + write_cmd.format(userdata, cloud_path, 'user-data')) + except: + raise ProvisioningError("Error creating user files") + + def wipe_test_device(self): + """Safety check - wipe the test drive if things go wrong + + This way if we reboot the sytem after a failed provision, it goes + back to the control boot image which we could use to provision + something else. + """ + try: + test_device = self.config['test_device'] + logger.error("Failed to write image, cleaning up...") + self._run_control( + 'sudo wipefs -af {}'.format(test_device)) + except: + # This is an attempt to salvage a bad run, further tracebacks + # would just add to the noise + pass + + def provision(self): + """Provision the device""" + url = self.job_data['provision_data'].get('url') + if url: + snappy_device_agents.download(url, 'snappy.img') + else: + logger.error("Bad data passed for provisioning") + raise ProvisioningError("Error provisioning system") + image_file = snappy_device_agents.compress_file('snappy.img') + test_username = self.job_data.get( + 'test_data').get('test_username', 'ubuntu') + test_password = self.job_data.get( + 'test_data').get('test_password', 'ubuntu') + server_ip = snappy_device_agents.get_local_ip_addr() + serve_q = multiprocessing.Queue() + file_server = multiprocessing.Process( + target=snappy_device_agents.serve_file, + args=(serve_q, image_file,)) + file_server.start() + server_port = serve_q.get() + logger.info("Flashing Test Image") + try: + self.flash_test_image(server_ip, server_port) + file_server.terminate() + logger.info("Creating Test User") + self.create_user() + logger.info("Booting Test Image") + self.ensure_test_image(test_username, test_password) + except: + # wipe out whatever we installed if things go badly + self.wipe_test_device() + raise + logger.info("END provision") From 299c2d304810e6107396ff0a60beb0789b798b9c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 23 Jan 2019 15:39:46 -0600 Subject: [PATCH 175/569] Catch KeyError in a few places for rpi3 --- devices/rpi3/rpi3.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index 71f8a93c..ced5eccd 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -250,7 +250,9 @@ def flash_test_image(self, server_ip, server_port): self._run_control( 'sudo umount {}*'.format(self.config['test_device']), timeout=30) - except ProvisioningError: + except KeyError: + raise RecoveryError("Device config missing test_device") + except: # We might not be mounted, so expect this to fail sometimes pass cmd = 'nc.traditional {} {}| gunzip| sudo dd of={} bs=16M'.format( @@ -280,6 +282,9 @@ def mount_writable_partition(self): try: self._run_control('sudo mount {} /mnt'.format( self.config['snappy_writable_partition'])) + except KeyError: + raise RecoveryError( + "Device config missing snappy_writable_partition") except: err = ("Error mounting writable partition on test image {}. " "Check device configuration".format( From 416e8b0dd3e34d956a1fb1473ed13c92073f07a6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Feb 2019 12:57:51 -0600 Subject: [PATCH 176/569] Add basic support for muxpi --- devices/muxpi/__init__.py | 85 ++++++++++++++++ devices/muxpi/muxpi.py | 208 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 devices/muxpi/__init__.py create mode 100644 devices/muxpi/muxpi.py diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py new file mode 100644 index 00000000..4a0e7f8a --- /dev/null +++ b/devices/muxpi/__init__.py @@ -0,0 +1,85 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu Raspberry PI muxpi support code.""" + +import logging +import yaml + +import guacamole + +import snappy_device_agents +from devices.muxpi.muxpi import MuxPi +from snappy_device_agents import logmsg, run_test_cmds +from devices import (Catch, RecoveryError) + +device_name = "muxpi" + + +class provision(guacamole.Command): + + """Tool for provisioning baremetal with a given image.""" + + @Catch(RecoveryError, 46) + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + device = MuxPi(ctx.args.config, ctx.args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class runtest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = run_test_cmds(test_cmds, config) + logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + +class DeviceAgent(guacamole.Command): + + """Device agent for Ubuntu Raspberry PI muxpi""" + + sub_commands = ( + ('provision', provision), + ('runtest', runtest), + ) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py new file mode 100644 index 00000000..b04ae670 --- /dev/null +++ b/devices/muxpi/muxpi.py @@ -0,0 +1,208 @@ +# Copyright (C) 2017 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu Raspberry PI muxpi support code.""" + +import json +import logging +import multiprocessing +import os +import subprocess +import time +import yaml + +import snappy_device_agents + +from devices import (ProvisioningError, + RecoveryError) + +logger = logging.getLogger() + + +class MuxPi: + + """Device Agent for MuxPi.""" + + def __init__(self, config, job_data): + with open(config) as configfile: + self.config = yaml.load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) + + def _run_control(self, cmd, timeout=60): + """ + Run a command on the control host over ssh + + :param cmd: + Command to run + :param timeout: + Timeout (default 60) + :returns: + Return output from the command, if any + """ + control_host = self.config.get('control_host') + control_user = self.config.get('control_user', 'ubuntu') + ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(control_user, control_host), + cmd] + try: + output = subprocess.check_output( + ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout) + except subprocess.CalledProcessError as e: + raise ProvisioningError(e.output) + return output + + def provision(self): + try: + url = self.job_data['provision_data']['url'] + snappy_device_agents.download(url, 'snappy.img') + except KeyError: + raise ProvisioningError('You must specify a "url" value in ' + 'the "provision_data" section of ' + 'your job_data') + self._run_control('stm -ts') + time.sleep(5) + logger.info('Flashing Test image') + image_file = snappy_device_agents.compress_file('snappy.img') + server_ip = snappy_device_agents.get_local_ip_addr() + serve_q = multiprocessing.Queue() + file_server = multiprocessing.Process( + target=snappy_device_agents.serve_file, + args=(serve_q, image_file,)) + file_server.start() + server_port = serve_q.get() + try: + self.flash_test_image(server_ip, server_port) + file_server.terminate() + logger.info("Creating Test User") + self.create_user() + logger.info("Booting Test Image") + self.unmount_writable_partition() + self._run_control('stm -dut') + self.check_test_image_booted() + except Exception: + raise + + def flash_test_image(self, server_ip, server_port): + """ + Flash the image at :image_url to the sd card. + + :param server_ip: + IP address of the image server. The image will be downloaded and + gunzipped over the SD card. + :param server_port: + TCP port to connect to on server_ip for downloading the image + :raises ProvisioningError: + If the command times out or anything else fails. + """ + # First unmount, just in case + self.unmount_writable_partition() + cmd = 'nc.traditional {} {}| gunzip| sudo dd of={} bs=16M'.format( + server_ip, server_port, self.config['test_device']) + logger.info("Running: %s", cmd) + try: + # XXX: I hope 30 min is enough? but maybe not! + self._run_control(cmd, timeout=1800) + except Exception: + raise ProvisioningError("timeout reached while flashing image!") + try: + self._run_control('sync') + except Exception: + # Nothing should go wrong here, but let's sleep if it does + logger.warn("Something went wrong with the sync, sleeping...") + time.sleep(30) + try: + self._run_control( + 'sudo hdparm -z {}'.format(self.config['test_device']), + timeout=30) + except Exception: + raise ProvisioningError("Unable to run hdparm to rescan " + "partitions") + + def unmount_writable_partition(self): + try: + self._run_control( + 'sudo umount {}*'.format(self.config['test_device']), + timeout=30) + except KeyError: + raise RecoveryError("Device config missing test_device") + except Exception: + # We might not be mounted, so expect this to fail sometimes + pass + + def mount_writable_partition(self): + # Mount the writable partition + try: + self._run_control('sudo mount {} /mnt'.format( + self.config['snappy_writable_partition'])) + except KeyError: + raise RecoveryError( + "Device config missing snappy_writable_partition") + except Exception: + err = ("Error mounting writable partition on test image {}. " + "Check device configuration".format( + self.config['snappy_writable_partition'])) + raise ProvisioningError(err) + + def create_user(self): + """Create user account for default ubuntu user""" + self.mount_writable_partition() + metadata = 'instance_id: cloud-image' + userdata = ('#cloud-config\n' + 'password: ubuntu\n' + 'chpasswd:\n' + ' list:\n' + ' - ubuntu:ubuntu\n' + ' expire: False\n' + 'ssh_pwauth: True') + try: + output = self._run_control('ls /mnt') + if 'system-data' in str(output): + base = '/mnt/system-data' + else: + base = '/mnt' + cloud_path = os.path.join( + base, 'var/lib/cloud/seed/nocloud-net') + self._run_control('sudo mkdir -p {}'.format(cloud_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(metadata, cloud_path, 'meta-data')) + self._run_control( + write_cmd.format(userdata, cloud_path, 'user-data')) + except Exception: + raise ProvisioningError("Error creating user files") + + def check_test_image_booted(self): + logger.info("Checking if test image booted.") + started = time.time() + # Retry for a while since we might still be rebooting + test_username = self.job_data.get( + 'test_data').get('test_username', 'ubuntu') + test_password = self.job_data.get( + 'test_data').get('test_password', 'ubuntu') + while time.time() - started < 300: + try: + time.sleep(10) + cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '{}@{}'.format(test_username, self.config['device_ip'])] + subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=60) + return True + except Exception: + pass + # If we get here, then we didn't boot in time + raise ProvisioningError("Failed to boot test image!") From 23459668af96f741ef89534ed30b8d179cd26441 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 18 Feb 2019 12:15:51 +0100 Subject: [PATCH 177/569] Expect xz images by default --- snappy_device_agents/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3893a18f..ca88ff8f 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -250,27 +250,27 @@ def compress_file(filename): :return compressed_filename: The filename of the compressed file """ - compressed_filename = "{}.gz".format(filename) + compressed_filename = "{}.xz".format(filename) try: # If debug enabled in SPI, an older image could still be there os.unlink(compressed_filename) except: pass - if filetype(filename) is 'gz': + if filetype(filename) is 'xz': # just hard link it so we can unlink later without special handling os.link(filename, compressed_filename) + elif filetype(filename) is 'gz': + with lzma.open(compressed_filename, 'wb') as compressed_image: + with gzip.GzipFile(filename, 'rb') as old_compressed: + shutil.copyfileobj(old_compressed, compressed_image) elif filetype(filename) is 'bz2': - with gzip.open(compressed_filename, 'wb') as compressed_image: + with lzma.open(compressed_filename, 'wb') as compressed_image: with bz2.BZ2File(filename, 'rb') as old_compressed: shutil.copyfileobj(old_compressed, compressed_image) - elif filetype(filename) is 'xz': - with gzip.open(compressed_filename, 'wb') as compressed_image: - with lzma.LZMAFile(filename, 'rb') as old_compressed: - shutil.copyfileobj(old_compressed, compressed_image) else: # filetype is 'unknown' so assumed to be raw image with open(filename, 'rb') as uncompressed_image: - with gzip.open(compressed_filename, 'wb') as compressed_image: + with lzma.open(compressed_filename, 'wb') as compressed_image: shutil.copyfileobj(uncompressed_image, compressed_image) os.unlink(filename) return compressed_filename From 541babea504055b7528a918f4858da88733af475 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 18 Feb 2019 14:31:24 +0100 Subject: [PATCH 178/569] Support using xz by default in common devices --- devices/dragonboard/dragonboard.py | 4 ++-- devices/muxpi/muxpi.py | 4 ++-- devices/netboot/netboot.py | 2 +- devices/rpi3/rpi3.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index e2782bb2..ee42fe94 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -239,7 +239,7 @@ def flash_test_image(self, server_ip, server_port): :param server_ip: IP address of the image server. The image will be downloaded and - gunzipped over the SD card. + uncompressed over the SD card. :param server_port: TCP port to connect to on server_ip for downloading the image :raises ProvisioningError: @@ -253,7 +253,7 @@ def flash_test_image(self, server_ip, server_port): except ProvisioningError: # We might not be mounted, so expect this to fail sometimes pass - cmd = 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( + cmd = 'nc {} {}| unxz| sudo dd of={} bs=16M'.format( server_ip, server_port, self.config['test_device']) logger.info("Running: %s", cmd) try: diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index b04ae670..763c97e0 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -101,7 +101,7 @@ def flash_test_image(self, server_ip, server_port): :param server_ip: IP address of the image server. The image will be downloaded and - gunzipped over the SD card. + uncompressed over the SD card. :param server_port: TCP port to connect to on server_ip for downloading the image :raises ProvisioningError: @@ -109,7 +109,7 @@ def flash_test_image(self, server_ip, server_port): """ # First unmount, just in case self.unmount_writable_partition() - cmd = 'nc.traditional {} {}| gunzip| sudo dd of={} bs=16M'.format( + cmd = 'nc.traditional {} {}| xzcat| sudo dd of={} bs=16M'.format( server_ip, server_port, self.config['test_device']) logger.info("Running: %s", cmd) try: diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index eeac2970..d252f53a 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -206,7 +206,7 @@ def flash_test_image(self, server_ip, server_port): :param server_ip: IP address of the image server. The image will be downloaded and - gunzipped over the SD card. + uncompressed over the SD card. :param server_port: TCP port to connect to on server_ip for downloading the image :raises ProvisioningError: diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index ced5eccd..48d59931 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -239,7 +239,7 @@ def flash_test_image(self, server_ip, server_port): :param server_ip: IP address of the image server. The image will be downloaded and - gunzipped over the SD card. + uncompressed over the SD card. :param server_port: TCP port to connect to on server_ip for downloading the image :raises ProvisioningError: @@ -255,7 +255,7 @@ def flash_test_image(self, server_ip, server_port): except: # We might not be mounted, so expect this to fail sometimes pass - cmd = 'nc.traditional {} {}| gunzip| sudo dd of={} bs=16M'.format( + cmd = 'nc.traditional {} {}| xzcat| sudo dd of={} bs=16M'.format( server_ip, server_port, self.config['test_device']) logger.info("Running: %s", cmd) try: From f8c0bc45efc81a48947d6c447ecd483ab54a4ab1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 20 Feb 2019 11:59:18 +0100 Subject: [PATCH 179/569] More support for default xz --- snappy_device_agents/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index ca88ff8f..f3674339 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -252,13 +252,13 @@ def compress_file(filename): """ compressed_filename = "{}.xz".format(filename) try: - # If debug enabled in SPI, an older image could still be there + # Remove the compressed_filename if it exists, just in case os.unlink(compressed_filename) - except: + except FileNotFoundError: pass if filetype(filename) is 'xz': # just hard link it so we can unlink later without special handling - os.link(filename, compressed_filename) + os.rename(filename, compressed_filename) elif filetype(filename) is 'gz': with lzma.open(compressed_filename, 'wb') as compressed_image: with gzip.GzipFile(filename, 'rb') as old_compressed: @@ -272,7 +272,11 @@ def compress_file(filename): with open(filename, 'rb') as uncompressed_image: with lzma.open(compressed_filename, 'wb') as compressed_image: shutil.copyfileobj(uncompressed_image, compressed_image) - os.unlink(filename) + try: + # Remove the original file, unless we already did + os.unlink(filename) + except FileNotFoundError: + pass return compressed_filename From 9d471871bcdadd321094ab58204e3816b3a1cfdd Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 25 Feb 2019 13:20:43 -0600 Subject: [PATCH 180/569] Ignore it if maas release fails, this isn't critical and can happen if you don't have admin permissions on the maas server --- devices/maas2/maas2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index bfea37da..865909ee 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -152,7 +152,7 @@ def node_release(self): maas_user = self.config.get('maas_user') node_id = self.config.get('node_id') cmd = ['maas', maas_user, 'machine', 'release', node_id] - subprocess.check_call(cmd) + subprocess.run(cmd) # Make sure the device is available before returning for timeout in range(0, 10): time.sleep(5) From 9f245b14885dd81548b5aebff521e092746e2244 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 25 Feb 2019 15:33:58 -0600 Subject: [PATCH 181/569] Support new job position api in testflinger-server --- testflinger-cli | 23 ++++++++++++++++------- testflinger_cli/__init__.py | 12 ++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/testflinger-cli b/testflinger-cli index c13361f6..a7363300 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -60,7 +60,7 @@ def status(ctx, job_id): print('Received 404 error from server. Are you sure this ' 'is a testflinger server?') sys.exit(1) - except: + except Exception: print('Error communicating with server, check connection and retry') sys.exit(1) print(job_state) @@ -132,7 +132,7 @@ def results(ctx, job_id): print('Received 404 error from server. Are you sure this ' 'is a testflinger server?') sys.exit(1) - except: + except Exception: print('Error communicating with server, check connection and retry') sys.exit(1) @@ -158,7 +158,7 @@ def artifacts(ctx, job_id, filename): print('Received 404 error from server. Are you sure this ' 'is a testflinger server?') sys.exit(1) - except: + except Exception: print('Error communicating with server, check connection and retry') sys.exit(1) print('Artifacts downloaded to {}'.format(filename)) @@ -171,9 +171,18 @@ def poll(ctx, job_id): conn = ctx.obj['conn'] job_state = get_job_state(conn, job_id) if job_state == 'waiting': - print('This job is currently waiting on a node to become available. ' - 'You will see no further output until the job starts running.') + print('This job is currently waiting on a node to become available.') + prev_queue_pos = None while job_state != 'complete': + if job_state == 'waiting': + try: + queue_pos = conn.get_job_position(job_id) + if int(queue_pos) != prev_queue_pos: + prev_queue_pos = int(queue_pos) + print('Queue position: {}'.format(queue_pos)) + except Exception: + # Ignore any bad response, this will retry + pass time.sleep(10) output = '' try: @@ -182,7 +191,7 @@ def poll(ctx, job_id): if e.status == 204: # We are still waiting for the job to start pass - except: + except Exception: continue if output: print(output, end='', flush=True) @@ -204,7 +213,7 @@ def get_job_state(conn, job_id): print('Received 404 error from server. Are you sure this ' 'is a testflinger server?') sys.exit(1) - except: + except Exception: # If we fail to get the job_state here, it could be because of timeout # but we can keep going and retrying pass diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index c1d807fb..96ff880e 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -146,3 +146,15 @@ def get_output(self, job_id): """ endpoint = '/v1/result/{}/output'.format(job_id) return self.get(endpoint) + + def get_job_position(self, job_id): + """Get the status of a test job + + :param job_id: + ID for the test job + :return: + String containing the queue position for the specified ID + i.e. how many jobs are ahead of it in the queue + """ + endpoint = '/v1/job/{}/position'.format(job_id) + return self.get(endpoint) From 0d30fec92b4a71f995c4a637c81890840c1d91bb Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 27 Feb 2019 16:59:59 -0600 Subject: [PATCH 182/569] Remove bbb device --- devices/bbb/__init__.py | 116 ------------------ devices/bbb/beagleboneblack.py | 218 --------------------------------- 2 files changed, 334 deletions(-) delete mode 100644 devices/bbb/__init__.py delete mode 100644 devices/bbb/beagleboneblack.py diff --git a/devices/bbb/__init__.py b/devices/bbb/__init__.py deleted file mode 100644 index 63033ccc..00000000 --- a/devices/bbb/__init__.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Beagle Bone Black support code.""" - -import logging -import multiprocessing -import yaml - -import guacamole - -import snappy_device_agents -from devices.bbb.beagleboneblack import BeagleBoneBlack -from snappy_device_agents import logmsg, runcmd - - -device_name = "bbb" - - -class provision(guacamole.Command): - - """Tool for provisioning beagle bone black with a given image.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - - device = BeagleBoneBlack(ctx.args.config) - logmsg(logging.INFO, "BEGIN provision") - logmsg(logging.INFO, "Booting Master Image") - device.ensure_emmc_image() - image = snappy_device_agents.get_image(ctx.args.job_data) - server_ip = snappy_device_agents.get_local_ip_addr() - test_username = snappy_device_agents.get_test_username( - ctx.args.job_data) - test_password = snappy_device_agents.get_test_password( - ctx.args.job_data) - q = multiprocessing.Queue() - file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, args=(q, image,)) - file_server.start() - server_port = q.get() - logmsg(logging.INFO, "Flashing Test Image") - device.flash_sd(server_ip, server_port) - file_server.terminate() - logmsg(logging.INFO, "Booting Test Image") - device.ensure_test_image(test_username, test_password) - logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = 0 - for cmd in test_cmds: - # Settings from the device yaml configfile like device_ip can be - # formatted in test commands like "foo {device_ip}" - try: - cmd = cmd.format(**config) - except: - exitcode = 20 - logmsg(logging.ERROR, "Unable to format command: %s", cmd) - - logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd) - if rc: - exitcode = 4 - logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for BeagleBone Black.""" - - sub_commands = ( - ('provision', provision), - ('runtest', runtest), - ) diff --git a/devices/bbb/beagleboneblack.py b/devices/bbb/beagleboneblack.py deleted file mode 100644 index 9e65a25a..00000000 --- a/devices/bbb/beagleboneblack.py +++ /dev/null @@ -1,218 +0,0 @@ -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Beagle Bone Black support code.""" - -import logging -import subprocess -import time -import yaml - -from devices import ProvisioningError - -logger = logging.getLogger() - - -class BeagleBoneBlack: - - """Snappy Device Agent for Beagle Bone Black.""" - - def __init__(self, config): - with open(config) as configfile: - self.config = yaml.load(configfile) - - def setboot(self, mode): - """ - Set the boot mode of the device. - - :param mode: - One of 'master' or 'test' - :raises RuntimeError: - If the command times out or anything else fails. - - This method sets the snappy boot method to the specified value. - """ - if mode == 'master': - setboot_script = self.config['select_master_script'] - elif mode == 'test': - setboot_script = self.config['select_test_script'] - else: - raise KeyError - for cmd in setboot_script: - logger.info("Running %s", cmd) - try: - subprocess.check_call(cmd.split(), timeout=60) - except: - raise RuntimeError("timeout reaching control host!") - - def hardreset(self): - """ - Reboot the device. - - :raises RuntimeError: - If the command times out or anything else fails. - - .. note:: - This function executes ``bin/hardreset`` which is not a part of a - standard image. You need to provide it yourself. - """ - for cmd in self.config['reboot_script']: - logger.info("running %s", cmd) - try: - subprocess.check_call(cmd.split(), timeout=60) - except: - raise RuntimeError("timeout reaching control host!") - - def ensure_test_image(self, test_username, test_password): - """ - Actively switch the device to boot the test image. - - :param test_username: - Username of the default user in the test image - :param test_password: - Password of the default user in the test image - :raises ProvisioningError: - If the command times out or anything else fails. - """ - logger.info("Booting the test image") - self.setboot('test') - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo /sbin/halt'] - try: - subprocess.check_call(cmd) - except: - pass - time.sleep(60) - self.hardreset() - - started = time.time() - # Retry for a while since we might still be rebooting - test_image_booted = False - while time.time() - started < 300: - try: - time.sleep(10) - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] - subprocess.check_call(cmd) - test_image_booted = self.is_test_image_booted() - except: - pass - if test_image_booted: - break - # Check again if we are in the master image - if not test_image_booted: - raise ProvisioningError("Failed to boot test image!") - - def is_test_image_booted(self): - """ - Check if the master image is booted. - - :returns: - True if the test image is currently booted, False otherwise. - :raises TimeoutError: - If the command times out - :raises CalledProcessError: - If the command fails - """ - - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'snap -h'] - subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) - # If we get here, then the above command proved we are in snappy - return True - - def is_emmc_image_booted(self): - """ - Check if the emmc image is booted. - - :returns: - True if the emmc image is currently booted, False otherwise. - :raises RuntimeError: - If the command times out or anything else fails. - - .. note:: - The emmc contains the non-test image. - """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'cat /etc/issue'] - # FIXME: come up with a better way of checking this - output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) - if 'BeagleBoardUbuntu' in str(output): - return True - return False - - def ensure_emmc_image(self): - """ - Actively switch the device to boot the test image. - - :raises RuntimeError: - If the command times out or anything else fails. - """ - emmc_booted = False - logger.info("Making sure the emmc image is booted") - try: - emmc_booted = self.is_emmc_image_booted() - except: - # don't worry if this doesn't work, we'll hard reset later - pass - - if not emmc_booted: - # We are not in the emmc image, so just hard reset - self.setboot('master') - self.hardreset() - - started = time.time() - while time.time() - started < 300: - try: - emmc_booted = self.is_emmc_image_booted() - except: - continue - break - # Check again if we are in the emmc image - if not emmc_booted: - raise RuntimeError("Could not reboot to emmc!") - - def flash_sd(self, server_ip, server_port): - """ - Flash the image at :image_url to the sd card. - - :param server_ip: - IP address of the image server. The image will be downloaded and - gunzipped over the SD card. - :param server_port: - TCP port to connect to on server_ip for downloading the image - :raises RuntimeError: - If the command times out or anything else fails. - """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'nc {} {}| gunzip| sudo dd of=/dev/mmcblk0 bs=16M'.format( - server_ip, server_port)] - logger.info("Running: %s", cmd) - try: - # XXX: I hope 30 min is enough? but maybe not! - subprocess.check_call(cmd, timeout=1800) - except: - raise RuntimeError("timeout reached while flashing image!") From 1a84a6d5b6bcc4d47d50407fc5f5a733cc4ba1b3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 27 Feb 2019 17:00:50 -0600 Subject: [PATCH 183/569] Remove old maas device agent --- devices/maas/__init__.py | 87 ---------------------------- devices/maas/maas.py | 120 --------------------------------------- 2 files changed, 207 deletions(-) delete mode 100644 devices/maas/__init__.py delete mode 100644 devices/maas/maas.py diff --git a/devices/maas/__init__.py b/devices/maas/__init__.py deleted file mode 100644 index cd98b540..00000000 --- a/devices/maas/__init__.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (C) 2016 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Ubuntu Maas support code.""" - -import logging -import yaml - -import guacamole - -import snappy_device_agents -from devices.maas.maas import Maas -from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) - -device_name = "maas" - - -class provision(guacamole.Command): - - """Tool for provisioning baremetal with a given image.""" - - @Catch(RecoveryError, 46) - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - device = Maas(ctx.args.config, ctx.args.job_data) - logmsg(logging.INFO, "BEGIN provision") - logmsg(logging.INFO, "Recovering device") - device.recover() - logmsg(logging.INFO, "Provisioning device") - device.provision() - logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Ubuntu Maas.""" - - sub_commands = ( - ('provision', provision), - ('runtest', runtest), - ) diff --git a/devices/maas/maas.py b/devices/maas/maas.py deleted file mode 100644 index f1d8f725..00000000 --- a/devices/maas/maas.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright (C) 2016 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Ubuntu Maas support code.""" - -import json -import logging -import subprocess -import time -import yaml - -from devices import (ProvisioningError, - RecoveryError) - -logger = logging.getLogger() - - -class Maas: - - """Device Agent for Maas.""" - - def __init__(self, config, job_data): - with open(config) as configfile: - self.config = yaml.load(configfile) - with open(job_data) as j: - self.job_data = json.load(j) - - def recover(self): - agent_name = self.config.get('agent_name') - logger.info("Releasing node %s", agent_name) - self.node_release() - - def provision(self): - maas_user = self.config.get('maas_user') - node_id = self.config.get('node_id') - node_name = self.config.get('node_name') - agent_name = self.config.get('agent_name') - provision_data = self.job_data.get('provision_data') - # Default to a safe LTS if no distro is specified - distro = provision_data.get('distro', 'xenial') - logger.info('Acquiring node') - cmd = ['maas', maas_user, 'nodes', 'acquire', - 'name={}'.format(node_name)] - # Do not use runcmd for this - we need the output, not the end user - subprocess.check_call(cmd) - logger.info( - 'Starting node %s with distro %s', agent_name, distro) - cmd = ['maas', maas_user, 'node', 'start', node_id, - 'distro_series={}'.format(distro)] - output = subprocess.check_output(cmd) - # Make sure the device is available before returning - for timeout in range(0, 10): - time.sleep(60) - status = self.node_status() - if status == 'Deployed': - if self.check_test_image_booted(): - return - logger.error('Device %s still in "%s" state, deployment failed!', - agent_name, status) - logger.error(output) - raise ProvisioningError("Provisioning failed!") - - def check_test_image_booted(self): - logger.info("Checking if test image booted.") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'snap -h'] - try: - subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) - except: - return False - # If we get here, then the above command proved we are booted - return True - - def node_status(self): - """Return status of the node according to maas: - - Not in deployment: node is not deployed - Deploying: Deployment in progress - Deployed: Node is provisioned and ready for use - """ - maas_user = self.config.get('maas_user') - node_id = self.config.get('node_id') - cmd = ['maas', maas_user, 'nodes', 'deployment-status', - 'nodes={}'.format(node_id)] - # Do not use runcmd for this - we need the output, not the end user - output = subprocess.check_output(cmd) - data = json.loads(output.decode()) - return data.get(node_id) - - def node_release(self): - """Release the node to make it available again""" - maas_user = self.config.get('maas_user') - node_id = self.config.get('node_id') - cmd = ['maas', maas_user, 'nodes', 'release', - 'nodes={}'.format(node_id)] - subprocess.check_call(cmd) - # Make sure the device is available before returning - for timeout in range(0, 10): - time.sleep(5) - status = self.node_status() - if status == 'Not in deployment': - return - agent_name = self.config.get('agent_name') - logger.error('Device %s still in "%s" state, could not recover!', - agent_name, status) - raise RecoveryError("Device recovery failed!") From a47b4a42e9ece87e3edaa4b8fc22f63f303e4775 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 27 Feb 2019 17:01:11 -0600 Subject: [PATCH 184/569] remove inception device type --- devices/inception/__init__.py | 116 ----------------- devices/inception/inception.py | 222 --------------------------------- 2 files changed, 338 deletions(-) delete mode 100644 devices/inception/__init__.py delete mode 100644 devices/inception/inception.py diff --git a/devices/inception/__init__.py b/devices/inception/__init__.py deleted file mode 100644 index b00a6738..00000000 --- a/devices/inception/__init__.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Inception x86 support code.""" - -import logging -import multiprocessing -import yaml - -import guacamole - -import snappy_device_agents -from devices.inception.inception import Inception -from snappy_device_agents import logmsg, runcmd -from devices import (Catch, RecoveryError) - -device_name = "inception" - - -class provision(guacamole.Command): - - """Tool for provisioning x86 baremetal with a given image.""" - - @Catch(RecoveryError, 46) - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - device = Inception(ctx.args.config) - logmsg(logging.INFO, "BEGIN provision") - logmsg(logging.INFO, "Booting Master Image") - device.ensure_master_image() - image = snappy_device_agents.get_image(ctx.args.job_data) - server_ip = snappy_device_agents.get_local_ip_addr() - test_username = snappy_device_agents.get_test_username( - ctx.args.job_data) - test_password = snappy_device_agents.get_test_password( - ctx.args.job_data) - q = multiprocessing.Queue() - file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, args=(q, image,)) - file_server.start() - server_port = q.get() - logmsg(logging.INFO, "Flashing Test Image") - device.flash_test_image(server_ip, server_port) - file_server.terminate() - logmsg(logging.INFO, "Booting Test Image") - device.ensure_test_image(test_username, test_password) - logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = 0 - for cmd in test_cmds: - # Settings from the device yaml configfile like device_ip can be - # formatted in test commands like "foo {device_ip}" - try: - cmd = cmd.format(**config) - except: - exitcode = 20 - logmsg(logging.ERROR, "Unable to format command: %s", cmd) - - logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd) - if rc: - exitcode = 4 - logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Inception x86.""" - - sub_commands = ( - ('provision', provision), - ('runtest', runtest), - ) diff --git a/devices/inception/inception.py b/devices/inception/inception.py deleted file mode 100644 index 214a6726..00000000 --- a/devices/inception/inception.py +++ /dev/null @@ -1,222 +0,0 @@ -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Inception x86 support code.""" - -import logging -import subprocess -import time -import yaml - -from devices import (ProvisioningError, - RecoveryError) - -logger = logging.getLogger() - - -class Inception: - - """Snappy Device Agent for Inception x86.""" - - def __init__(self, config): - with open(config) as configfile: - self.config = yaml.load(configfile) - - def setboot(self, mode): - """ - Set the boot mode of the device. - - :param mode: - One of 'master' or 'test' - :raises ProvisioningError: - If the command times out or anything else fails. - - This method sets the snappy boot method to the specified value. - """ - if mode == 'master': - setboot_script = self.config['select_master_script'] - elif mode == 'test': - setboot_script = self.config['select_test_script'] - else: - raise KeyError - for cmd in setboot_script: - logger.info("Running %s", cmd) - try: - subprocess.check_call(cmd.split(), timeout=60) - except: - raise ProvisioningError("timeout reaching control host!") - - def hardreset(self): - """ - Reboot the device. - - :raises RecoveryError: - If the command times out or anything else fails. - - .. note:: - This function runs the commands specified in 'reboot_script' - in the config yaml. - """ - for cmd in self.config['reboot_script']: - logger.info("Running %s", cmd) - try: - subprocess.check_call(cmd.split(), timeout=60) - except: - raise RecoveryError("timeout reaching control host!") - - def ensure_test_image(self, test_username, test_password): - """ - Actively switch the device to boot the test image. - - :param test_username: - Username of the default user in the test image - :param test_password: - Password of the default user in the test image - :raises ProvisioningError: - If the command times out or anything else fails. - """ - logger.info("Booting the test image") - self.setboot('test') - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo /sbin/reboot'] - try: - subprocess.check_call(cmd) - except: - pass - time.sleep(60) - - started = time.time() - # Retry for a while since we might still be rebooting - test_image_booted = False - while time.time() - started < 300: - try: - time.sleep(10) - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] - subprocess.check_call(cmd) - test_image_booted = self.is_test_image_booted() - except: - pass - if test_image_booted: - break - # Check again if we are in the master image - if not test_image_booted: - raise ProvisioningError("Failed to boot test image!") - - def is_test_image_booted(self): - """ - Check if the master image is booted. - - :returns: - True if the test image is currently booted, False otherwise. - :raises TimeoutError: - If the command times out - :raises CalledProcessError: - If the command fails - """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'snap -h'] - subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) - # If we get here, then the above command proved we are in snappy - return True - - def is_master_image_booted(self): - """ - Check if the master image is booted. - - :returns: - True if the master image is currently booted, False otherwise. - - .. note:: - The master contains the non-test image. - """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'cat /boot/grub/grub.cfg'] - # This is really just checking that the master image can be reached - # and has been configured with inception grub entries - try: - output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) - if 'laas_inception' in str(output): - return True - except: - # Don't raise any exceptions at this point, just return false - pass - return False - - def ensure_master_image(self): - """ - Actively switch the device to boot the test image. - - :raises RecoveryError: - If the command times out or anything else fails. - """ - master_booted = False - logger.info("Making sure the master image is booted") - try: - master_booted = self.is_master_image_booted() - except: - # don't worry if this doesn't work, we'll hard reset later - pass - - if not master_booted: - # We are not in the master image, so just hard reset - self.setboot('master') - self.hardreset() - - started = time.time() - while time.time() - started < 300: - try: - time.sleep(10) - master_booted = self.is_master_image_booted() - except: - pass - if master_booted: - break - # Check again if we are in the master image - if not master_booted: - raise RecoveryError("Could not reboot to master!") - - def flash_test_image(self, server_ip, server_port): - """ - Flash the image at :image_url to the sd card. - - :param server_ip: - IP address of the image server. The image will be downloaded and - gunzipped over the SD card. - :param server_port: - TCP port to connect to on server_ip for downloading the image - :raises ProvisioningError: - If the command times out or anything else fails. - """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( - server_ip, server_port, self.config['test_device'])] - logger.info("Running: %s", cmd) - try: - # XXX: I hope 30 min is enough? but maybe not! - subprocess.check_call(cmd, timeout=1800) - except: - raise ProvisioningError("timeout reached while flashing image!") From 49397efd894e80a4abd12f083958d7c4eb83985a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 27 Feb 2019 17:01:28 -0600 Subject: [PATCH 185/569] remove rpi2 device type --- devices/rpi2/__init__.py | 115 ----------------------- devices/rpi2/rpi2.py | 196 --------------------------------------- 2 files changed, 311 deletions(-) delete mode 100644 devices/rpi2/__init__.py delete mode 100644 devices/rpi2/rpi2.py diff --git a/devices/rpi2/__init__.py b/devices/rpi2/__init__.py deleted file mode 100644 index 791c7729..00000000 --- a/devices/rpi2/__init__.py +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Raspberry Pi 2 support code.""" - -import logging -import multiprocessing -import yaml - -import guacamole - -import snappy_device_agents -from devices.rpi2.rpi2 import RaspberryPi2 -from snappy_device_agents import logmsg, runcmd -from devices import (Catch, RecoveryError, ProvisioningError) - - -device_name = "rpi2" - - -class provision(guacamole.Command): - - """Tool for provisioning Raspberry Pi 2 with a given image.""" - - @Catch(RecoveryError, 46) - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - - device = RaspberryPi2(ctx.args.config) - logmsg(logging.INFO, "BEGIN provision") - logmsg(logging.INFO, "Booting Master Image") - device.ensure_master_image() - image = snappy_device_agents.get_image(ctx.args.job_data) - if not image: - raise ProvisioningError('Error downloading image') - server_ip = snappy_device_agents.get_local_ip_addr() - q = multiprocessing.Queue() - file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, args=(q, image,)) - file_server.start() - server_port = q.get() - logmsg(logging.INFO, "Flashing Test Image") - device.flash_sd(server_ip, server_port) - file_server.terminate() - logmsg(logging.INFO, "Booting Test Image") - device.ensure_test_image() - logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = 0 - for cmd in test_cmds: - # Settings from the device yaml configfile like device_ip can be - # formatted in test commands like "foo {device_ip}" - try: - cmd = cmd.format(**config) - except: - logmsg(logging.ERROR, "Unable to format command: %s", cmd) - - logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd) - if rc: - exitcode = 4 - logmsg(logging.WARNING, "Command failed, rc=%d", rc) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Raspberry Pi 2.""" - - sub_commands = ( - ('provision', provision), - ('runtest', runtest), - ) diff --git a/devices/rpi2/rpi2.py b/devices/rpi2/rpi2.py deleted file mode 100644 index 6de706fb..00000000 --- a/devices/rpi2/rpi2.py +++ /dev/null @@ -1,196 +0,0 @@ -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Raspberry Pi 2 support code.""" - -import logging -import subprocess -import time -import yaml - -from devices import (ProvisioningError, - RecoveryError) - - -logger = logging.getLogger() - - -class RaspberryPi2: - - """Snappy Device Agent for Raspberry Pi 2.""" - - def __init__(self, config): - with open(config) as configfile: - self.config = yaml.load(configfile) - - def setboot(self, mode): - """ - Set the boot mode of the device. - - :param mode: - One of 'master' or 'test' - :raises RecoveryError: - If the command times out or anything else fails. - - This method sets the snappy boot method to the specified value. - """ - if mode == 'master': - setboot_script = self.config['select_master_script'] - elif mode == 'test': - setboot_script = self.config['select_test_script'] - else: - raise KeyError - for cmd in setboot_script: - logger.info("Running %s", cmd) - try: - subprocess.check_call(cmd.split(), timeout=60) - except: - raise RecoveryError("timeout reaching control host!") - - def hardreset(self): - """ - Reboot the device. - - :raises RecoveryError: - If the command times out or anything else fails. - - .. note:: - This function executes ``bin/hardreset`` which is not a part of a - standard image. You need to provide it yourself. - """ - for cmd in self.config['reboot_script']: - logger.info("running %s", cmd) - try: - subprocess.check_call(cmd.split(), timeout=60) - except: - raise RecoveryError("timeout reaching control host!") - - def ensure_test_image(self): - """ - Actively switch the device to boot the test image. - - :raises ProvisioningError: - If the command times out or anything else fails. - """ - # FIXME: I don't have a great way to ensure we're in the test image - # yet, so just check that we're *not* in the master image - logger.info("Booting the test image") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo /sbin/halt'] - try: - subprocess.check_call(cmd) - except: - pass - time.sleep(60) - self.setboot('test') - self.hardreset() - - master_booted = False - started = time.time() - while time.time() - started < 300: - try: - master_booted = self.is_master_image_booted() - if master_booted is False: - break - except: - continue - # Check again if we are in the master image - if master_booted: - # XXX: This should *never* happen since we set the boot mode! - raise ProvisioningError( - "Still booting to master after flashing image!") - - def is_master_image_booted(self): - """ - Check if the master image is booted. - - :returns: - True if the master image is currently booted, False if another - image is booted - :raises CalledProcessError: - If the command exits with an error - :raises TimeoutExpired - If the command times out - - .. note:: - The master contains the non-test image. - """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'apt-get -h'] - # apt-get -h under snappy returns a warning rather than full help - output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) - if 'update' in str(output): - return True - else: - return False - - def ensure_master_image(self): - """ - Actively switch the device to boot the test image. - - :raises RecoveryError: - If the command times out or anything else fails. - """ - master_booted = False - logger.info("Making sure the master image is booted") - try: - master_booted = self.is_master_image_booted() - except: - # don't worry if this doesn't work, we'll hard reset later - pass - - if not master_booted: - # We are not in the master image, so just hard reset - self.setboot('master') - self.hardreset() - - started = time.time() - while time.time() - started < 300: - try: - master_booted = self.is_master_image_booted() - except: - continue - break - # Check again if we are in the master image - if not master_booted: - raise RecoveryError("Could not reboot to master!") - - def flash_sd(self, server_ip, server_port): - """ - Flash the image at :image_url to the sd card. - - :param server_ip: - IP address of the image server. The image will be downloaded and - gunzipped over the SD card. - :param server_port: - TCP port to connect to on server_ip for downloading the image - :raises ProvisioningError: - If the command times out or anything else fails. - """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'nc {} {}| gunzip| sudo dd of={} bs=16M'.format( - server_ip, server_port, self.config['test_device'])] - logger.info("Running: %s", cmd) - try: - # XXX: I hope 30 min is enough? but maybe not! - subprocess.check_call(cmd, timeout=1800) - except: - raise ProvisioningError("timeout reached while flashing image!") From 55ebcce70e71f86b1f5e4c8fb981f8d1ad60f0e8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 27 Feb 2019 17:02:14 -0600 Subject: [PATCH 186/569] remove touch device type --- devices/touch/__init__.py | 90 ------------------------ devices/touch/touch.py | 141 -------------------------------------- 2 files changed, 231 deletions(-) delete mode 100644 devices/touch/__init__.py delete mode 100644 devices/touch/touch.py diff --git a/devices/touch/__init__.py b/devices/touch/__init__.py deleted file mode 100644 index ecdf9119..00000000 --- a/devices/touch/__init__.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (C) 2016 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Ubuntu Touch support code.""" - -import logging -import os -import yaml - -import guacamole - -import snappy_device_agents -from devices.touch.touch import Touch -from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) - -device_name = "touch" - - -class provision(guacamole.Command): - - """Tool for provisioning baremetal with a given image.""" - - @Catch(RecoveryError, 46) - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - device = Touch(ctx.args.config, ctx.args.job_data) - logmsg(logging.INFO, "BEGIN provision") - logmsg(logging.INFO, "Recovering device") - device.recover() - device.provision() - logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = 0 - env = os.environ.copy() - env['ANDROID_SERIAL'] = config.get('serial') - exitcode = run_test_cmds(test_cmds, config, env) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Ubuntu Touch.""" - - sub_commands = ( - ('provision', provision), - ('runtest', runtest), - ) diff --git a/devices/touch/touch.py b/devices/touch/touch.py deleted file mode 100644 index 6d4c81e4..00000000 --- a/devices/touch/touch.py +++ /dev/null @@ -1,141 +0,0 @@ -# Copyright (C) 2016 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Ubuntu Touch support code.""" - -import json -import logging -import yaml - -from devices import (ProvisioningError, - RecoveryError) -from snappy_device_agents import download, runcmd - -logger = logging.getLogger() - - -class Touch: - - """Device Agent for Touch.""" - - def __init__(self, config, job_data): - with open(config) as configfile: - self.config = yaml.load(configfile) - with open(job_data) as j: - self.job_data = json.load(j) - - def recover(self): - recovery_script = self.config.get('recovery_script') - for cmd in recovery_script: - logger.info("Running %s", cmd) - rc = runcmd(cmd) - if rc: - raise RecoveryError("Device recovery failed!") - - def provision(self): - p = self.job_data.get('provision_data') - self.get_recovery_image() - - server = p.get('server', 'https://system-image.ubuntu.com') - - if p.get('revision'): - rev_arg = '--revision={}'.format(p.get('revision')) - else: - rev_arg = '' - - password = p.get('password', '0000') - - self.adb_reboot_bootloader() - - cmd = ('ubuntu-device-flash --server={} {} touch --serial={} ' - '--channel={} --device={} --recovery-image=recovery.img ' - '--developer-mode --password={} ' - '--bootstrap'.format(server, rev_arg, self.config.get('serial'), - p.get('channel'), - self.config.get('device_type'), password)) - logger.info('Running ubuntu-device-flash') - rc = runcmd(cmd) - if rc: - raise ProvisioningError("Flashing new image failed!") - self.adb_wait_for_device() - self.handle_welcome_wizard() - self.handle_edges_intro() - self.configure_network() - - def configure_network(self): - netspec = self.config.get('network_spec') - serial = self.config.get('serial') - if not netspec: - logger.warning('No network settings specified in the config') - return - logger.info('Configuring the network') - rc = runcmd('phablet-config -s {} network --write "{}"'.format( - serial, netspec)) - if rc: - logger.error('Error configuring network') - - def handle_welcome_wizard(self): - p = self.job_data.get('provision_data') - wizard = p.get('welcome_wizard', 'off') - if wizard.lower() == 'on': - logger.info('Welcome wizard will be left enabled') - return - - logger.info('Disabling the welcome wizard') - serial = self.config.get('serial') - cmd = ('phablet-config -s {} welcome-wizard ' - '--disable'.format(serial)) - rc = runcmd(cmd) - if rc: - raise ProvisioningError("Disable welcome wizard failed!") - self.adb_wait_for_device() - - def handle_edges_intro(self): - p = self.job_data.get('provision_data') - intro = p.get('edges_intro', 'off') - if intro.lower() == 'on': - logger.info('Edges intro will be left enabled') - return - - logger.info('Disabling the edges intro') - serial = self.config.get('serial') - cmd = ('phablet-config -s {} edges-intro ' - '--disable'.format(serial)) - rc = runcmd(cmd) - if rc: - raise ProvisioningError("Disable edges intro failed!") - self.adb_wait_for_device() - - def adb_reboot_bootloader(self): - serial = self.config.get('serial') - cmd = 'adb -s {} reboot-bootloader'.format(serial) - rc = runcmd(cmd) - if rc: - raise RecoveryError("Reboot to bootloader failed!") - # FIXME: we should probably attempt hard-recovery here - - def adb_wait_for_device(self): - serial = self.config.get('serial') - cmd = 'adb -s {} wait-for-device'.format(serial) - rc = runcmd(cmd) - if rc: - raise ProvisioningError("Wait for device failed!") - - def get_recovery_image(self): - device = self.config.get('device_type') - if not device: - raise ProvisioningError('No device_type specified in config') - url = ('http://people.canonical.com/~plars/touch/' - 'recovery-{}.img'.format(device)) - download(url, 'recovery.img') From 0beb9177599f10ac3ff5ac528d37c79ec0d43fcc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 Mar 2019 14:58:15 -0600 Subject: [PATCH 187/569] Slight change to the wording of job position when polling to hopefully make it more clear --- testflinger-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger-cli b/testflinger-cli index a7363300..0f8b92c8 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -179,7 +179,7 @@ def poll(ctx, job_id): queue_pos = conn.get_job_position(job_id) if int(queue_pos) != prev_queue_pos: prev_queue_pos = int(queue_pos) - print('Queue position: {}'.format(queue_pos)) + print('Jobs ahead in queue: {}'.format(queue_pos)) except Exception: # Ignore any bad response, this will retry pass From 30ff75b130ebe572be2d6e0a3db1e3d43d5a3437 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 Mar 2019 16:27:45 -0600 Subject: [PATCH 188/569] Runtest class is the same for all device agents, so share it --- devices/__init__.py | 30 +++++++++++++++++++++++++++++ devices/cm3/__init__.py | 33 +++++--------------------------- devices/dragonboard/__init__.py | 33 +++++--------------------------- devices/maas2/__init__.py | 33 +++++--------------------------- devices/muxpi/__init__.py | 33 +++++--------------------------- devices/netboot/__init__.py | 34 ++++++--------------------------- devices/noprovision/__init__.py | 33 +++++--------------------------- devices/oemrecovery/__init__.py | 33 +++++--------------------------- devices/rpi3/__init__.py | 33 +++++--------------------------- 9 files changed, 71 insertions(+), 224 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 7676d11e..a088af03 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -12,8 +12,12 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see +import guacamole import imp +import logging import os +import snappy_device_agents +import yaml class ProvisioningError(Exception): @@ -24,6 +28,31 @@ class RecoveryError(Exception): pass +class DefaultRuntest(guacamole.Command): + + """Tool for running tests on a provisioned device.""" + + def invoked(self, ctx): + """Method called when the command is invoked.""" + with open(ctx.args.config) as configfile: + config = yaml.load(configfile) + snappy_device_agents.configure_logging(config) + snappy_device_agents.logmsg(logging.INFO, "BEGIN testrun") + + test_opportunity = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + test_cmds = test_opportunity.get('test_data').get('test_cmds') + exitcode = snappy_device_agents.run_test_cmds(test_cmds, config) + snappy_device_agents.logmsg(logging.INFO, "END testrun") + return exitcode + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + def Catch(exception, returnval=0): """ Decorator for catching Exceptions and returning values instead @@ -55,5 +84,6 @@ def load_devices(): devices.append((module.device_name, module.DeviceAgent)) return tuple(devices) + if __name__ == '__main__': load_devices() diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py index 350ba6b2..e8b94c74 100644 --- a/devices/cm3/__init__.py +++ b/devices/cm3/__init__.py @@ -21,8 +21,10 @@ import snappy_device_agents from devices.cm3.cm3 import CM3 -from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) +from snappy_device_agents import logmsg +from devices import (Catch, + RecoveryError, + DefaultRuntest) device_name = "cm3" @@ -50,36 +52,11 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - class DeviceAgent(guacamole.Command): """Device agent for Ubuntu Raspberry PI cm3""" sub_commands = ( ('provision', provision), - ('runtest', runtest), + ('runtest', DefaultRuntest), ) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index eba83be5..34826c54 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -21,8 +21,10 @@ import snappy_device_agents from devices.dragonboard.dragonboard import Dragonboard -from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) +from snappy_device_agents import logmsg +from devices import (Catch, + RecoveryError, + DefaultRuntest) device_name = "dragonboard" @@ -50,36 +52,11 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - class DeviceAgent(guacamole.Command): """Device agent for Dragonboard.""" sub_commands = ( ('provision', provision), - ('runtest', runtest), + ('runtest', DefaultRuntest), ) diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index e4cb3e8f..2efa4023 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -21,8 +21,10 @@ import snappy_device_agents from devices.maas2.maas2 import Maas2 -from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) +from snappy_device_agents import logmsg +from devices import (Catch, + RecoveryError, + DefaultRuntest) device_name = "maas2" @@ -52,36 +54,11 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - class DeviceAgent(guacamole.Command): """Device agent for Ubuntu MaaS 2.0 CLI.""" sub_commands = ( ('provision', provision), - ('runtest', runtest), + ('runtest', DefaultRuntest), ) diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py index 4a0e7f8a..2cba405a 100644 --- a/devices/muxpi/__init__.py +++ b/devices/muxpi/__init__.py @@ -21,8 +21,10 @@ import snappy_device_agents from devices.muxpi.muxpi import MuxPi -from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) +from snappy_device_agents import logmsg +from devices import (Catch, + RecoveryError, + DefaultRuntest) device_name = "muxpi" @@ -50,36 +52,11 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - class DeviceAgent(guacamole.Command): """Device agent for Ubuntu Raspberry PI muxpi""" sub_commands = ( ('provision', provision), - ('runtest', runtest), + ('runtest', DefaultRuntest), ) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index e62635b5..1084c8e5 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -22,9 +22,12 @@ import snappy_device_agents from devices.netboot.netboot import Netboot -from snappy_device_agents import logmsg, run_test_cmds +from snappy_device_agents import logmsg -from devices import (Catch, ProvisioningError, RecoveryError) +from devices import (Catch, + ProvisioningError, + RecoveryError, + DefaultRuntest) device_name = "netboot" @@ -81,36 +84,11 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - class DeviceAgent(guacamole.Command): """Device agent for Netboot.""" sub_commands = ( ('provision', provision), - ('runtest', runtest), + ('runtest', DefaultRuntest), ) diff --git a/devices/noprovision/__init__.py b/devices/noprovision/__init__.py index cc46fe5f..9d6a9800 100644 --- a/devices/noprovision/__init__.py +++ b/devices/noprovision/__init__.py @@ -21,9 +21,11 @@ import snappy_device_agents from devices.noprovision.noprovision import Noprovision -from snappy_device_agents import logmsg, run_test_cmds +from snappy_device_agents import logmsg -from devices import (Catch, RecoveryError) +from devices import (Catch, + RecoveryError, + DefaultRuntest) device_name = "noprovision" @@ -52,36 +54,11 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - class DeviceAgent(guacamole.Command): """Device agent for Noprovision.""" sub_commands = ( ('provision', provision), - ('runtest', runtest), + ('runtest', DefaultRuntest), ) diff --git a/devices/oemrecovery/__init__.py b/devices/oemrecovery/__init__.py index 23d54a55..53fa3c88 100644 --- a/devices/oemrecovery/__init__.py +++ b/devices/oemrecovery/__init__.py @@ -21,8 +21,10 @@ import snappy_device_agents from devices.oemrecovery.oemrecovery import OemRecovery -from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) +from snappy_device_agents import logmsg +from devices import (Catch, + RecoveryError, + DefaultRuntest) device_name = "oemrecovery" @@ -50,36 +52,11 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - class DeviceAgent(guacamole.Command): """Device agent for OEM Recovery""" sub_commands = ( ('provision', provision), - ('runtest', runtest), + ('runtest', DefaultRuntest), ) diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py index 019c6c19..1e542336 100644 --- a/devices/rpi3/__init__.py +++ b/devices/rpi3/__init__.py @@ -21,8 +21,10 @@ import snappy_device_agents from devices.rpi3.rpi3 import Rpi3 -from snappy_device_agents import logmsg, run_test_cmds -from devices import (Catch, RecoveryError) +from snappy_device_agents import logmsg +from devices import (Catch, + RecoveryError, + DefaultRuntest) device_name = "rpi3" @@ -50,36 +52,11 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') -class runtest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: - config = yaml.load(configfile) - snappy_device_agents.configure_logging(config) - logmsg(logging.INFO, "BEGIN testrun") - - test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = run_test_cmds(test_cmds, config) - logmsg(logging.INFO, "END testrun") - return exitcode - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - class DeviceAgent(guacamole.Command): """Device agent for Rpi3.""" sub_commands = ( ('provision', provision), - ('runtest', runtest), + ('runtest', DefaultRuntest), ) From 6474bfa4339241e1eb74accf589ce787d4a0bbc2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 7 Mar 2019 10:11:43 -0600 Subject: [PATCH 189/569] Some annoying warning with the new pyyaml for yaml.load() can be avoided by simply using safe_load() --- devices/__init__.py | 2 +- devices/cm3/__init__.py | 2 +- devices/cm3/cm3.py | 2 +- devices/dragonboard/__init__.py | 2 +- devices/dragonboard/dragonboard.py | 2 +- devices/maas2/__init__.py | 2 +- devices/maas2/maas2.py | 2 +- devices/muxpi/__init__.py | 2 +- devices/muxpi/muxpi.py | 2 +- devices/netboot/__init__.py | 2 +- devices/netboot/netboot.py | 2 +- devices/noprovision/__init__.py | 2 +- devices/noprovision/noprovision.py | 2 +- devices/oemrecovery/__init__.py | 2 +- devices/oemrecovery/oemrecovery.py | 2 +- devices/rpi3/__init__.py | 2 +- devices/rpi3/rpi3.py | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index a088af03..ad411f60 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -35,7 +35,7 @@ class DefaultRuntest(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) snappy_device_agents.logmsg(logging.INFO, "BEGIN testrun") diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py index e8b94c74..7b4d44f9 100644 --- a/devices/cm3/__init__.py +++ b/devices/cm3/__init__.py @@ -37,7 +37,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = CM3(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index c490d6f6..7a25fbad 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -32,7 +32,7 @@ class CM3: def __init__(self, config, job_data): with open(config) as configfile: - self.config = yaml.load(configfile) + self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 34826c54..606342df 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -37,7 +37,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = Dragonboard(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index ee42fe94..3de4c058 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -35,7 +35,7 @@ class Dragonboard: def __init__(self, config, job_data): with open(config) as configfile: - self.config = yaml.load(configfile) + self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index 2efa4023..f5661962 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -37,7 +37,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = Maas2(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 865909ee..de9ec208 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -33,7 +33,7 @@ class Maas2: def __init__(self, config, job_data): with open(config) as configfile: - self.config = yaml.load(configfile) + self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py index 2cba405a..61ef2874 100644 --- a/devices/muxpi/__init__.py +++ b/devices/muxpi/__init__.py @@ -37,7 +37,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = MuxPi(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 763c97e0..6f71b51d 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -36,7 +36,7 @@ class MuxPi: def __init__(self, config, job_data): with open(config) as configfile: - self.config = yaml.load(configfile) + self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 1084c8e5..3f6f1b57 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -40,7 +40,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = Netboot(ctx.args.config) image = snappy_device_agents.get_image(ctx.args.job_data) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index d252f53a..7fad98f4 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -34,7 +34,7 @@ class Netboot: def __init__(self, config): with open(config) as configfile: - self.config = yaml.load(configfile) + self.config = yaml.safe_load(configfile) def setboot(self, mode): """ diff --git a/devices/noprovision/__init__.py b/devices/noprovision/__init__.py index 9d6a9800..4f4a40a5 100644 --- a/devices/noprovision/__init__.py +++ b/devices/noprovision/__init__.py @@ -38,7 +38,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = Noprovision(ctx.args.config) test_username = snappy_device_agents.get_test_username( diff --git a/devices/noprovision/noprovision.py b/devices/noprovision/noprovision.py index 500d3dac..426866e0 100644 --- a/devices/noprovision/noprovision.py +++ b/devices/noprovision/noprovision.py @@ -31,7 +31,7 @@ class Noprovision: def __init__(self, config): with open(config) as configfile: - self.config = yaml.load(configfile) + self.config = yaml.safe_load(configfile) def hardreset(self): """ diff --git a/devices/oemrecovery/__init__.py b/devices/oemrecovery/__init__.py index 53fa3c88..6aaeb9f5 100644 --- a/devices/oemrecovery/__init__.py +++ b/devices/oemrecovery/__init__.py @@ -37,7 +37,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = OemRecovery(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 55dff2be..4019e02d 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -33,7 +33,7 @@ class OemRecovery: def __init__(self, config, job_data): with open(config) as configfile: - self.config = yaml.load(configfile) + self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py index 1e542336..8e731c18 100644 --- a/devices/rpi3/__init__.py +++ b/devices/rpi3/__init__.py @@ -37,7 +37,7 @@ class provision(guacamole.Command): def invoked(self, ctx): """Method called when the command is invoked.""" with open(ctx.args.config) as configfile: - config = yaml.load(configfile) + config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = Rpi3(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index 48d59931..c6baf7bf 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -35,7 +35,7 @@ class Rpi3: def __init__(self, config, job_data): with open(config) as configfile: - self.config = yaml.load(configfile) + self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) From 169f966d86f7e4b330794be372365d795aa066c8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 8 Mar 2019 14:19:23 -0600 Subject: [PATCH 190/569] Allow the cli to be used for setting job state to cancelled --- testflinger-cli | 28 ++++++++++++++++++++++++++++ testflinger_cli/__init__.py | 15 ++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/testflinger-cli b/testflinger-cli index 0f8b92c8..7142e04c 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -66,6 +66,34 @@ def status(ctx, job_id): print(job_state) +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def cancel(ctx, job_id): + conn = ctx.obj['conn'] + try: + job_state = conn.get_status(job_id) + except testflinger_cli.HTTPError as e: + if e.status == 204: + print('Job {} not found. Check the job id to be sure it is ' + 'correct.'.format(job_id)) + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct.') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + except Exception: + print('Error communicating with server, check connection and retry') + sys.exit(1) + if job_state in ('complete', 'cancelled'): + print('Job {} is already in {} state and cannot be cancelled.'.format( + job_id, job_state)) + sys.exit(1) + conn.post_job_state(job_id, 'cancelled') + + @cli.command() @click.argument('filename', nargs=1) @click.option('--quiet', '-q', is_flag=True) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 96ff880e..1866b637 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -78,12 +78,25 @@ def get_status(self, job_id): ID for the test job :return: String containing the job_state for the specified ID - (waiting, setup, provision, test, complete) + (waiting, setup, provision, test, reserved, released, + cancelled, complete) """ endpoint = '/v1/result/{}'.format(job_id) data = json.loads(self.get(endpoint)) return data.get('job_state') + def post_job_state(self, job_id, state): + """Post the status of a test job + + :param job_id: + ID for the test job + :param state: + Job state to set for the specified job + """ + endpoint = '/v1/result/{}'.format(job_id) + data = dict(job_state=state) + self.put(endpoint, data) + def submit_job(self, job_data): """Submit a test job to the testflinger server From 7824d8dbb483bae64e6dd4c5c4e84bf66640e6a0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 13 Mar 2019 10:09:29 -0500 Subject: [PATCH 191/569] Extend the muxpi provisioning timeout because some devices take a bit longer --- devices/muxpi/muxpi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 6f71b51d..e00a9a5a 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -192,7 +192,7 @@ def check_test_image_booted(self): 'test_data').get('test_username', 'ubuntu') test_password = self.job_data.get( 'test_data').get('test_password', 'ubuntu') - while time.time() - started < 300: + while time.time() - started < 600: try: time.sleep(10) cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', From 910ff0d9bfddb5ee6077e7c3da3f5c93f728bbad Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 13 Mar 2019 14:01:49 -0500 Subject: [PATCH 192/569] Add a client method that checks the current state of the job --- testflinger_agent/client.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 1d634900..9dc09273 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -91,6 +91,27 @@ def post_result(self, job_id, data): (result_uri, job_request.status_code)) raise TFServerError(job_request.status_code) + def get_result(self, job_id): + """Get current results data to the testflinger server for this job + + :param job_id: + id for the job on which we want to post results + :param data: + dict with data to be posted in json or an empty dict if + there was an error + """ + result_uri = urljoin(self.config.get('server'), '/v1/result/') + result_uri = urljoin(result_uri, job_id) + job_request = requests.get(result_uri) + if job_request.status_code != 200: + logger.error('Unable to get results from: %s (error: %s)' % + (result_uri, job_request.status_code)) + return {} + if job_request.content: + return job_request.json() + else: + return {} + def transmit_job_outcome(self, rundir): """Post job outcome json data to the testflinger server From 828f826861d2fe0d9e53cd0227a15d2dc3942baf Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 13 Mar 2019 14:03:34 -0500 Subject: [PATCH 193/569] If we receive SIGTERM while processing a job, kill the running process and exit immediately --- testflinger_agent/job.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 79c1dbe5..849ba1ae 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -17,6 +17,7 @@ import logging import os import select +import signal import sys import subprocess import time @@ -61,14 +62,15 @@ def run_test_phase(self, phase, rundir): logger.info('Running %s_command: %s', phase, cmd) # Set the exitcode to some failed status in case we get interrupted exitcode = 99 - for line in self.banner('Starting testflinger {} phase on {}'.format(phase, node)): + + for line in self.banner( + 'Starting testflinger {} phase on {}'.format(phase, node)): self.run_with_log("echo '{}'".format(line), phase_log, rundir) try: exitcode = self.run_with_log(cmd, phase_log, rundir) except Exception as e: logger.exception(e) finally: - # Save the output log in the json file no matter what with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: outcome_data = json.load(f) if os.path.exists(phase_log): @@ -78,7 +80,7 @@ def run_test_phase(self, phase, rundir): with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w', encoding='utf-8') as f: json.dump(outcome_data, f) - return exitcode + sys.exit(exitcode) def run_with_log(self, cmd, logfile, cwd=None): """Execute command in a subprocess and log the output @@ -102,6 +104,10 @@ def run_with_log(self, cmd, logfile, cwd=None): process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=cwd) + + def cleanup(signum, frame): + process.kill() + signal.signal(signal.SIGTERM, cleanup) set_nonblock(process.stdout.fileno()) readpoll.register(process.stdout, select.POLLIN) while process.poll() is None: From bb1fda4a99a6ed115305de4ace6881b1414a0c9b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 13 Mar 2019 14:04:33 -0500 Subject: [PATCH 194/569] Execute the test phase as a separate process. Look for requests to cancel the test job, and handle them in an appropriate fashion for the current test phase. --- testflinger_agent/agent.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 7189631b..cb132462 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -14,6 +14,7 @@ import json import logging +import multiprocessing import os import shutil @@ -36,6 +37,11 @@ def get_offline_file(self): def check_offline(self): return os.path.exists(self.get_offline_file()) + def check_job_state(self, job_id): + job_data = self.client.get_result(job_id) + if job_data: + return job_data.get('job_state') + def mark_device_offline(self): # Create the offline file, this should work even if it exists open(self.get_offline_file(), 'w').close() @@ -63,20 +69,27 @@ def process_jobs(self): json.dump({}, f) for phase in TEST_PHASES: + # First make sure the job hasn't been cancelled + if (self.check_job_state(job.job_id) == 'cancelled' and + phase != 'cleanup'): + logger.info("Job cancellation was requested, exiting.") + break # Try to update the job_state on the testflinger server try: self.client.post_result(job.job_id, {'job_state': phase}) except TFServerError: pass - try: - exitcode = job.run_test_phase(phase, rundir) - except Exception as e: - # If we hit some unknown exception, preserve results, - # log the exception, and stop execution - logger.exception(e) - results_basedir = self.client.config.get('results_basedir') - shutil.move(rundir, results_basedir) - return + proc = multiprocessing.Process(target=job.run_test_phase, + args=(phase, rundir,)) + proc.start() + while proc.is_alive(): + proc.join(10) + if (self.check_job_state(job.job_id) == 'cancelled' and + phase != 'provision'): + logger.info("Job cancellation was requested, exiting.") + proc.terminate() + exitcode = proc.exitcode + # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline if exitcode == 46: From da7a7e59183415f0ac59e188f404a943d60208c7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 14 Mar 2019 19:37:48 -0500 Subject: [PATCH 195/569] Make cleanup always run, even if the job is cancelled --- testflinger_agent/agent.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index cb132462..14292bbe 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -48,7 +48,7 @@ def mark_device_offline(self): def process_jobs(self): """Coordinate checking for new jobs and handling them if they exists""" - TEST_PHASES = ['setup', 'provision', 'test', 'cleanup'] + TEST_PHASES = ['setup', 'provision', 'test'] # First, see if we have any old results that we couldn't send last time self.retry_old_results() @@ -70,8 +70,7 @@ def process_jobs(self): for phase in TEST_PHASES: # First make sure the job hasn't been cancelled - if (self.check_job_state(job.job_id) == 'cancelled' and - phase != 'cleanup'): + if self.check_job_state(job.job_id) == 'cancelled': logger.info("Job cancellation was requested, exiting.") break # Try to update the job_state on the testflinger server @@ -101,6 +100,10 @@ def process_jobs(self): if exitcode: logger.debug('Phase %s failed, aborting job' % phase) break + + # Always run the cleanup, even if the job was cancelled + job.run_test_phase('cleanup', rundir) + try: self.client.transmit_job_outcome(rundir) except Exception as e: From 1aa99fe5f64ffbcafe01c9a467d4d3e73c5ba8ea Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 14 Mar 2019 19:57:03 -0500 Subject: [PATCH 196/569] Allow the agent to also process a test phase called 'reserve' --- testflinger_agent/agent.py | 2 +- testflinger_agent/schema.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 14292bbe..15075dfb 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -48,7 +48,7 @@ def mark_device_offline(self): def process_jobs(self): """Coordinate checking for new jobs and handling them if they exists""" - TEST_PHASES = ['setup', 'provision', 'test'] + TEST_PHASES = ['setup', 'provision', 'test', 'reserve'] # First, see if we have any old results that we couldn't send last time self.retry_old_results() diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index e5c43373..de1106b5 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -30,6 +30,7 @@ voluptuous.Required('setup_command', default=''): str, voluptuous.Required('provision_command', default=''): str, voluptuous.Required('test_command', default=''): str, + voluptuous.Required('reserve_command', default=''): str, voluptuous.Required('cleanup_command', default=''): str, voluptuous.Optional('global_timeout'): int, voluptuous.Optional('output_timeout'): int, From 586e5b9ef2d0b3c6151047a05b4d3b2f5f7658fc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 14 Mar 2019 22:11:19 -0500 Subject: [PATCH 197/569] Ignore global_timeout checks if we are in reserve phase --- testflinger_agent/job.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 849ba1ae..ec194f06 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -138,7 +138,8 @@ def cleanup(signum, frame): f.write(buf) process.kill() break - if time.time() - start_time > global_timeout: + if (self.phase != 'reserve' and + time.time() - start_time > global_timeout): buf = '\nERROR: Global timeout reached! ({}s)\n'.format( global_timeout) live_output_buffer += buf From c84b5c14a2a7682a2f3348b5d559f7392b540016 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 14 Mar 2019 22:13:20 -0500 Subject: [PATCH 198/569] Output should be posted anytime there is anthing in the buffer once the interval is reached --- testflinger_agent/job.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index ec194f06..6df5faca 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -118,17 +118,6 @@ def cleanup(signum, frame): if buf: sys.stdout.write(buf) live_output_buffer += buf - # Don't spam the server, only flush the buffer if there - # is output and it's been more than 10s - if time.time() - buffer_timeout > 10: - buffer_timeout = time.time() - # Try to stream output, if we can't connect, then - # keep buffer for the next pass through this - if self.client.post_live_output( - self.job_id, live_output_buffer): - live_output_buffer = '' - f.write(buf) - f.flush() else: if (self.phase == 'test' and time.time() - buffer_timeout > output_timeout): @@ -146,6 +135,17 @@ def cleanup(signum, frame): f.write(buf) process.kill() break + # Don't spam the server, only flush the buffer if there + # is output and it's been more than 10s + if live_output_buffer and time.time() - buffer_timeout > 10: + buffer_timeout = time.time() + # Try to stream output, if we can't connect, then + # keep buffer for the next pass through this + if self.client.post_live_output( + self.job_id, live_output_buffer): + live_output_buffer = '' + f.write(buf) + f.flush() buf = process.stdout.read() if buf: buf = buf.decode(sys.stdout.encoding) From 73c81dfe2ce96734766ff1cd2c64d4e9fcfbe864 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 14 Mar 2019 22:29:36 -0500 Subject: [PATCH 199/569] Add a reserve command to the device agent --- devices/__init__.py | 59 +++++++++++++++++++++++++++++++++ devices/cm3/__init__.py | 2 ++ devices/dragonboard/__init__.py | 2 ++ devices/maas2/__init__.py | 2 ++ devices/muxpi/__init__.py | 2 ++ devices/netboot/__init__.py | 2 ++ devices/noprovision/__init__.py | 2 ++ devices/oemrecovery/__init__.py | 2 ++ devices/rpi3/__init__.py | 2 ++ 9 files changed, 75 insertions(+) diff --git a/devices/__init__.py b/devices/__init__.py index ad411f60..12864ccc 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -17,8 +17,12 @@ import logging import os import snappy_device_agents +import subprocess +import time import yaml +from datetime import datetime, timedelta + class ProvisioningError(Exception): pass @@ -53,6 +57,61 @@ def register_arguments(self, parser): parser.add_argument('job_data', help='Testflinger json data file') +class DefaultReserve(guacamole.Command): + + """Block this system while it is reserved for manual use""" + + def invoked(self, ctx): + with open(ctx.args.config) as configfile: + config = yaml.safe_load(configfile) + snappy_device_agents.logmsg(logging.INFO, "BEGIN reservation") + snappy_device_agents.configure_logging(config) + snappy_device_agents.logmsg(logging.INFO, "BEGIN reservation") + job_data = snappy_device_agents.get_test_opportunity( + ctx.args.job_data) + try: + test_username = job_data['test_data']['test_username'] + except KeyError: + test_username = 'ubuntu' + device_ip = config['device_ip'] + reservation_data = job_data['reservation_data'] + ssh_keys = reservation_data.get('ssh_keys', []) + # default reservation timeout is 1 hour + timeout = reservation_data.get('timeout', '3600') + for key in ssh_keys: + try: + os.unlink('key.pub') + except FileNotFoundError: + pass + cmd = ['ssh-import-id', '-o', 'key.pub', key] + proc = subprocess.run(cmd) + if proc.returncode != 0: + print('Unable to import ssh key from:', key) + continue + cmd = ['ssh-copy-id', '-f', '-i', 'key.pub', + '{}@{}'.format(test_username, device_ip)] + proc = subprocess.run(cmd) + if proc.returncode != 0: + print('Problem copying ssh key to target device for:', key) + print('*** TESTFLINGER SYSTEM RESERVED ***') + print('You can now connect to {}@{}'.format(test_username, device_ip)) + now = datetime.utcnow().isoformat() + expire_time = (datetime.utcnow() + timedelta(seconds=3600)).isoformat() + print('Current time: [{}]'.format(now)) + print('Reservation expires at: [{}]'.format(expire_time)) + print('Reservation will automatically timeout in {} ' + 'seconds'.format(timeout)) + print('To end the reservation sooner use: testflinger-cli ' + 'cancel ') + time.sleep(int(timeout)) + + def register_arguments(self, parser): + """Method called to customize the argument parser.""" + parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + parser.add_argument('job_data', help='Testflinger json data file') + + def Catch(exception, returnval=0): """ Decorator for catching Exceptions and returning values instead diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py index 7b4d44f9..2f11cef2 100644 --- a/devices/cm3/__init__.py +++ b/devices/cm3/__init__.py @@ -24,6 +24,7 @@ from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, + DefaultReserve, DefaultRuntest) device_name = "cm3" @@ -58,5 +59,6 @@ class DeviceAgent(guacamole.Command): sub_commands = ( ('provision', provision), + ('reserve', DefaultReserve), ('runtest', DefaultRuntest), ) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 606342df..2c4b4535 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -24,6 +24,7 @@ from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, + DefaultReserve, DefaultRuntest) device_name = "dragonboard" @@ -58,5 +59,6 @@ class DeviceAgent(guacamole.Command): sub_commands = ( ('provision', provision), + ('reserve', DefaultReserve), ('runtest', DefaultRuntest), ) diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index f5661962..f8339570 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -24,6 +24,7 @@ from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, + DefaultReserve, DefaultRuntest) device_name = "maas2" @@ -60,5 +61,6 @@ class DeviceAgent(guacamole.Command): sub_commands = ( ('provision', provision), + ('reserve', DefaultReserve), ('runtest', DefaultRuntest), ) diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py index 61ef2874..c22deaee 100644 --- a/devices/muxpi/__init__.py +++ b/devices/muxpi/__init__.py @@ -24,6 +24,7 @@ from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, + DefaultReserve, DefaultRuntest) device_name = "muxpi" @@ -58,5 +59,6 @@ class DeviceAgent(guacamole.Command): sub_commands = ( ('provision', provision), + ('reserve', DefaultReserve), ('runtest', DefaultRuntest), ) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 3f6f1b57..50e5e7e6 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -27,6 +27,7 @@ from devices import (Catch, ProvisioningError, RecoveryError, + DefaultReserve, DefaultRuntest) device_name = "netboot" @@ -90,5 +91,6 @@ class DeviceAgent(guacamole.Command): sub_commands = ( ('provision', provision), + ('reserve', DefaultReserve), ('runtest', DefaultRuntest), ) diff --git a/devices/noprovision/__init__.py b/devices/noprovision/__init__.py index 4f4a40a5..e625fe1f 100644 --- a/devices/noprovision/__init__.py +++ b/devices/noprovision/__init__.py @@ -25,6 +25,7 @@ from devices import (Catch, RecoveryError, + DefaultReserve, DefaultRuntest) device_name = "noprovision" @@ -60,5 +61,6 @@ class DeviceAgent(guacamole.Command): sub_commands = ( ('provision', provision), + ('reserve', DefaultReserve), ('runtest', DefaultRuntest), ) diff --git a/devices/oemrecovery/__init__.py b/devices/oemrecovery/__init__.py index 6aaeb9f5..2f88991f 100644 --- a/devices/oemrecovery/__init__.py +++ b/devices/oemrecovery/__init__.py @@ -24,6 +24,7 @@ from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, + DefaultReserve, DefaultRuntest) device_name = "oemrecovery" @@ -58,5 +59,6 @@ class DeviceAgent(guacamole.Command): sub_commands = ( ('provision', provision), + ('reserve', DefaultReserve), ('runtest', DefaultRuntest), ) diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py index 8e731c18..06118de6 100644 --- a/devices/rpi3/__init__.py +++ b/devices/rpi3/__init__.py @@ -24,6 +24,7 @@ from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, + DefaultReserve, DefaultRuntest) device_name = "rpi3" @@ -58,5 +59,6 @@ class DeviceAgent(guacamole.Command): sub_commands = ( ('provision', provision), + ('reserve', DefaultReserve), ('runtest', DefaultRuntest), ) From 2b870388801fb99537b0e3e55403d980e5edb658 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 09:29:25 -0500 Subject: [PATCH 200/569] Remove extra logging message for start of reservation --- devices/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/devices/__init__.py b/devices/__init__.py index 12864ccc..ced3f1bf 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -64,7 +64,6 @@ class DefaultReserve(guacamole.Command): def invoked(self, ctx): with open(ctx.args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.logmsg(logging.INFO, "BEGIN reservation") snappy_device_agents.configure_logging(config) snappy_device_agents.logmsg(logging.INFO, "BEGIN reservation") job_data = snappy_device_agents.get_test_opportunity( From 15a86b4a8e07024e629e55d6a6c784c82110cc55 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 09:54:44 -0500 Subject: [PATCH 201/569] Use request object as a boolean rather than checking the status from it --- testflinger_agent/client.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 9dc09273..7d0f205b 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -70,7 +70,7 @@ def repost_job(self, job_data): Resubmitting job: {}\n""".format(job_id) self.post_live_output(job_id, job_output) job_request = requests.post(job_uri, json=job_data) - if job_request.status_code != 200: + if not job_request: logger.error('Unable to re-post job to: %s (error: %s)' % (job_uri, job_request.status_code)) raise TFServerError(job_request.status_code) @@ -86,7 +86,7 @@ def post_result(self, job_id, data): result_uri = urljoin(self.config.get('server'), '/v1/result/') result_uri = urljoin(result_uri, job_id) job_request = requests.post(result_uri, json=data) - if job_request.status_code != 200: + if not job_request: logger.error('Unable to post results to: %s (error: %s)' % (result_uri, job_request.status_code)) raise TFServerError(job_request.status_code) @@ -103,7 +103,7 @@ def get_result(self, job_id): result_uri = urljoin(self.config.get('server'), '/v1/result/') result_uri = urljoin(result_uri, job_id) job_request = requests.get(result_uri) - if job_request.status_code != 200: + if not job_request: logger.error('Unable to get results from: %s (error: %s)' % (result_uri, job_request.status_code)) return {} @@ -148,7 +148,7 @@ def transmit_job_outcome(self, rundir): 'file': ('file', tarball, 'application/x-gzip')} artifact_request = requests.post( artifact_uri, files=file_upload) - if artifact_request.status_code != 200: + if not artifact_request: logger.error('Unable to post results to: %s (error: %s)' % (artifact_uri, artifact_request.status_code)) raise TFServerError(artifact_request.status_code) @@ -172,6 +172,4 @@ def post_live_output(self, job_id, data): except Exception as e: logger.exception(e) return False - if job_request.status_code != 200: - return False - return True + return bool(job_request) From adf66c3ef6f5a9359c9a8405be02d2ef76990a4b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 16:01:49 -0500 Subject: [PATCH 202/569] If no reserve_data exists, skip that section like we do for provision --- testflinger_agent/job.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 6df5faca..2bb9e6c1 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -58,6 +58,8 @@ def run_test_phase(self, phase, rundir): if phase == 'provision' and 'provision_data' not in self.job_data: logger.info('No provision_data defined in job data, skipping...') return 0 + if phase == 'reserve' and 'reserve_data' not in self.job_data: + return 0 phase_log = os.path.join(rundir, phase+'.log') logger.info('Running %s_command: %s', phase, cmd) # Set the exitcode to some failed status in case we get interrupted From ffcf8edc123c8467156ea0e1ad66e75178fcce9b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 16:02:14 -0500 Subject: [PATCH 203/569] run_test_phase needs to be handled as a sub process now because it exits. Don't call it directly --- testflinger_agent/agent.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 15075dfb..6b71e640 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -102,6 +102,10 @@ def process_jobs(self): break # Always run the cleanup, even if the job was cancelled + proc = multiprocessing.Process(target=job.run_test_phase, + args=('cleanup', rundir,)) + proc.start() + proc.join() job.run_test_phase('cleanup', rundir) try: From b34daeda90fd2d1b33bbcdd03b437db9e41b71e7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 16:03:35 -0500 Subject: [PATCH 204/569] Use reserve_data instead of reservation_data to be consistent --- devices/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index ced3f1bf..c677b89b 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -73,10 +73,10 @@ def invoked(self, ctx): except KeyError: test_username = 'ubuntu' device_ip = config['device_ip'] - reservation_data = job_data['reservation_data'] - ssh_keys = reservation_data.get('ssh_keys', []) + reserve_data = job_data['reserve_data'] + ssh_keys = reserve_data.get('ssh_keys', []) # default reservation timeout is 1 hour - timeout = reservation_data.get('timeout', '3600') + timeout = reserve_data.get('timeout', '3600') for key in ssh_keys: try: os.unlink('key.pub') From ee9417ba850c5eb8b342ab69c0e983d7e9b65e39 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 19:54:33 -0500 Subject: [PATCH 205/569] Skip test section if no test_data is defined --- testflinger_agent/job.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 2bb9e6c1..1ea42951 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -58,6 +58,9 @@ def run_test_phase(self, phase, rundir): if phase == 'provision' and 'provision_data' not in self.job_data: logger.info('No provision_data defined in job data, skipping...') return 0 + if phase == 'test' and 'test_data' not in self.job_data: + logger.info('No test_data defined in job data, skipping...') + return 0 if phase == 'reserve' and 'reserve_data' not in self.job_data: return 0 phase_log = os.path.join(rundir, phase+'.log') From b286a8cbf0755f8099ec395e6b4141b62c4c53b5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 20:02:36 -0500 Subject: [PATCH 206/569] Don't skip reservation if the test phase fails --- testflinger_agent/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 6b71e640..0d308b45 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -97,7 +97,7 @@ def process_jobs(self): shutil.rmtree(rundir) # Return NOW so we don't keep trying to process jobs return - if exitcode: + if phase != 'test' and exitcode: logger.debug('Phase %s failed, aborting job' % phase) break From 9124615788107e53010092ef00185c6d5c351c3c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 20:51:39 -0500 Subject: [PATCH 207/569] Remove extra call to run_test_phase --- testflinger_agent/agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 0d308b45..220926cb 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -106,7 +106,6 @@ def process_jobs(self): args=('cleanup', rundir,)) proc.start() proc.join() - job.run_test_phase('cleanup', rundir) try: self.client.transmit_job_outcome(rundir) From 721697038acbd604ccb0d2fdc1eff047c21a8123 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 15 Mar 2019 20:55:33 -0500 Subject: [PATCH 208/569] Handle empty test_data if it doesn't exist --- devices/cm3/cm3.py | 4 ++-- devices/dragonboard/dragonboard.py | 4 ++-- devices/muxpi/muxpi.py | 4 ++-- devices/oemrecovery/oemrecovery.py | 6 +++--- devices/rpi3/rpi3.py | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index 7a25fbad..f65de7a6 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -94,9 +94,9 @@ def check_test_image_booted(self): started = time.time() # Retry for a while since we might still be rebooting test_username = self.job_data.get( - 'test_data').get('test_username', 'ubuntu') + 'test_data', {}).get('test_username', 'ubuntu') test_password = self.job_data.get( - 'test_data').get('test_password', 'ubuntu') + 'test_data', {}).get('test_password', 'ubuntu') while time.time() - started < 300: try: time.sleep(10) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index 3de4c058..be2e512f 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -359,9 +359,9 @@ def provision(self): raise ProvisioningError("Error copying system-user assertion") image_file = snappy_device_agents.compress_file('snappy.img') test_username = self.job_data.get( - 'test_data').get('test_username', 'ubuntu') + 'test_data', {}).get('test_username', 'ubuntu') test_password = self.job_data.get( - 'test_data').get('test_password', 'ubuntu') + 'test_data', {}).get('test_password', 'ubuntu') server_ip = snappy_device_agents.get_local_ip_addr() serve_q = multiprocessing.Queue() file_server = multiprocessing.Process( diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index e00a9a5a..998ca1fb 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -189,9 +189,9 @@ def check_test_image_booted(self): started = time.time() # Retry for a while since we might still be rebooting test_username = self.job_data.get( - 'test_data').get('test_username', 'ubuntu') + 'test_data', {}).get('test_username', 'ubuntu') test_password = self.job_data.get( - 'test_data').get('test_password', 'ubuntu') + 'test_data', {}).get('test_password', 'ubuntu') while time.time() - started < 600: try: time.sleep(10) diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 4019e02d..0cfbb8b6 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -50,7 +50,7 @@ def _run_device(self, cmd, timeout=60): """ try: test_username = self.job_data.get( - 'test_data').get('test_username', 'ubuntu') + 'test_data', {}).get('test_username', 'ubuntu') except: test_username = 'ubuntu' ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', @@ -82,9 +82,9 @@ def provision(self): def copy_ssh_id(self): try: test_username = self.job_data.get( - 'test_data').get('test_username', 'ubuntu') + 'test_data', {}).get('test_username', 'ubuntu') test_password = self.job_data.get( - 'test_data').get('test_password', 'ubuntu') + 'test_data', {}).get('test_password', 'ubuntu') except: test_username = 'ubuntu' test_password = 'ubuntu' diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index c6baf7bf..99417cc2 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -350,9 +350,9 @@ def provision(self): raise ProvisioningError("Error provisioning system") image_file = snappy_device_agents.compress_file('snappy.img') test_username = self.job_data.get( - 'test_data').get('test_username', 'ubuntu') + 'test_data', {}).get('test_username', 'ubuntu') test_password = self.job_data.get( - 'test_data').get('test_password', 'ubuntu') + 'test_data', {}).get('test_password', 'ubuntu') server_ip = snappy_device_agents.get_local_ip_addr() serve_q = multiprocessing.Queue() file_server = multiprocessing.Process( From cf198f5bb42085ee4d4e202c55e65a6b604b2a9b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 26 Mar 2019 09:33:31 -0500 Subject: [PATCH 209/569] Add a poll option to the submit subcommand --- testflinger-cli | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/testflinger-cli b/testflinger-cli index 7142e04c..2501f67e 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -96,9 +96,10 @@ def cancel(ctx, job_id): @cli.command() @click.argument('filename', nargs=1) +@click.option('--poll', '-p', 'poll_opt', is_flag=True) @click.option('--quiet', '-q', is_flag=True) @click.pass_context -def submit(ctx, filename, quiet): +def submit(ctx, filename, quiet, poll_opt): conn = ctx.obj['conn'] with open(filename) as f: data = f.read() @@ -121,6 +122,8 @@ def submit(ctx, filename, quiet): else: print('Job submitted successfully!') print('job_id: {}'.format(job_id)) + if poll_opt: + ctx.invoke(poll, job_id=job_id) @cli.command() From b4746490afeaf4fe994453b062b953e40758f22f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 26 Mar 2019 10:15:31 -0500 Subject: [PATCH 210/569] Flush the output log if there is any output, and don't wait or there could be duplicate writes --- testflinger_agent/job.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 1ea42951..07ac7d4a 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -123,6 +123,8 @@ def cleanup(signum, frame): if buf: sys.stdout.write(buf) live_output_buffer += buf + f.write(buf) + f.flush() else: if (self.phase == 'test' and time.time() - buffer_timeout > output_timeout): @@ -149,8 +151,6 @@ def cleanup(signum, frame): if self.client.post_live_output( self.job_id, live_output_buffer): live_output_buffer = '' - f.write(buf) - f.flush() buf = process.stdout.read() if buf: buf = buf.decode(sys.stdout.encoding) From cb4153be4bd93407c035727e53befeee9fcc9821 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 26 Mar 2019 10:16:45 -0500 Subject: [PATCH 211/569] When showing how to cancel a reservation, use the real job id --- devices/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/devices/__init__.py b/devices/__init__.py index c677b89b..ba645674 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -100,8 +100,9 @@ def invoked(self, ctx): print('Reservation expires at: [{}]'.format(expire_time)) print('Reservation will automatically timeout in {} ' 'seconds'.format(timeout)) + job_id = job_data.get('job_id', '') print('To end the reservation sooner use: testflinger-cli ' - 'cancel ') + 'cancel {}'.format(job_id)) time.sleep(int(timeout)) def register_arguments(self, parser): From 896af6cefbe38fb517b85dd08d8a6794da4e54cc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 28 Mar 2019 14:48:03 -0500 Subject: [PATCH 212/569] Add oneshot argument for polling to exit after current output --- testflinger-cli | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/testflinger-cli b/testflinger-cli index 2501f67e..33043e67 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -197,9 +197,19 @@ def artifacts(ctx, job_id, filename): @cli.command() @click.argument('job_id', nargs=1) +@click.option('--oneshot', '-o', is_flag=True, + help='Get latest output and exit immediately') @click.pass_context -def poll(ctx, job_id): +def poll(ctx, job_id, oneshot): conn = ctx.obj['conn'] + if oneshot: + try: + output = get_latest_output(conn, job_id) + except Exception: + sys.exit(1) + if output: + print(output, end='', flush=True) + sys.exit(0) job_state = get_job_state(conn, job_id) if job_state == 'waiting': print('This job is currently waiting on a node to become available.') @@ -217,11 +227,7 @@ def poll(ctx, job_id): time.sleep(10) output = '' try: - output = conn.get_output(job_id) - except testflinger_cli.HTTPError as e: - if e.status == 204: - # We are still waiting for the job to start - pass + output = get_latest_output(conn, job_id) except Exception: continue if output: @@ -230,6 +236,17 @@ def poll(ctx, job_id): print(job_state) +def get_latest_output(conn, job_id): + output = '' + try: + output = conn.get_output(job_id) + except testflinger_cli.HTTPError as e: + if e.status == 204: + # We are still waiting for the job to start + pass + return output + + def get_job_state(conn, job_id): try: return conn.get_status(job_id) From 0bad27e5fdb4b2bd1b398bc9fd0eafc0ab794e4b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 1 Apr 2019 08:33:10 -0500 Subject: [PATCH 213/569] Fix hardcoded timeout value in the time we say the reservation will expire --- devices/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/__init__.py b/devices/__init__.py index ba645674..69cc7b80 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -95,7 +95,7 @@ def invoked(self, ctx): print('*** TESTFLINGER SYSTEM RESERVED ***') print('You can now connect to {}@{}'.format(test_username, device_ip)) now = datetime.utcnow().isoformat() - expire_time = (datetime.utcnow() + timedelta(seconds=3600)).isoformat() + expire_time = (datetime.utcnow() + timedelta(seconds=timeout)).isoformat() print('Current time: [{}]'.format(now)) print('Reservation expires at: [{}]'.format(expire_time)) print('Reservation will automatically timeout in {} ' From 5121cca24d3b05e6f9072e4d68617de515d1554f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 1 Apr 2019 12:30:07 -0500 Subject: [PATCH 214/569] Use max_reserve_timeout if specified, otherwise cap reservations at 18 hours --- devices/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 69cc7b80..39d6d733 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -75,8 +75,6 @@ def invoked(self, ctx): device_ip = config['device_ip'] reserve_data = job_data['reserve_data'] ssh_keys = reserve_data.get('ssh_keys', []) - # default reservation timeout is 1 hour - timeout = reserve_data.get('timeout', '3600') for key in ssh_keys: try: os.unlink('key.pub') @@ -92,6 +90,12 @@ def invoked(self, ctx): proc = subprocess.run(cmd) if proc.returncode != 0: print('Problem copying ssh key to target device for:', key) + # default reservation timeout is 1 hour + timeout = reserve_data.get('timeout', '3600') + # If max_reserve_timeout isn't specified, default to 18 hours + max_reserve_timeout = config.get('max_reserve_timeout', 18 * 60 * 60) + if timeout > max_reserve_timeout: + timeout = max_reserve_timeout print('*** TESTFLINGER SYSTEM RESERVED ***') print('You can now connect to {}@{}'.format(test_username, device_ip)) now = datetime.utcnow().isoformat() From 509f0bec71e8ed633fd612abe41e5f55bc45bdc8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 1 Apr 2019 13:00:49 -0500 Subject: [PATCH 215/569] Use timeout with ssh-copy-id, and log errors nicer --- devices/__init__.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 39d6d733..2b406f2b 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -83,13 +83,22 @@ def invoked(self, ctx): cmd = ['ssh-import-id', '-o', 'key.pub', key] proc = subprocess.run(cmd) if proc.returncode != 0: - print('Unable to import ssh key from:', key) + snappy_device_agents.logmsg( + logging.ERROR, + 'Unable to import ssh key from: {}'.format(key)) continue cmd = ['ssh-copy-id', '-f', '-i', 'key.pub', '{}@{}'.format(test_username, device_ip)] - proc = subprocess.run(cmd) + try: + proc = subprocess.run(cmd, timeout=30) + except Exception: + snappy_device_agents.logmsg( + logging.ERROR, + 'Error copying ssh key to device for: {}'.format(key)) if proc.returncode != 0: - print('Problem copying ssh key to target device for:', key) + snappy_device_agents.logmsg( + logging.ERROR, + 'Problem copying ssh key to device for: {}'.format(key)) # default reservation timeout is 1 hour timeout = reserve_data.get('timeout', '3600') # If max_reserve_timeout isn't specified, default to 18 hours From 02788483442ed4e916f17c87c1a71d6e03fccc78 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 1 Apr 2019 13:34:12 -0500 Subject: [PATCH 216/569] use yaml.safe_load --- testflinger_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 1866b637..5a7d700c 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -106,7 +106,7 @@ def submit_job(self, job_data): ID for the test job """ endpoint = '/v1/job' - data = yaml.load(job_data) + data = yaml.safe_load(job_data) response = self.put(endpoint, data) return json.loads(response).get('job_id') From a55303e07dacb443a61999e9e40c844ccc2e384d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 1 Apr 2019 14:24:29 -0500 Subject: [PATCH 217/569] flake fix --- devices/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/devices/__init__.py b/devices/__init__.py index 2b406f2b..f7a43274 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -108,7 +108,8 @@ def invoked(self, ctx): print('*** TESTFLINGER SYSTEM RESERVED ***') print('You can now connect to {}@{}'.format(test_username, device_ip)) now = datetime.utcnow().isoformat() - expire_time = (datetime.utcnow() + timedelta(seconds=timeout)).isoformat() + expire_time = ( + datetime.utcnow() + timedelta(seconds=timeout)).isoformat() print('Current time: [{}]'.format(now)) print('Reservation expires at: [{}]'.format(expire_time)) print('Reservation will automatically timeout in {} ' From a806cdaed0d881fa4d5d26f9e13602aa84066bf0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 4 Apr 2019 07:42:10 -0500 Subject: [PATCH 218/569] Force max_reserve_timeout and timeout to be an int --- devices/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index f7a43274..beed0cfa 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -100,9 +100,9 @@ def invoked(self, ctx): logging.ERROR, 'Problem copying ssh key to device for: {}'.format(key)) # default reservation timeout is 1 hour - timeout = reserve_data.get('timeout', '3600') + timeout = int(reserve_data.get('timeout', '3600')) # If max_reserve_timeout isn't specified, default to 18 hours - max_reserve_timeout = config.get('max_reserve_timeout', 18 * 60 * 60) + max_reserve_timeout = int(config.get('max_reserve_timeout', 18 * 60 * 60)) if timeout > max_reserve_timeout: timeout = max_reserve_timeout print('*** TESTFLINGER SYSTEM RESERVED ***') From 42262dd18b064f2e282e1f06ed6e5b0e95185e0a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 4 Apr 2019 07:55:05 -0500 Subject: [PATCH 219/569] flake8 fix --- devices/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/__init__.py b/devices/__init__.py index beed0cfa..055dbede 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -102,7 +102,7 @@ def invoked(self, ctx): # default reservation timeout is 1 hour timeout = int(reserve_data.get('timeout', '3600')) # If max_reserve_timeout isn't specified, default to 18 hours - max_reserve_timeout = int(config.get('max_reserve_timeout', 18 * 60 * 60)) + max_reserve_timeout = int(config.get('max_reserve_timeout', 18*60*60)) if timeout > max_reserve_timeout: timeout = max_reserve_timeout print('*** TESTFLINGER SYSTEM RESERVED ***') From 4b617724e64896f67e44adc5360917a24b1dfb50 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 4 Apr 2019 09:48:30 -0500 Subject: [PATCH 220/569] Retry copying the ssh key just in case the system is rebooting, or temporarily offline when we start the reservation section --- devices/__init__.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 055dbede..30ab36d0 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -89,16 +89,21 @@ def invoked(self, ctx): continue cmd = ['ssh-copy-id', '-f', '-i', 'key.pub', '{}@{}'.format(test_username, device_ip)] - try: + for retry in range(10): + # Retry ssh key copy just in case it's rebooting proc = subprocess.run(cmd, timeout=30) - except Exception: + if proc.returncode == 0: + break snappy_device_agents.logmsg( logging.ERROR, 'Error copying ssh key to device for: {}'.format(key)) - if proc.returncode != 0: - snappy_device_agents.logmsg( - logging.ERROR, - 'Problem copying ssh key to device for: {}'.format(key)) + if retry != 9: + snappy_device_agents.logmsg(logging.INFO, 'Retrying...') + time.sleep(60) + else: + snappy_device_agents.logmsg( + logging.ERROR, + 'Failed to copy ssh key: {}'.format(key)) # default reservation timeout is 1 hour timeout = int(reserve_data.get('timeout', '3600')) # If max_reserve_timeout isn't specified, default to 18 hours From 1586debd2afb4cacdcad2c213cace08aed791e5a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 4 Apr 2019 15:18:34 -0500 Subject: [PATCH 221/569] Support kernel specification in maas2 deployments --- devices/maas2/maas2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index de9ec208..5acce1b9 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -73,7 +73,9 @@ def provision(self): 'with distro {}'.format(agent_name, distro)) cmd = ['maas', maas_user, 'machine', 'deploy', node_id, 'distro_series={}'.format(distro)] - print(self.job_data) + kernel = provision_data.get('kernel') + if kernel: + cmd.append('hwe_kernel={}'.format(kernel)) user_data = provision_data.get('user_data') if user_data: data = base64.b64encode(user_data.encode()).decode() From 4092baf7ccc5ba9f6a993b62d94d7a3d9fb70ae0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 5 Apr 2019 12:07:34 -0500 Subject: [PATCH 222/569] Also log an error when copying the ssh key for reserve if we hit a timeout --- devices/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 30ab36d0..f501aeea 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -91,9 +91,13 @@ def invoked(self, ctx): '{}@{}'.format(test_username, device_ip)] for retry in range(10): # Retry ssh key copy just in case it's rebooting - proc = subprocess.run(cmd, timeout=30) - if proc.returncode == 0: - break + try: + proc = subprocess.run(cmd, timeout=30) + if proc.returncode == 0: + break + except subprocess.TimeoutExpired: + # Log an error for timeout or any other problem + pass snappy_device_agents.logmsg( logging.ERROR, 'Error copying ssh key to device for: {}'.format(key)) From 61fe6c4a9d7eef2afa296967b8c2c358a19648e2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 2 May 2019 14:24:15 -0500 Subject: [PATCH 223/569] Change default reserve max to 6 hours --- devices/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index f501aeea..e81b4301 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -110,8 +110,8 @@ def invoked(self, ctx): 'Failed to copy ssh key: {}'.format(key)) # default reservation timeout is 1 hour timeout = int(reserve_data.get('timeout', '3600')) - # If max_reserve_timeout isn't specified, default to 18 hours - max_reserve_timeout = int(config.get('max_reserve_timeout', 18*60*60)) + # If max_reserve_timeout isn't specified, default to 6 hours + max_reserve_timeout = int(config.get('max_reserve_timeout', 6*60*60)) if timeout > max_reserve_timeout: timeout = max_reserve_timeout print('*** TESTFLINGER SYSTEM RESERVED ***') From 79e91676ac40e0ed042eff7d8fa8809cb78b2ca1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 May 2019 12:27:23 -0500 Subject: [PATCH 224/569] improve logging, catch things earlier if possible --- testflinger-agent | 13 ++++++++++++- testflinger_agent/__init__.py | 30 ++++++++++++------------------ testflinger_agent/agent.py | 2 +- testflinger_agent/client.py | 2 +- testflinger_agent/job.py | 2 +- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/testflinger-agent b/testflinger-agent index b4de8fb6..55cba80a 100755 --- a/testflinger-agent +++ b/testflinger-agent @@ -13,7 +13,18 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import logging +import sys + from testflinger_agent import main +logger = logging.getLogger(__name__) + if __name__ == '__main__': - main() + try: + main() + except KeyboardInterrupt: + logger.info('Caught interrupt, exiting!') + sys.exit(0) + except Exception as e: + logger.exception(e) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index b956df97..f4f14797 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -23,7 +23,7 @@ from testflinger_agent.agent import TestflingerAgent from testflinger_agent.client import TestflingerClient -logger = logging.getLogger() +logger = logging.getLogger(__name__) def main(): @@ -34,23 +34,17 @@ def main(): client = TestflingerClient(config) agent = TestflingerAgent(client) while True: - try: - if agent.check_offline(): - logger.error("Agent %s is offline, not processing jobs! " - "Remove %s to resume processing" % - (config.get('agent_id'), - agent.get_offline_file())) - while agent.check_offline(): - time.sleep(check_interval) - logger.info("Checking jobs") - agent.process_jobs() - logger.info("Sleeping for {}".format(check_interval)) - time.sleep(check_interval) - except KeyboardInterrupt: - logger.info('Caught interrupt, exiting!') - sys.exit(0) - except Exception as e: - logger.exception(e) + if agent.check_offline(): + logger.error("Agent %s is offline, not processing jobs! " + "Remove %s to resume processing" % + (config.get('agent_id'), + agent.get_offline_file())) + while agent.check_offline(): + time.sleep(check_interval) + logger.info("Checking jobs") + agent.process_jobs() + logger.info("Sleeping for {}".format(check_interval)) + time.sleep(check_interval) def load_config(configfile): diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 220926cb..2a825cca 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -21,7 +21,7 @@ from testflinger_agent.job import TestflingerJob from testflinger_agent.errors import TFServerError -logger = logging.getLogger() +logger = logging.getLogger(__name__) class TestflingerAgent: diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 7d0f205b..678456b1 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -25,7 +25,7 @@ from testflinger_agent.errors import TFServerError -logger = logging.getLogger() +logger = logging.getLogger(__name__) class TestflingerClient: diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 07ac7d4a..6816929d 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -22,7 +22,7 @@ import subprocess import time -logger = logging.getLogger() +logger = logging.getLogger(__name__) class TestflingerJob: From ad85a3143ac8c1af4284effc359323096ebfdce8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 May 2019 12:30:35 -0500 Subject: [PATCH 225/569] Handle server address better and default to https --- testflinger_agent/client.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 678456b1..6e17f533 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -31,9 +31,10 @@ class TestflingerClient: def __init__(self, config): self.config = config - server = self.config.get('server_address') - if not server.lower().startswith('http'): - self.config['server'] = 'http://' + server + self.server = self.config.get( + 'server_address', 'https://testflinger.canonical.com') + if not self.server.lower().startswith('http'): + self.server = 'http://' + self.server def check_jobs(self): """Check for new jobs for on the Testflinger server @@ -41,7 +42,7 @@ def check_jobs(self): :return: Dict with job data, or None if no job found """ try: - job_uri = urljoin(self.config.get('server'), '/v1/job') + job_uri = urljoin(self.server, '/v1/job') queue_list = self.config.get('job_queues') logger.debug("Requesting a job") job_request = requests.get(job_uri, params={'queue': queue_list}, @@ -61,7 +62,7 @@ def repost_job(self, job_data): :param job_id: id for the job on which we want to post results """ - job_uri = urljoin(self.config.get('server'), '/v1/job') + job_uri = urljoin(self.server, '/v1/job') job_id = job_data.get('job_id') logger.info('Resubmitting job: %s', job_id) job_output = """ @@ -83,7 +84,7 @@ def post_result(self, job_id, data): :param data: dict with data to be posted in json """ - result_uri = urljoin(self.config.get('server'), '/v1/result/') + result_uri = urljoin(self.server, '/v1/result/') result_uri = urljoin(result_uri, job_id) job_request = requests.post(result_uri, json=data) if not job_request: @@ -100,7 +101,7 @@ def get_result(self, job_id): dict with data to be posted in json or an empty dict if there was an error """ - result_uri = urljoin(self.config.get('server'), '/v1/result/') + result_uri = urljoin(self.server, '/v1/result/') result_uri = urljoin(result_uri, job_id) job_request = requests.get(result_uri) if not job_request: @@ -141,7 +142,7 @@ def transmit_job_outcome(self, rundir): root_dir=rundir, base_dir='artifacts') # Create uri for API: /v1/result/ artifact_uri = urljoin( - self.config.get('server'), + self.server, '/v1/result/{}/artifact'.format(job_id)) with open(artifact_file+'.tar.gz', 'rb') as tarball: file_upload = { @@ -164,7 +165,7 @@ def post_live_output(self, job_id, data): :param data: string with latest output data """ - output_uri = urljoin(self.config.get('server'), + output_uri = urljoin(self.server, '/v1/result/{}/output'.format(job_id)) try: job_request = requests.post( From 2e305cbcd51634dd0480a6fa3f97a7c104ddd0f1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 May 2019 12:37:41 -0500 Subject: [PATCH 226/569] Use https by default in testflinger-cli --- testflinger-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger-cli b/testflinger-cli index 33043e67..5877f276 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -32,7 +32,7 @@ if os.path.exists(os.path.join(basedir, 'setup.py')): @click.group() -@click.option('--server', default='http://testflinger.canonical.com', +@click.option('--server', default='https://testflinger.canonical.com', help='Testflinger server to use') @click.pass_context def cli(ctx, server): From bd959df13aabd7bf13372495b101eabd4eb4afc7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 29 May 2019 10:23:38 -0500 Subject: [PATCH 227/569] Allow yaml to be piped to stdin by using "-" as the filename --- testflinger-cli | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/testflinger-cli b/testflinger-cli index 5877f276..a1e16ee8 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -101,8 +101,11 @@ def cancel(ctx, job_id): @click.pass_context def submit(ctx, filename, quiet, poll_opt): conn = ctx.obj['conn'] - with open(filename) as f: - data = f.read() + if filename == '-': + data = sys.stdin.read() + else: + with open(filename) as f: + data = f.read() try: job_id = conn.submit_job(data) except testflinger_cli.HTTPError as e: From 9877c67be8fe6b03b484a996255e28ad8e394a6c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 31 May 2019 14:44:40 -0500 Subject: [PATCH 228/569] Replace errors on decode() --- testflinger_agent/job.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 6816929d..f0e205e8 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -119,7 +119,8 @@ def cleanup(signum, frame): # Check if there's any new data, timeout after 10s data_ready = readpoll.poll(10000) if data_ready: - buf = process.stdout.read().decode(sys.stdout.encoding) + buf = process.stdout.read().decode( + sys.stdout.encoding, errors='replace') if buf: sys.stdout.write(buf) live_output_buffer += buf @@ -153,7 +154,7 @@ def cleanup(signum, frame): live_output_buffer = '' buf = process.stdout.read() if buf: - buf = buf.decode(sys.stdout.encoding) + buf = buf.decode(sys.stdout.encoding, errors='replace') sys.stdout.write(buf) live_output_buffer += buf f.write(buf) From 42058757c4a89347247705c9f8ba50250af84841 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 3 Jun 2019 14:33:59 -0500 Subject: [PATCH 229/569] Refactor and allow this to be called with either testflinger-cli or testflinger --- setup.py | 9 +- testflinger-cli | 258 +---------------------- testflinger_cli/__init__.py | 396 ++++++++++++++++++++++-------------- testflinger_cli/client.py | 173 ++++++++++++++++ 4 files changed, 430 insertions(+), 406 deletions(-) create mode 100644 testflinger_cli/client.py diff --git a/setup.py b/setup.py index c188f994..a5517520 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -28,6 +28,11 @@ install_requires=INSTALL_REQUIRES, test_suite='testflinger_cli.tests', tests_require=TEST_REQUIRES, - scripts=['testflinger-cli'], + entry_points=''' + [console_scripts] + testflinger-cli=testflinger_cli:cli + testflinger=testflinger_cli:cli + ''', + ) diff --git a/testflinger-cli b/testflinger-cli index a1e16ee8..4b6c8861 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,260 +16,8 @@ # -import click -import json -import os -import sys -import time - -import testflinger_cli - - -# Make it easier to run from a checkout -basedir = os.path.abspath(os.path.join(__file__, '..')) -if os.path.exists(os.path.join(basedir, 'setup.py')): - sys.path.insert(0, basedir) - - -@click.group() -@click.option('--server', default='https://testflinger.canonical.com', - help='Testflinger server to use') -@click.pass_context -def cli(ctx, server): - env_server = os.environ.get('TESTFLINGER_SERVER') - if env_server: - server = env_server - ctx.obj['conn'] = testflinger_cli.Client(server) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.pass_context -def status(ctx, job_id): - conn = ctx.obj['conn'] - try: - job_state = conn.get_status(job_id) - except testflinger_cli.HTTPError as e: - if e.status == 204: - print('No data found for that job id. Check the job id to be sure ' - 'it is correct') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') - if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) - except Exception: - print('Error communicating with server, check connection and retry') - sys.exit(1) - print(job_state) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.pass_context -def cancel(ctx, job_id): - conn = ctx.obj['conn'] - try: - job_state = conn.get_status(job_id) - except testflinger_cli.HTTPError as e: - if e.status == 204: - print('Job {} not found. Check the job id to be sure it is ' - 'correct.'.format(job_id)) - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct.') - if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) - except Exception: - print('Error communicating with server, check connection and retry') - sys.exit(1) - if job_state in ('complete', 'cancelled'): - print('Job {} is already in {} state and cannot be cancelled.'.format( - job_id, job_state)) - sys.exit(1) - conn.post_job_state(job_id, 'cancelled') - - -@cli.command() -@click.argument('filename', nargs=1) -@click.option('--poll', '-p', 'poll_opt', is_flag=True) -@click.option('--quiet', '-q', is_flag=True) -@click.pass_context -def submit(ctx, filename, quiet, poll_opt): - conn = ctx.obj['conn'] - if filename == '-': - data = sys.stdin.read() - else: - with open(filename) as f: - data = f.read() - try: - job_id = conn.submit_job(data) - except testflinger_cli.HTTPError as e: - if e.status == 400: - print('The job you submitted contained bad data or bad ' - 'formatting, or did not specify a job_queue.') - if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - else: - # This shouldn't happen, so let's get the full trace - print('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) - sys.exit(1) - if quiet: - print(job_id) - else: - print('Job submitted successfully!') - print('job_id: {}'.format(job_id)) - if poll_opt: - ctx.invoke(poll, job_id=job_id) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.pass_context -def show(ctx, job_id): - conn = ctx.obj['conn'] - try: - results = conn.show_job(job_id) - except testflinger_cli.HTTPError as e: - if e.status == 204: - print('No data found for that job id.') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') - if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) - print(json.dumps(results, sort_keys=True, indent=4)) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.pass_context -def results(ctx, job_id): - conn = ctx.obj['conn'] - try: - results = conn.get_results(job_id) - except testflinger_cli.HTTPError as e: - if e.status == 204: - print('No results found for that job id.') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') - if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) - except Exception: - print('Error communicating with server, check connection and retry') - sys.exit(1) - - print(json.dumps(results, sort_keys=True, indent=4)) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.option('--filename', default='artifacts.tgz') -@click.pass_context -def artifacts(ctx, job_id, filename): - conn = ctx.obj['conn'] - print('Downloading artifacts tarball...') - try: - conn.get_artifact(job_id, filename) - except testflinger_cli.HTTPError as e: - if e.status == 204: - print('No artifacts tarball found for that job id.') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') - if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) - except Exception: - print('Error communicating with server, check connection and retry') - sys.exit(1) - print('Artifacts downloaded to {}'.format(filename)) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.option('--oneshot', '-o', is_flag=True, - help='Get latest output and exit immediately') -@click.pass_context -def poll(ctx, job_id, oneshot): - conn = ctx.obj['conn'] - if oneshot: - try: - output = get_latest_output(conn, job_id) - except Exception: - sys.exit(1) - if output: - print(output, end='', flush=True) - sys.exit(0) - job_state = get_job_state(conn, job_id) - if job_state == 'waiting': - print('This job is currently waiting on a node to become available.') - prev_queue_pos = None - while job_state != 'complete': - if job_state == 'waiting': - try: - queue_pos = conn.get_job_position(job_id) - if int(queue_pos) != prev_queue_pos: - prev_queue_pos = int(queue_pos) - print('Jobs ahead in queue: {}'.format(queue_pos)) - except Exception: - # Ignore any bad response, this will retry - pass - time.sleep(10) - output = '' - try: - output = get_latest_output(conn, job_id) - except Exception: - continue - if output: - print(output, end='', flush=True) - job_state = get_job_state(conn, job_id) - print(job_state) - - -def get_latest_output(conn, job_id): - output = '' - try: - output = conn.get_output(job_id) - except testflinger_cli.HTTPError as e: - if e.status == 204: - # We are still waiting for the job to start - pass - return output - - -def get_job_state(conn, job_id): - try: - return conn.get_status(job_id) - except testflinger_cli.HTTPError as e: - if e.status == 204: - print('No data found for that job id. Check the job id to be sure ' - 'it is correct') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') - if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) - except Exception: - # If we fail to get the job_state here, it could be because of timeout - # but we can keep going and retrying - pass - return 'unknown' +from testflinger_cli import cli if __name__ == '__main__': - cli(obj={}) + cli() diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 5a7d700c..ece9f48e 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,160 +14,258 @@ # along with this program. If not, see . # + +import click import json -import requests +import os import sys -import urllib.parse -import yaml +import time +from testflinger_cli import client -class HTTPError(Exception): - def __init__(self, status): - self.status = status +# Make it easier to run from a checkout +basedir = os.path.abspath(os.path.join(__file__, '..')) +if os.path.exists(os.path.join(basedir, 'setup.py')): + sys.path.insert(0, basedir) -class Client(): - """Testflinger connection client""" - def __init__(self, server): - self.server = server - def get(self, uri_frag, timeout=15): - """Submit a GET request to the server - :param uri_frag: - endpoint for the GET request - :return: - String containing the response from the server - """ - uri = urllib.parse.urljoin(self.server, uri_frag) - try: - req = requests.get(uri, timeout=timeout) - except requests.exceptions.ConnectTimeout as e: - print('Timeout while trying to communicate with the server.') - raise - except requests.exceptions.ConnectionError as e: - print('Unable to communicate with specified server.') - raise - if req.status_code != 200: - raise HTTPError(req.status_code) - return req.text - - def put(self, uri_frag, data, timeout=15): - """Submit a POST request to the server - :param uri_frag: - endpoint for the POST request - :return: - String containing the response from the server - """ - uri = urllib.parse.urljoin(self.server, uri_frag) +@click.group() +@click.option('--server', default='https://testflinger.canonical.com', + help='Testflinger server to use') +@click.pass_context +def cli(ctx, server): + ctx.obj = {} + env_server = os.environ.get('TESTFLINGER_SERVER') + if env_server: + server = env_server + ctx.obj['conn'] = client.Client(server) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def status(ctx, job_id): + conn = ctx.obj['conn'] + try: + job_state = conn.get_status(job_id) + except client.HTTPError as e: + if e.status == 204: + print('No data found for that job id. Check the job id to be sure ' + 'it is correct') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + except Exception: + print('Error communicating with server, check connection and retry') + sys.exit(1) + print(job_state) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def cancel(ctx, job_id): + conn = ctx.obj['conn'] + try: + job_state = conn.get_status(job_id) + except client.HTTPError as e: + if e.status == 204: + print('Job {} not found. Check the job id to be sure it is ' + 'correct.'.format(job_id)) + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct.') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + except Exception: + print('Error communicating with server, check connection and retry') + sys.exit(1) + if job_state in ('complete', 'cancelled'): + print('Job {} is already in {} state and cannot be cancelled.'.format( + job_id, job_state)) + sys.exit(1) + conn.post_job_state(job_id, 'cancelled') + + +@cli.command() +@click.argument('filename', nargs=1) +@click.option('--poll', '-p', 'poll_opt', is_flag=True) +@click.option('--quiet', '-q', is_flag=True) +@click.pass_context +def submit(ctx, filename, quiet, poll_opt): + conn = ctx.obj['conn'] + if filename == '-': + data = sys.stdin.read() + else: + with open(filename) as f: + data = f.read() + try: + job_id = conn.submit_job(data) + except client.HTTPError as e: + if e.status == 400: + print('The job you submitted contained bad data or bad ' + 'formatting, or did not specify a job_queue.') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + else: + # This shouldn't happen, so let's get the full trace + print('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) + sys.exit(1) + if quiet: + print(job_id) + else: + print('Job submitted successfully!') + print('job_id: {}'.format(job_id)) + if poll_opt: + ctx.invoke(poll, job_id=job_id) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def show(ctx, job_id): + conn = ctx.obj['conn'] + try: + results = conn.show_job(job_id) + except client.HTTPError as e: + if e.status == 204: + print('No data found for that job id.') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + print(json.dumps(results, sort_keys=True, indent=4)) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.pass_context +def results(ctx, job_id): + conn = ctx.obj['conn'] + try: + results = conn.get_results(job_id) + except client.HTTPError as e: + if e.status == 204: + print('No results found for that job id.') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + except Exception: + print('Error communicating with server, check connection and retry') + sys.exit(1) + + print(json.dumps(results, sort_keys=True, indent=4)) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.option('--filename', default='artifacts.tgz') +@click.pass_context +def artifacts(ctx, job_id, filename): + conn = ctx.obj['conn'] + print('Downloading artifacts tarball...') + try: + conn.get_artifact(job_id, filename) + except client.HTTPError as e: + if e.status == 204: + print('No artifacts tarball found for that job id.') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + except Exception: + print('Error communicating with server, check connection and retry') + sys.exit(1) + print('Artifacts downloaded to {}'.format(filename)) + + +@cli.command() +@click.argument('job_id', nargs=1) +@click.option('--oneshot', '-o', is_flag=True, + help='Get latest output and exit immediately') +@click.pass_context +def poll(ctx, job_id, oneshot): + conn = ctx.obj['conn'] + if oneshot: try: - req = requests.post(uri, json=data, timeout=timeout) - except requests.exceptions.ConnectTimeout as e: - print('Timout while trying to communicate with the server.') - sys.exit(1) - except requests.exceptions.ConnectionError as e: - print('Unable to communicate with specified server.') + output = get_latest_output(conn, job_id) + except Exception: sys.exit(1) - if req.status_code != 200: - raise HTTPError(req.status_code) - return req.text - - def get_status(self, job_id): - """Get the status of a test job - - :param job_id: - ID for the test job - :return: - String containing the job_state for the specified ID - (waiting, setup, provision, test, reserved, released, - cancelled, complete) - """ - endpoint = '/v1/result/{}'.format(job_id) - data = json.loads(self.get(endpoint)) - return data.get('job_state') - - def post_job_state(self, job_id, state): - """Post the status of a test job - - :param job_id: - ID for the test job - :param state: - Job state to set for the specified job - """ - endpoint = '/v1/result/{}'.format(job_id) - data = dict(job_state=state) - self.put(endpoint, data) - - def submit_job(self, job_data): - """Submit a test job to the testflinger server - - :param job_data: - String containing json or yaml data for the job to submit - :return: - ID for the test job - """ - endpoint = '/v1/job' - data = yaml.safe_load(job_data) - response = self.put(endpoint, data) - return json.loads(response).get('job_id') - - def show_job(self, job_id): - """Show the JSON job definition for the specified ID - - :param job_id: - ID for the test job - :return: - JSON job definition for the specified ID - """ - endpoint = '/v1/job/{}'.format(job_id) - return json.loads(self.get(endpoint)) - - def get_results(self, job_id): - """Get results for a specified test job - - :param job_id: - ID for the test job - :return: - Dict containing the results returned from the server - """ - endpoint = '/v1/result/{}'.format(job_id) - return json.loads(self.get(endpoint)) - - def get_artifact(self, job_id, path): - """Get results for a specified test job - - :param job_id: - ID for the test job - :param path: - Path and filename for the artifact file - """ - endpoint = '/v1/result/{}/artifact'.format(job_id) - uri = urllib.parse.urljoin(self.server, endpoint) - req = requests.get(uri, timeout=15) - if req.status_code != 200: - raise HTTPError(req.status_code) - with open(path, 'wb') as artifact: - for chunk in req.iter_content(chunk_size=4096): - artifact.write(chunk) - - def get_output(self, job_id): - """Get the latest output for a specified test job - - :param job_id: - ID for the test job - :return: - String containing the latest output from the job - """ - endpoint = '/v1/result/{}/output'.format(job_id) - return self.get(endpoint) - - def get_job_position(self, job_id): - """Get the status of a test job - - :param job_id: - ID for the test job - :return: - String containing the queue position for the specified ID - i.e. how many jobs are ahead of it in the queue - """ - endpoint = '/v1/job/{}/position'.format(job_id) - return self.get(endpoint) + if output: + print(output, end='', flush=True) + sys.exit(0) + job_state = get_job_state(conn, job_id) + if job_state == 'waiting': + print('This job is currently waiting on a node to become available.') + prev_queue_pos = None + while job_state != 'complete': + if job_state == 'waiting': + try: + queue_pos = conn.get_job_position(job_id) + if int(queue_pos) != prev_queue_pos: + prev_queue_pos = int(queue_pos) + print('Jobs ahead in queue: {}'.format(queue_pos)) + except Exception: + # Ignore any bad response, this will retry + pass + time.sleep(10) + output = '' + try: + output = get_latest_output(conn, job_id) + except Exception: + continue + if output: + print(output, end='', flush=True) + job_state = get_job_state(conn, job_id) + print(job_state) + + +def get_latest_output(conn, job_id): + output = '' + try: + output = conn.get_output(job_id) + except client.HTTPError as e: + if e.status == 204: + # We are still waiting for the job to start + pass + return output + + +def get_job_state(conn, job_id): + try: + return conn.get_status(job_id) + except client.HTTPError as e: + if e.status == 204: + print('No data found for that job id. Check the job id to be sure ' + 'it is correct') + elif e.status == 400: + print('Invalid job id specified. Check the job id to be sure it ' + 'is correct') + if e.status == 404: + print('Received 404 error from server. Are you sure this ' + 'is a testflinger server?') + sys.exit(1) + except Exception: + # If we fail to get the job_state here, it could be because of timeout + # but we can keep going and retrying + pass + return 'unknown' diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py new file mode 100644 index 00000000..bd1de3a9 --- /dev/null +++ b/testflinger_cli/client.py @@ -0,0 +1,173 @@ +# Copyright (C) 2017-2019 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import json +import requests +import sys +import urllib.parse +import yaml + + +class HTTPError(Exception): + def __init__(self, status): + self.status = status + + +class Client(): + """Testflinger connection client""" + def __init__(self, server): + self.server = server + + def get(self, uri_frag, timeout=15): + """Submit a GET request to the server + :param uri_frag: + endpoint for the GET request + :return: + String containing the response from the server + """ + uri = urllib.parse.urljoin(self.server, uri_frag) + try: + req = requests.get(uri, timeout=timeout) + except requests.exceptions.ConnectTimeout: + print('Timeout while trying to communicate with the server.') + raise + except requests.exceptions.ConnectionError: + print('Unable to communicate with specified server.') + raise + if req.status_code != 200: + raise HTTPError(req.status_code) + return req.text + + def put(self, uri_frag, data, timeout=15): + """Submit a POST request to the server + :param uri_frag: + endpoint for the POST request + :return: + String containing the response from the server + """ + uri = urllib.parse.urljoin(self.server, uri_frag) + try: + req = requests.post(uri, json=data, timeout=timeout) + except requests.exceptions.ConnectTimeout: + print('Timout while trying to communicate with the server.') + sys.exit(1) + except requests.exceptions.ConnectionError: + print('Unable to communicate with specified server.') + sys.exit(1) + if req.status_code != 200: + raise HTTPError(req.status_code) + return req.text + + def get_status(self, job_id): + """Get the status of a test job + + :param job_id: + ID for the test job + :return: + String containing the job_state for the specified ID + (waiting, setup, provision, test, reserved, released, + cancelled, complete) + """ + endpoint = '/v1/result/{}'.format(job_id) + data = json.loads(self.get(endpoint)) + return data.get('job_state') + + def post_job_state(self, job_id, state): + """Post the status of a test job + + :param job_id: + ID for the test job + :param state: + Job state to set for the specified job + """ + endpoint = '/v1/result/{}'.format(job_id) + data = dict(job_state=state) + self.put(endpoint, data) + + def submit_job(self, job_data): + """Submit a test job to the testflinger server + + :param job_data: + String containing json or yaml data for the job to submit + :return: + ID for the test job + """ + endpoint = '/v1/job' + data = yaml.safe_load(job_data) + response = self.put(endpoint, data) + return json.loads(response).get('job_id') + + def show_job(self, job_id): + """Show the JSON job definition for the specified ID + + :param job_id: + ID for the test job + :return: + JSON job definition for the specified ID + """ + endpoint = '/v1/job/{}'.format(job_id) + return json.loads(self.get(endpoint)) + + def get_results(self, job_id): + """Get results for a specified test job + + :param job_id: + ID for the test job + :return: + Dict containing the results returned from the server + """ + endpoint = '/v1/result/{}'.format(job_id) + return json.loads(self.get(endpoint)) + + def get_artifact(self, job_id, path): + """Get results for a specified test job + + :param job_id: + ID for the test job + :param path: + Path and filename for the artifact file + """ + endpoint = '/v1/result/{}/artifact'.format(job_id) + uri = urllib.parse.urljoin(self.server, endpoint) + req = requests.get(uri, timeout=15) + if req.status_code != 200: + raise HTTPError(req.status_code) + with open(path, 'wb') as artifact: + for chunk in req.iter_content(chunk_size=4096): + artifact.write(chunk) + + def get_output(self, job_id): + """Get the latest output for a specified test job + + :param job_id: + ID for the test job + :return: + String containing the latest output from the job + """ + endpoint = '/v1/result/{}/output'.format(job_id) + return self.get(endpoint) + + def get_job_position(self, job_id): + """Get the status of a test job + + :param job_id: + ID for the test job + :return: + String containing the queue position for the specified ID + i.e. how many jobs are ahead of it in the queue + """ + endpoint = '/v1/job/{}/position'.format(job_id) + return self.get(endpoint) From 30b8abdc7a6975ccf8d7376088526d38bafbfe02 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 6 Jun 2019 11:04:10 -0500 Subject: [PATCH 230/569] Print out information on how to connect to the serial console when reservations begin --- devices/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/devices/__init__.py b/devices/__init__.py index e81b4301..80f7bce0 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -114,8 +114,13 @@ def invoked(self, ctx): max_reserve_timeout = int(config.get('max_reserve_timeout', 6*60*60)) if timeout > max_reserve_timeout: timeout = max_reserve_timeout + serial_host = config.get('serial_host') + serial_port = config.get('serial_port') print('*** TESTFLINGER SYSTEM RESERVED ***') print('You can now connect to {}@{}'.format(test_username, device_ip)) + if serial_host and serial_port: + print('Serial access is available via: telnet {} {}'.format( + serial_host, serial_port)) now = datetime.utcnow().isoformat() expire_time = ( datetime.utcnow() + timedelta(seconds=timeout)).isoformat() From 9ac9dc3ec744dc3cbfc9a91efbc3123b8d48c809 Mon Sep 17 00:00:00 2001 From: Jeremy Su Date: Fri, 7 Jun 2019 16:01:25 +0800 Subject: [PATCH 231/569] Support customize timeout value Currently, sanappy device agent wait longest 60 mins for maas provisioning. Some OEM platform need more than 60 mins (such as checking the hardware components during installation) for provisioning. Thus, insert a new field to specify the timeout value separately. --- devices/maas2/maas2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 5acce1b9..95661b76 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -61,6 +61,7 @@ def provision(self): maas_user = self.config.get('maas_user') node_id = self.config.get('node_id') agent_name = self.config.get('agent_name') + timeout_min = self.config.get('timeout_min') provision_data = self.job_data.get('provision_data') # Default to a safe LTS if no distro is specified distro = provision_data.get('distro', 'xenial') @@ -91,7 +92,8 @@ def provision(self): # Make sure the device is available before returning minutes_spent = 0 - timeout_min = 60 + if timeout_min is None: + timeout_min = 60 while minutes_spent < timeout_min: time.sleep(60) minutes_spent += 1 From ce2a85c310b877988cf296936ed5ea6b65ce2026 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 12 Jun 2019 14:07:07 -0500 Subject: [PATCH 232/569] Automatically log serial output for devices that support it during provision and test phases --- devices/__init__.py | 81 ++++++++++++++++++++++++++++++++- devices/cm3/__init__.py | 15 +++++- devices/dragonboard/__init__.py | 17 +++++-- devices/maas2/__init__.py | 15 +++++- devices/muxpi/__init__.py | 15 +++++- devices/netboot/__init__.py | 21 +++++++-- devices/rpi3/__init__.py | 17 +++++-- 7 files changed, 163 insertions(+), 18 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 80f7bce0..42be123b 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -15,8 +15,11 @@ import guacamole import imp import logging +import multiprocessing import os +import select import snappy_device_agents +import socket import subprocess import time import yaml @@ -32,6 +35,72 @@ class RecoveryError(Exception): pass +def SerialLogger(host=None, port=None, filename=None): + """Factory to generate real or fake SerialLogger object based on params""" + if host and port and filename: + return RealSerialLogger(host, port, filename) + else: + return StubSerialLogger(host, port, filename) + + +class StubSerialLogger(): + def __init__(self, host, port, filename): + pass + + def start(self): + pass + + def stop(self): + pass + + +class RealSerialLogger(): + + """Set up a subprocess to connect to an ip and collect serial logs""" + + def __init__(self, host, port, filename): + if not (host and port and filename): + self.stub = True + self.stub = False + self.host = host + self.port = int(port) + self.filename = filename + + def start(self): + def reconnector(): + while True: + try: + self._log_serial() + except Exception: + pass + # Keep trying if we can't connect, but sleep between attempts + snappy_device_agents.logmsg( + logging.ERROR, "Error connecting to serial logging server") + time.sleep(30) + self.proc = multiprocessing.Process(target=reconnector, daemon=True) + self.proc.start() + + def _log_serial(self): + with open(self.filename, 'a+') as f: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((self.host, self.port)) + while True: + read_sockets, _, _ = select.select([s], [], []) + for sock in read_sockets: + data = sock.recv(4096) + if data: + f.write(data.decode( + encoding='utf-8', errors='replace')) + f.flush() + else: + snappy_device_agents.logmsg( + logging.ERROR, "Serial Log connection closed") + return + + def stop(self): + self.proc.terminate() + + class DefaultRuntest(guacamole.Command): """Tool for running tests on a provisioned device.""" @@ -46,7 +115,17 @@ def invoked(self, ctx): test_opportunity = snappy_device_agents.get_test_opportunity( ctx.args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') - exitcode = snappy_device_agents.run_test_cmds(test_cmds, config) + serial_host = config.get('serial_host') + serial_port = config.get('serial_port') + serial_proc = SerialLogger( + serial_host, serial_port, 'test-serial.log') + serial_proc.start() + try: + exitcode = snappy_device_agents.run_test_cmds(test_cmds, config) + except Exception as e: + raise e + finally: + serial_proc.stop() snappy_device_agents.logmsg(logging.INFO, "END testrun") return exitcode diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py index 2f11cef2..91d55c6e 100644 --- a/devices/cm3/__init__.py +++ b/devices/cm3/__init__.py @@ -25,7 +25,8 @@ from devices import (Catch, RecoveryError, DefaultReserve, - DefaultRuntest) + DefaultRuntest, + SerialLogger) device_name = "cm3" @@ -43,7 +44,17 @@ def invoked(self, ctx): device = CM3(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") - device.provision() + serial_host = config.get('serial_host') + serial_port = config.get('serial_port') + serial_proc = SerialLogger( + serial_host, serial_port, 'provision-serial.log') + serial_proc.start() + try: + device.provision() + except Exception as e: + raise e + finally: + serial_proc.stop() logmsg(logging.INFO, "END provision") def register_arguments(self, parser): diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 2c4b4535..f291357f 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -25,7 +25,8 @@ from devices import (Catch, RecoveryError, DefaultReserve, - DefaultRuntest) + DefaultRuntest, + SerialLogger) device_name = "dragonboard" @@ -43,8 +44,18 @@ def invoked(self, ctx): device = Dragonboard(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") - device.ensure_master_image() - device.provision() + serial_host = config.get('serial_host') + serial_port = config.get('serial_port') + serial_proc = SerialLogger( + serial_host, serial_port, 'provision-serial.log') + serial_proc.start() + try: + device.ensure_master_image() + device.provision() + except Exception as e: + raise e + finally: + serial_proc.stop() def register_arguments(self, parser): """Method called to customize the argument parser.""" diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index f8339570..47abcedf 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -25,7 +25,8 @@ from devices import (Catch, RecoveryError, DefaultReserve, - DefaultRuntest) + DefaultRuntest, + SerialLogger) device_name = "maas2" @@ -45,7 +46,17 @@ def invoked(self, ctx): logmsg(logging.INFO, "Recovering device") device.recover() logmsg(logging.INFO, "Provisioning device") - device.provision() + serial_host = config.get('serial_host') + serial_port = config.get('serial_port') + serial_proc = SerialLogger( + serial_host, serial_port, 'provision-serial.log') + serial_proc.start() + try: + device.provision() + except Exception as e: + raise e + finally: + serial_proc.stop() logmsg(logging.INFO, "END provision") def register_arguments(self, parser): diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py index c22deaee..f46ceadf 100644 --- a/devices/muxpi/__init__.py +++ b/devices/muxpi/__init__.py @@ -25,7 +25,8 @@ from devices import (Catch, RecoveryError, DefaultReserve, - DefaultRuntest) + DefaultRuntest, + SerialLogger) device_name = "muxpi" @@ -43,7 +44,17 @@ def invoked(self, ctx): device = MuxPi(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") - device.provision() + serial_host = config.get('serial_host') + serial_port = config.get('serial_port') + serial_proc = SerialLogger( + serial_host, serial_port, 'provision-serial.log') + serial_proc.start() + try: + device.provision() + except Exception as e: + raise e + finally: + serial_proc.stop() logmsg(logging.INFO, "END provision") def register_arguments(self, parser): diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 50e5e7e6..9ecb43c5 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -28,7 +28,8 @@ ProvisioningError, RecoveryError, DefaultReserve, - DefaultRuntest) + DefaultRuntest, + SerialLogger) device_name = "netboot" @@ -72,10 +73,20 @@ def invoked(self, ctx): file_server.start() server_port = q.get() logmsg(logging.INFO, "Flashing Test Image") - device.flash_test_image(server_ip, server_port) - file_server.terminate() - logmsg(logging.INFO, "Booting Test Image") - device.ensure_test_image(test_username, test_password) + serial_host = config.get('serial_host') + serial_port = config.get('serial_port') + serial_proc = SerialLogger( + serial_host, serial_port, 'provision-serial.log') + serial_proc.start() + try: + device.flash_test_image(server_ip, server_port) + logmsg(logging.INFO, "Booting Test Image") + device.ensure_test_image(test_username, test_password) + except Exception as e: + raise e + finally: + file_server.terminate() + serial_proc.stop() logmsg(logging.INFO, "END provision") def register_arguments(self, parser): diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py index 06118de6..1df01276 100644 --- a/devices/rpi3/__init__.py +++ b/devices/rpi3/__init__.py @@ -25,7 +25,8 @@ from devices import (Catch, RecoveryError, DefaultReserve, - DefaultRuntest) + DefaultRuntest, + SerialLogger) device_name = "rpi3" @@ -43,8 +44,18 @@ def invoked(self, ctx): device = Rpi3(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") - device.ensure_master_image() - device.provision() + serial_host = config.get('serial_host') + serial_port = config.get('serial_port') + serial_proc = SerialLogger( + serial_host, serial_port, 'provision-serial.log') + serial_proc.start() + try: + device.ensure_master_image() + device.provision() + except Exception as e: + raise e + finally: + serial_proc.stop() def register_arguments(self, parser): """Method called to customize the argument parser.""" From 8a75969e82ce91010f93087a7fa3b5faa24a48b1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 13 Jun 2019 12:34:10 -0500 Subject: [PATCH 233/569] Attach serial output for phases if it exists --- testflinger_agent/job.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index f0e205e8..725f0001 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -63,24 +63,28 @@ def run_test_phase(self, phase, rundir): return 0 if phase == 'reserve' and 'reserve_data' not in self.job_data: return 0 - phase_log = os.path.join(rundir, phase+'.log') + output_log = os.path.join(rundir, phase+'.log') + serial_log = os.path.join(rundir, phase+'-serial.log') logger.info('Running %s_command: %s', phase, cmd) # Set the exitcode to some failed status in case we get interrupted exitcode = 99 for line in self.banner( 'Starting testflinger {} phase on {}'.format(phase, node)): - self.run_with_log("echo '{}'".format(line), phase_log, rundir) + self.run_with_log("echo '{}'".format(line), output_log, rundir) try: - exitcode = self.run_with_log(cmd, phase_log, rundir) + exitcode = self.run_with_log(cmd, output_log, rundir) except Exception as e: logger.exception(e) finally: with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: outcome_data = json.load(f) - if os.path.exists(phase_log): - with open(phase_log, encoding='utf-8') as f: + if os.path.exists(output_log): + with open(output_log, encoding='utf-8') as f: outcome_data[phase+'_output'] = f.read() + if os.path.exists(serial_log): + with open(serial_log, encoding='utf-8') as f: + outcome_data[phase+'_serial'] = f.read() outcome_data[phase+'_status'] = exitcode with open(os.path.join(rundir, 'testflinger-outcome.json'), 'w', encoding='utf-8') as f: From d663bc461283a7753c22d2146abac5b4c0033af1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 21 Jun 2019 12:43:05 -0500 Subject: [PATCH 234/569] Ignore instead of replace on decode errors from serial. This fixes a hidden unicode problem --- devices/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/__init__.py b/devices/__init__.py index 42be123b..232916c6 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -90,7 +90,7 @@ def _log_serial(self): data = sock.recv(4096) if data: f.write(data.decode( - encoding='utf-8', errors='replace')) + encoding='utf-8', errors='ignore')) f.flush() else: snappy_device_agents.logmsg( From dbb4566a1dd385304f12d50b9234174fd5e22f5a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 24 Jun 2019 16:40:34 -0500 Subject: [PATCH 235/569] Handle submission file errors better, and a few other possible corner cases --- testflinger_cli/__init__.py | 140 +++++++++++++++++++----------------- 1 file changed, 73 insertions(+), 67 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index ece9f48e..4be6905e 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -51,18 +51,17 @@ def status(ctx, job_id): job_state = conn.get_status(job_id) except client.HTTPError as e: if e.status == 204: - print('No data found for that job id. Check the job id to be sure ' - 'it is correct') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') + raise SystemExit('No data found for that job id. Check the job ' + 'id to be sure it is correct') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id to ' + 'be sure it is correct') if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') except Exception: - print('Error communicating with server, check connection and retry') - sys.exit(1) + raise SystemExit( + 'Error communicating with server, check connection and retry') print(job_state) @@ -75,22 +74,20 @@ def cancel(ctx, job_id): job_state = conn.get_status(job_id) except client.HTTPError as e: if e.status == 204: - print('Job {} not found. Check the job id to be sure it is ' - 'correct.'.format(job_id)) - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct.') + raise SystemExit('Job {} not found. Check the job id to be sure ' + 'it is correct.'.format(job_id)) + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id to ' + 'be sure it is correct.') if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') except Exception: - print('Error communicating with server, check connection and retry') - sys.exit(1) + raise SystemExit( + 'Error communicating with server, check connection and retry') if job_state in ('complete', 'cancelled'): - print('Job {} is already in {} state and cannot be cancelled.'.format( - job_id, job_state)) - sys.exit(1) + raise SystemExit('Job {} is already in {} state and cannot be ' + 'cancelled.'.format(job_id, job_state)) conn.post_job_state(job_id, 'cancelled') @@ -104,22 +101,26 @@ def submit(ctx, filename, quiet, poll_opt): if filename == '-': data = sys.stdin.read() else: - with open(filename) as f: - data = f.read() + try: + with open(filename) as f: + data = f.read() + except FileNotFoundError: + raise SystemExit('File not found: {}'.format(filename)) + except Exception: + raise SystemExit('Unable to read file: {}'.format(filename)) try: job_id = conn.submit_job(data) except client.HTTPError as e: if e.status == 400: - print('The job you submitted contained bad data or bad ' - 'formatting, or did not specify a job_queue.') + raise SystemExit('The job you submitted contained bad data or ' + 'bad formatting, or did not specify a ' + 'job_queue.') if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - else: - # This shouldn't happen, so let's get the full trace - print('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) - sys.exit(1) + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') + # This shouldn't happen, so let's get more information + raise SystemExit('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) if quiet: print(job_id) else: @@ -138,14 +139,16 @@ def show(ctx, job_id): results = conn.show_job(job_id) except client.HTTPError as e: if e.status == 204: - print('No data found for that job id.') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') + raise SystemExit('No data found for that job id.') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id to ' + 'be sure it is correct') if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') + # This shouldn't happen, so let's get more information + raise SystemExit('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) print(json.dumps(results, sort_keys=True, indent=4)) @@ -158,17 +161,19 @@ def results(ctx, job_id): results = conn.get_results(job_id) except client.HTTPError as e: if e.status == 204: - print('No results found for that job id.') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') + raise SystemExit('No results found for that job id.') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id to ' + 'be sure it is correct') if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') + # This shouldn't happen, so let's get more information + raise SystemExit('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) except Exception: - print('Error communicating with server, check connection and retry') - sys.exit(1) + raise SystemExit( + 'Error communicating with server, check connection and retry') print(json.dumps(results, sort_keys=True, indent=4)) @@ -184,17 +189,19 @@ def artifacts(ctx, job_id, filename): conn.get_artifact(job_id, filename) except client.HTTPError as e: if e.status == 204: - print('No artifacts tarball found for that job id.') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') + raise SystemExit('No artifacts tarball found for that job id.') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id to ' + 'be sure it is correct') if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') + # This shouldn't happen, so let's get more information + raise SystemExit('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) except Exception: - print('Error communicating with server, check connection and retry') - sys.exit(1) + raise SystemExit( + 'Error communicating with server, check connection and retry') print('Artifacts downloaded to {}'.format(filename)) @@ -255,15 +262,14 @@ def get_job_state(conn, job_id): return conn.get_status(job_id) except client.HTTPError as e: if e.status == 204: - print('No data found for that job id. Check the job id to be sure ' - 'it is correct') - elif e.status == 400: - print('Invalid job id specified. Check the job id to be sure it ' - 'is correct') + raise SystemExit('No data found for that job id. Check the job ' + 'id to be sure it is correct') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id to ' + 'be sure it is correct') if e.status == 404: - print('Received 404 error from server. Are you sure this ' - 'is a testflinger server?') - sys.exit(1) + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') except Exception: # If we fail to get the job_state here, it could be because of timeout # but we can keep going and retrying From f6a072553396953cade4076d962487909d8b1915 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 3 Jul 2019 16:39:58 -0500 Subject: [PATCH 236/569] Check for a restart marker file, and exit the agent so that it will be restart if that marker file exists --- testflinger_agent/agent.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 2a825cca..2c306d12 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -34,9 +34,32 @@ def get_offline_file(self): 'TESTFLINGER-DEVICE-OFFLINE-{}'.format( self.client.config.get('agent_id'))) + def get_restart_files(self): + # Return possible restart filenames with and without dashes + # i.e. support both: + # TESTFLINGER-DEVICE-RESTART-devname-001 + # TESTFLINGER-DEVICE-RESTART-devname001 + agent = self.client.config.get('agent_id') + files = ['/tmp/TESTFLINGER-DEVICE-RESTART-{}'.format(agent), + '/tmp/TESTFLINGER-DEVICE-RESTART-{}'.format(agent.replace('-', ''))] + return files + def check_offline(self): return os.path.exists(self.get_offline_file()) + def check_restart(self): + possible_files = self.get_restart_files() + for restart_file in possible_files: + if os.path.exists(restart_file): + try: + os.unlink(restart_file) + logger.info("Restarting agent") + raise SystemExit("Restart Requested") + except Exception: + logger.error( + "Restart requested, but unable to remove marker file!") + break + def check_job_state(self, job_id): job_data = self.client.get_result(job_id) if job_data: @@ -53,6 +76,8 @@ def process_jobs(self): # First, see if we have any old results that we couldn't send last time self.retry_old_results() + self.check_restart() + job_data = self.client.check_jobs() while job_data: job = TestflingerJob(job_data, self.client) @@ -117,6 +142,7 @@ def process_jobs(self): results_basedir = self.client.config.get('results_basedir') shutil.move(rundir, results_basedir) + self.check_restart() if self.check_offline(): # Don't get a new job if we are now marked offline break From 66eb1040431f266847fb9fe8d2682b3ac3cf7002 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 3 Jul 2019 16:40:47 -0500 Subject: [PATCH 237/569] pep8 cleanup/reformatting --- testflinger_agent/agent.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 2c306d12..a1dd8bf7 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -30,8 +30,7 @@ def __init__(self, client): def get_offline_file(self): return os.path.join( - '/tmp', - 'TESTFLINGER-DEVICE-OFFLINE-{}'.format( + '/tmp', 'TESTFLINGER-DEVICE-OFFLINE-{}'.format( self.client.config.get('agent_id'))) def get_restart_files(self): @@ -40,8 +39,10 @@ def get_restart_files(self): # TESTFLINGER-DEVICE-RESTART-devname-001 # TESTFLINGER-DEVICE-RESTART-devname001 agent = self.client.config.get('agent_id') - files = ['/tmp/TESTFLINGER-DEVICE-RESTART-{}'.format(agent), - '/tmp/TESTFLINGER-DEVICE-RESTART-{}'.format(agent.replace('-', ''))] + files = [ + '/tmp/TESTFLINGER-DEVICE-RESTART-{}'.format(agent), + '/tmp/TESTFLINGER-DEVICE-RESTART-{}'.format(agent.replace('-', '')) + ] return files def check_offline(self): @@ -82,8 +83,8 @@ def process_jobs(self): while job_data: job = TestflingerJob(job_data, self.client) logger.info("Starting job %s", job.job_id) - rundir = os.path.join( - self.client.config.get('execution_basedir'), job.job_id) + rundir = os.path.join(self.client.config.get('execution_basedir'), + job.job_id) os.makedirs(rundir) # Dump the job data to testflinger.json in our execution directory with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: @@ -104,12 +105,15 @@ def process_jobs(self): except TFServerError: pass proc = multiprocessing.Process(target=job.run_test_phase, - args=(phase, rundir,)) + args=( + phase, + rundir, + )) proc.start() while proc.is_alive(): proc.join(10) - if (self.check_job_state(job.job_id) == 'cancelled' and - phase != 'provision'): + if (self.check_job_state(job.job_id) == 'cancelled' + and phase != 'provision'): logger.info("Job cancellation was requested, exiting.") proc.terminate() exitcode = proc.exitcode @@ -128,7 +132,10 @@ def process_jobs(self): # Always run the cleanup, even if the job was cancelled proc = multiprocessing.Process(target=job.run_test_phase, - args=('cleanup', rundir,)) + args=( + 'cleanup', + rundir, + )) proc.start() proc.join() @@ -154,9 +161,10 @@ def retry_old_results(self): results_dir = self.client.config.get('results_basedir') # List all the directories in 'results_basedir', where we store the # results that we couldn't transmit before - old_results = [os.path.join(results_dir, d) - for d in os.listdir(results_dir) - if os.path.isdir(os.path.join(results_dir, d))] + old_results = [ + os.path.join(results_dir, d) for d in os.listdir(results_dir) + if os.path.isdir(os.path.join(results_dir, d)) + ] for result in old_results: try: logger.info('Attempting to send result: %s' % result) From 9cbaae66ba5a87f7d830bc4ac7531f8548a3e53c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 5 Jul 2019 08:37:04 -0500 Subject: [PATCH 238/569] Use OSError instead of any exception when trying to unlink the restart marker file --- testflinger_agent/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index a1dd8bf7..d0af3853 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -56,7 +56,7 @@ def check_restart(self): os.unlink(restart_file) logger.info("Restarting agent") raise SystemExit("Restart Requested") - except Exception: + except OSError: logger.error( "Restart requested, but unable to remove marker file!") break From 5fdb8d18f96ec2cc39c2359536747cb3445a325a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 17 Jul 2019 13:20:16 -0500 Subject: [PATCH 239/569] User cloudinit data for provisioning dragonboard, and small delay to ensure first boot tasks are complete --- devices/dragonboard/dragonboard.py | 52 +++++++++++++++++------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index be2e512f..dc3e84c9 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -286,31 +286,37 @@ def mount_writable_partition(self): self.config['snappy_writable_partition'])) raise ProvisioningError(err) - def create_extrausers(self): - """Create extrauser account for default ubuntu user""" + def create_user(self): + """Create user account for default ubuntu user""" self.mount_writable_partition() + metadata = 'instance_id: cloud-image' + userdata = ('#cloud-config\n' + 'password: ubuntu\n' + 'chpasswd:\n' + ' list:\n' + ' - ubuntu:ubuntu\n' + ' expire: False\n' + 'ssh_pwauth: True') + with open('meta-data', 'w') as mdata: + mdata.write(metadata) + with open('user-data', 'w') as udata: + udata.write(userdata) try: - self._run_control('sudo mkdir -p /mnt/user-data/ubuntu') - self._run_control('sudo chown 1000.1000 /mnt/user-data/ubuntu') - except: - raise ProvisioningError("Error creating user home dir") - try: - self._run_control('sudo mkdir -p /mnt/system-data/var/lib/') - except: - raise ProvisioningError("Error creating dir for user files") - userdata_path = os.path.normpath( - os.path.join(os.path.dirname(os.path.realpath(__file__)), - '..', '..', 'data', 'extrausers')) - cmd = ['scp', '-r', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', userdata_path, - 'linaro@{}:/tmp/'.format( - self.config['device_ip'])] - try: - subprocess.check_call(cmd, timeout=60) + output = self._run_control('ls /mnt') + if 'system-data' in str(output): + base = '/mnt/system-data' + else: + base = '/mnt' + cloud_path = os.path.join( + base, 'var/lib/cloud/seed/nocloud-net') + self._run_control('sudo mkdir -p {}'.format(cloud_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(metadata, cloud_path, 'meta-data')) self._run_control( - 'sudo cp -a /tmp/extrausers /mnt/system-data/var/lib/') + write_cmd.format(userdata, cloud_path, 'user-data')) except: - raise ProvisioningError("Error writing user files") + raise ProvisioningError("Error creating user files") def setup_sudo(self): sudo_data = 'ubuntu ALL=(ALL) NOPASSWD:ALL' @@ -374,7 +380,7 @@ def provision(self): self.flash_test_image(server_ip, server_port) file_server.terminate() logger.info("Creating Test User") - self.create_extrausers() + self.create_user() self.setup_sudo() logger.info("Booting Test Image") self.ensure_test_image(test_username, test_password) @@ -382,4 +388,6 @@ def provision(self): # wipe out whatever we installed if things go badly self.wipe_test_device() raise + # Brief delay to ensure first boot tasks are complete + time.sleep(60) logger.info("END provision") From 5464ab641f157a86641bd485ea500c3ac5ef38ba Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 17 Jul 2019 13:21:02 -0500 Subject: [PATCH 240/569] Use ssh opts when copying ssh key for reservations --- devices/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/devices/__init__.py b/devices/__init__.py index 232916c6..4859b2b4 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -167,6 +167,8 @@ def invoked(self, ctx): 'Unable to import ssh key from: {}'.format(key)) continue cmd = ['ssh-copy-id', '-f', '-i', 'key.pub', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', '{}@{}'.format(test_username, device_ip)] for retry in range(10): # Retry ssh key copy just in case it's rebooting From bcd6f35c303445983f1557324268cc9dcdfcf128 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 22 Jul 2019 16:43:30 -0500 Subject: [PATCH 241/569] Automatic daily log rotation --- testflinger_agent/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index f4f14797..a83360ea 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -22,6 +22,7 @@ from testflinger_agent import schema from testflinger_agent.agent import TestflingerAgent from testflinger_agent.client import TestflingerClient +from logging.handlers import TimedRotatingFileHandler logger = logging.getLogger(__name__) @@ -66,9 +67,12 @@ def configure_logging(config): logfmt = logging.Formatter( fmt='[%(asctime)s] %(levelname)+7.7s: %(message)s', datefmt='%y-%m-%d %H:%M:%S') - file_log = logging.FileHandler( - filename=os.path.join(config.get('logging_basedir'), - 'testflinger-agent.log')) + log_path = os.path.join( + config.get('logging_basedir'), 'testflinger-agent.log') + file_log = TimedRotatingFileHandler(log_path, + when="midnight", + interval=1, + backupCount=6) file_log.setFormatter(logfmt) logger.addHandler(file_log) if not config.get('logging_quiet'): From 498dc8ffa6d21ead4491bebb004b7f8a00ba059f Mon Sep 17 00:00:00 2001 From: "Taihsiang Ho (tai271828)" Date: Fri, 26 Jul 2019 18:26:29 +0800 Subject: [PATCH 242/569] maas: show timeout information --- devices/maas2/maas2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 95661b76..8051ced9 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -94,6 +94,7 @@ def provision(self): minutes_spent = 0 if timeout_min is None: timeout_min = 60 + self._logger_info("Timeout value: {} minutes.".format(timeout_min)) while minutes_spent < timeout_min: time.sleep(60) minutes_spent += 1 From 27571be98af9e926eaecedbdfcef15a2b4f4b869 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 1 Aug 2019 11:57:12 -0500 Subject: [PATCH 243/569] Remove the fake cloud-init data that prevents cloud-init from finding our config on eoan/rpi --- devices/muxpi/muxpi.py | 5 +++++ devices/rpi3/rpi3.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 998ca1fb..64ae07d4 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -181,6 +181,11 @@ def create_user(self): write_cmd.format(metadata, cloud_path, 'meta-data')) self._run_control( write_cmd.format(userdata, cloud_path, 'user-data')) + # This needs to be removed on eoan for rpi, else cloud-init + # won't find the user-data we give it + rm_cmd = "sudo rm -f {}".format( + os.path.join(base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) + self._run_control(rm_cmd) except Exception: raise ProvisioningError("Error creating user files") diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index 99417cc2..5001af4c 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -320,6 +320,11 @@ def create_user(self): write_cmd.format(metadata, cloud_path, 'meta-data')) self._run_control( write_cmd.format(userdata, cloud_path, 'user-data')) + # This needs to be removed on eoan for rpi, else cloud-init + # won't find the user-data we give it + rm_cmd = "sudo rm -f {}".format( + os.path.join(base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) + self._run_control(rm_cmd) except: raise ProvisioningError("Error creating user files") From 352b090380868a1a30a1d76bd754a8891ccbe215 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 22 Aug 2019 14:39:21 -0500 Subject: [PATCH 244/569] Generally make the testflinger agent more tolerant of things that could go wrong when trying to connect to the server. If we can't connect to the server, retry when appropriate, and try to fail as gracefully as possible, while still preserving exception tracebacks in the logs. --- testflinger_agent/__init__.py | 1 - testflinger_agent/agent.py | 111 ++++++++++++++++++---------------- testflinger_agent/client.py | 60 +++++++++++++----- 3 files changed, 103 insertions(+), 69 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index a83360ea..230cffe0 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -15,7 +15,6 @@ import argparse import logging import os -import sys import time import yaml diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index d0af3853..5e67eda6 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -81,63 +81,70 @@ def process_jobs(self): job_data = self.client.check_jobs() while job_data: - job = TestflingerJob(job_data, self.client) - logger.info("Starting job %s", job.job_id) - rundir = os.path.join(self.client.config.get('execution_basedir'), - job.job_id) - os.makedirs(rundir) - # Dump the job data to testflinger.json in our execution directory - with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: - json.dump(job_data, f) - # Create json outcome file where phases will store their output - with open(os.path.join(rundir, 'testflinger-outcome.json'), - 'w') as f: - json.dump({}, f) - - for phase in TEST_PHASES: - # First make sure the job hasn't been cancelled - if self.check_job_state(job.job_id) == 'cancelled': - logger.info("Job cancellation was requested, exiting.") - break - # Try to update the job_state on the testflinger server - try: - self.client.post_result(job.job_id, {'job_state': phase}) - except TFServerError: - pass + try: + job = TestflingerJob(job_data, self.client) + logger.info("Starting job %s", job.job_id) + rundir = os.path.join( + self.client.config.get('execution_basedir'), + job.job_id) + os.makedirs(rundir) + # Dump the job data to testflinger.json in our execution dir + with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: + json.dump(job_data, f) + # Create json outcome file where phases will store their output + with open( + os.path.join(rundir, 'testflinger-outcome.json'), + 'w') as f: + json.dump({}, f) + + for phase in TEST_PHASES: + # First make sure the job hasn't been cancelled + if self.check_job_state(job.job_id) == 'cancelled': + logger.info("Job cancellation was requested, exiting.") + break + # Try to update the job_state on the testflinger server + try: + self.client.post_result( + job.job_id, {'job_state': phase}) + except TFServerError: + pass + proc = multiprocessing.Process(target=job.run_test_phase, + args=( + phase, + rundir, + )) + proc.start() + while proc.is_alive(): + proc.join(10) + if (self.check_job_state(job.job_id) == 'cancelled' + and phase != 'provision'): + logger.info( + "Job cancellation was requested, exiting.") + proc.terminate() + exitcode = proc.exitcode + + # exit code 46 is our indication that recovery failed! + # In this case, we need to mark the device offline + if exitcode == 46: + self.mark_device_offline() + self.client.repost_job(job_data) + shutil.rmtree(rundir) + # Return NOW so we don't keep trying to process jobs + return + if phase != 'test' and exitcode: + logger.debug('Phase %s failed, aborting job' % phase) + break + except Exception as e: + logger.exception(e) + finally: + # Always run the cleanup, even if the job was cancelled proc = multiprocessing.Process(target=job.run_test_phase, args=( - phase, + 'cleanup', rundir, )) proc.start() - while proc.is_alive(): - proc.join(10) - if (self.check_job_state(job.job_id) == 'cancelled' - and phase != 'provision'): - logger.info("Job cancellation was requested, exiting.") - proc.terminate() - exitcode = proc.exitcode - - # exit code 46 is our indication that recovery failed! - # In this case, we need to mark the device offline - if exitcode == 46: - self.mark_device_offline() - self.client.repost_job(job_data) - shutil.rmtree(rundir) - # Return NOW so we don't keep trying to process jobs - return - if phase != 'test' and exitcode: - logger.debug('Phase %s failed, aborting job' % phase) - break - - # Always run the cleanup, even if the job was cancelled - proc = multiprocessing.Process(target=job.run_test_phase, - args=( - 'cleanup', - rundir, - )) - proc.start() - proc.join() + proc.join() try: self.client.transmit_job_outcome(rundir) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 6e17f533..ca0445a0 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -21,7 +21,8 @@ import time from urllib.parse import urljoin - +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry from testflinger_agent.errors import TFServerError @@ -36,6 +37,20 @@ def __init__(self, config): if not self.server.lower().startswith('http'): self.server = 'http://' + self.server + def _requests_retry(self, retries=3): + session = requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=.3, + status_forcelist=(500, 502, 503, 504) + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + def check_jobs(self): """Check for new jobs for on the Testflinger server @@ -67,10 +82,15 @@ def repost_job(self, job_data): logger.info('Resubmitting job: %s', job_id) job_output = """ There was an unrecoverable error while running this stage. Your job - has been automatically resubmitted back to the queue. + will attempt to be automatically resubmitted back to the queue. Resubmitting job: {}\n""".format(job_id) self.post_live_output(job_id, job_output) - job_request = requests.post(job_uri, json=job_data) + try: + session = self._requests_retry(retries=5) + job_request = session.post(job_uri, json=job_data) + except Exception as e: + logger.exception(e) + raise TFServerError('other exception') if not job_request: logger.error('Unable to re-post job to: %s (error: %s)' % (job_uri, job_request.status_code)) @@ -86,7 +106,11 @@ def post_result(self, job_id, data): """ result_uri = urljoin(self.server, '/v1/result/') result_uri = urljoin(result_uri, job_id) - job_request = requests.post(result_uri, json=data) + try: + job_request = requests.post(result_uri, json=data) + except Exception as e: + logger.exception(e) + raise TFServerError('other exception') if not job_request: logger.error('Unable to post results to: %s (error: %s)' % (result_uri, job_request.status_code)) @@ -103,7 +127,11 @@ def get_result(self, job_id): """ result_uri = urljoin(self.server, '/v1/result/') result_uri = urljoin(result_uri, job_id) - job_request = requests.get(result_uri) + try: + job_request = requests.get(result_uri) + except Exception as e: + logger.exception(e) + return {} if not job_request: logger.error('Unable to get results from: %s (error: %s)' % (result_uri, job_request.status_code)) @@ -122,19 +150,9 @@ def transmit_job_outcome(self, rundir): with open(os.path.join(rundir, 'testflinger.json')) as f: job_data = json.load(f) job_id = job_data.get('job_id') - # Do not retransmit outcome if it's already been done and removed - outcome_file = os.path.join(rundir, 'testflinger-outcome.json') - if os.path.exists(outcome_file): - logger.info('Submitting job outcome for job: %s' % job_id) - with open(outcome_file) as f: - data = json.load(f) - data['job_state'] = 'complete' - self.post_result(job_id, data) - # Remove the outcome file so we don't retransmit - os.unlink(outcome_file) - artifacts_dir = os.path.join(rundir, 'artifacts') # If we find an 'artifacts' dir under rundir, archive it, and transmit # it to the Testflinger server + artifacts_dir = os.path.join(rundir, 'artifacts') if os.path.exists(artifacts_dir): with tempfile.TemporaryDirectory() as tmpdir: artifact_file = os.path.join(tmpdir, 'artifacts') @@ -155,6 +173,16 @@ def transmit_job_outcome(self, rundir): raise TFServerError(artifact_request.status_code) else: shutil.rmtree(artifacts_dir) + # Do not retransmit outcome if it's already been done and removed + outcome_file = os.path.join(rundir, 'testflinger-outcome.json') + if os.path.exists(outcome_file): + logger.info('Submitting job outcome for job: %s' % job_id) + with open(outcome_file) as f: + data = json.load(f) + data['job_state'] = 'complete' + self.post_result(job_id, data) + # Remove the outcome file so we don't retransmit + os.unlink(outcome_file) shutil.rmtree(rundir) def post_live_output(self, job_id, data): From 090afec1a8f50074f7d8efc1284fc34e1e830789 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 Sep 2019 11:21:28 -0500 Subject: [PATCH 245/569] Specify timeout values for requests --- testflinger_agent/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index ca0445a0..edaaecf2 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -107,7 +107,7 @@ def post_result(self, job_id, data): result_uri = urljoin(self.server, '/v1/result/') result_uri = urljoin(result_uri, job_id) try: - job_request = requests.post(result_uri, json=data) + job_request = requests.post(result_uri, json=data, timeout=30) except Exception as e: logger.exception(e) raise TFServerError('other exception') @@ -128,7 +128,7 @@ def get_result(self, job_id): result_uri = urljoin(self.server, '/v1/result/') result_uri = urljoin(result_uri, job_id) try: - job_request = requests.get(result_uri) + job_request = requests.get(result_uri, timeout=30) except Exception as e: logger.exception(e) return {} @@ -166,7 +166,7 @@ def transmit_job_outcome(self, rundir): file_upload = { 'file': ('file', tarball, 'application/x-gzip')} artifact_request = requests.post( - artifact_uri, files=file_upload) + artifact_uri, files=file_upload, timeout=600) if not artifact_request: logger.error('Unable to post results to: %s (error: %s)' % (artifact_uri, artifact_request.status_code)) @@ -197,7 +197,7 @@ def post_live_output(self, job_id, data): '/v1/result/{}/output'.format(job_id)) try: job_request = requests.post( - output_uri, data=data.encode('utf-8')) + output_uri, data=data.encode('utf-8'), timeout=60) except Exception as e: logger.exception(e) return False From ba858d099db23a1f59e786d1278f912f24872ba2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 13 Sep 2019 13:58:34 -0500 Subject: [PATCH 246/569] At least for ProvisioningError, hide the traceback and print a nicer error for the user to know that maas failed to deploy the system --- devices/maas2/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index 47abcedf..db55fc75 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -24,6 +24,7 @@ from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, + ProvisioningError, DefaultReserve, DefaultRuntest, SerialLogger) @@ -53,6 +54,9 @@ def invoked(self, ctx): serial_proc.start() try: device.provision() + except ProvisioningError as e: + logmsg(logging.ERROR, "Provisioning failed: {}".format(str(e))) + return 1 except Exception as e: raise e finally: From e617af55e14e5a1bdc5d54515632b09710db6e60 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Sep 2019 10:43:59 -0500 Subject: [PATCH 247/569] Ignore missing template keys in test_cmds --- snappy_device_agents/__init__.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index f3674339..73e38d44 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -393,6 +393,24 @@ def run_test_cmds(cmds, config=None, env={}): return 1 +def _process_cmds_template_vars(cmds, config=None): + """ + Fill in templated values for test command string. Ignore any values + in braces for which we don't have a config item. + + :param cmds: + Commands to run as a list of strings + :param config: + Config data for the device which can be used for filling templates + """ + class IgnoreUnknownDict(dict): + def __missing__(self, key): + return "{" + key + "}" + # Ensure config isn't None before we convert to IgnoreUnknownDict + config = IgnoreUnknownDict(config or {}) + return cmds.format_map(config) + + def _run_test_cmds_list(cmds, config=None, env={}): """ Run the test commands provided @@ -412,11 +430,7 @@ def _run_test_cmds_list(cmds, config=None, env={}): for cmd in cmds: # Settings from the device yaml configfile like device_ip can be # formatted in test commands like "foo {device_ip}" - try: - cmd = cmd.format(**config) - except: - exitcode = 20 - logmsg(logging.ERROR, "Unable to format command: %s", cmd) + cmd = _process_cmds_template_vars(cmd, config) logmsg(logging.INFO, "Running: %s", cmd) rc = runcmd(cmd, env) @@ -445,11 +459,7 @@ def _run_test_cmds_str(cmds, config=None, env={}): if not cmds.startswith('#!'): cmds = "#!/bin/bash\n" + cmds - try: - cmds = cmds.format(**config) - except KeyError as e: - logmsg(logging.ERROR, "Unable to format key: %s", e.args[0]) - return 20 + cmds = _process_cmds_template_vars(cmds, config) with open('tf_cmd_script', mode='w', encoding='utf-8') as tf_cmd_script: tf_cmd_script.write(cmds) os.chmod('tf_cmd_script', 0o775) From 24e92c5dd95aae2752cc1aaf06c84bf903573b9f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sun, 22 Sep 2019 22:23:50 -0500 Subject: [PATCH 248/569] New way of ignoring undefined template values that should work better --- snappy_device_agents/__init__.py | 34 ++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 73e38d44..3e3abc0f 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -403,12 +403,34 @@ def _process_cmds_template_vars(cmds, config=None): :param config: Config data for the device which can be used for filling templates """ - class IgnoreUnknownDict(dict): - def __missing__(self, key): - return "{" + key + "}" - # Ensure config isn't None before we convert to IgnoreUnknownDict - config = IgnoreUnknownDict(config or {}) - return cmds.format_map(config) + class IgnoreUnknownFormatter(string.Formatter): + def vformat(self, format_string, args, kwargs): + tokens = [] + for (literal, field_name, spec, conv) in self.parse(format_string): + # replace double braces if parse removed them + literal = literal.replace('{', '{{').replace('}', '}}') + # if parse didn't find field name in braces, just add the string + if not field_name: + tokens.append(literal) + else: + #if conf and spec are not defined, set to '' + conv = '!' + conv if conv else '' + spec = ':' + spec if spec else '' + # only consider field before index + field = field_name.split('[')[0].split('.')[0] + # If this field is one we've defined, fill template value + if field in kwargs: + tokens.extend([literal, '{', field_name, conv, spec, '}']) + else: + # If not, the use escaped braces to pass it through + tokens.extend([literal, '{{', field_name, conv, spec, '}}']) + format_string = ''.join(tokens) + return string.Formatter.vformat(self, format_string, args, kwargs) + # Ensure config is a dict + if not isinstance(config, dict): + config = {} + formatter = IgnoreUnknownFormatter() + return formatter.format(cmds, **config) def _run_test_cmds_list(cmds, config=None, env={}): From 9b88afe0e2d51355d3543bf83c8d295474ef493c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sun, 22 Sep 2019 22:30:49 -0500 Subject: [PATCH 249/569] Missing import for string --- snappy_device_agents/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3e3abc0f..9bed8c31 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -21,6 +21,7 @@ import os import shutil import socket +import string import subprocess import sys import tempfile From 2d5861b78d6573f953b420aa2e4fd1a4eec6ad54 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 23 Sep 2019 11:03:35 -0500 Subject: [PATCH 250/569] Pass {} as {{}} in the test_cmds. We never intend this for string formatting, only as literal for passing to something else --- snappy_device_agents/__init__.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 9bed8c31..36c5215f 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -410,21 +410,17 @@ def vformat(self, format_string, args, kwargs): for (literal, field_name, spec, conv) in self.parse(format_string): # replace double braces if parse removed them literal = literal.replace('{', '{{').replace('}', '}}') - # if parse didn't find field name in braces, just add the string - if not field_name: - tokens.append(literal) + # if conf and spec are not defined, set to '' + conv = '!' + conv if conv else '' + spec = ':' + spec if spec else '' + # only consider field before index + field = field_name.split('[')[0].split('.')[0] + # If this field is one we've defined, fill template value + if field in kwargs: + tokens.extend([literal, '{', field_name, conv, spec, '}']) else: - #if conf and spec are not defined, set to '' - conv = '!' + conv if conv else '' - spec = ':' + spec if spec else '' - # only consider field before index - field = field_name.split('[')[0].split('.')[0] - # If this field is one we've defined, fill template value - if field in kwargs: - tokens.extend([literal, '{', field_name, conv, spec, '}']) - else: - # If not, the use escaped braces to pass it through - tokens.extend([literal, '{{', field_name, conv, spec, '}}']) + # If not, the use escaped braces to pass it through + tokens.extend([literal, '{{', field_name, conv, spec, '}}']) format_string = ''.join(tokens) return string.Formatter.vformat(self, format_string, args, kwargs) # Ensure config is a dict From d52874f1c27033d84646fda1cd788b16ba27d1b9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 23 Sep 2019 11:23:08 -0500 Subject: [PATCH 251/569] Better handling of empty braces --- snappy_device_agents/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 36c5215f..6653dd29 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -410,6 +410,10 @@ def vformat(self, format_string, args, kwargs): for (literal, field_name, spec, conv) in self.parse(format_string): # replace double braces if parse removed them literal = literal.replace('{', '{{').replace('}', '}}') + # if parse didn't find field name in braces, just add empty braces + if not field_name: + tokens.extend([literal, '{{}}']) + continue # if conf and spec are not defined, set to '' conv = '!' + conv if conv else '' spec = ':' + spec if spec else '' From 72a8e42f4a44c158e45d17058c475ba14825846a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 23 Sep 2019 12:32:42 -0500 Subject: [PATCH 252/569] Handle end of file without injecting empty braces --- snappy_device_agents/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 6653dd29..951ef4a0 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -410,10 +410,14 @@ def vformat(self, format_string, args, kwargs): for (literal, field_name, spec, conv) in self.parse(format_string): # replace double braces if parse removed them literal = literal.replace('{', '{{').replace('}', '}}') - # if parse didn't find field name in braces, just add empty braces - if not field_name: + # if the field is {}, just add escaped empty braces + if field_name == '': tokens.extend([literal, '{{}}']) continue + # if field name was None, we just add the literal token + if field_name == None: + tokens.extend([literal]) + continue # if conf and spec are not defined, set to '' conv = '!' + conv if conv else '' spec = ':' + spec if spec else '' From 45ea23074203b901352db8f9ca69cc62206bde7f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 23 Sep 2019 14:50:10 -0500 Subject: [PATCH 253/569] A few opportunistic cleanups --- snappy_device_agents/__init__.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 951ef4a0..03336615 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -100,7 +100,7 @@ def delayretry(func, args, max_retries=3, delay=0): for retry_count in range(max_retries): try: ret = func(*args) - except: + except Exception: time.sleep(delay) if retry_count == max_retries-1: raise @@ -130,7 +130,7 @@ def udf_create_image(params): try: output_opt = cmd.index('-o') cmd[output_opt + 1] = imagepath - except: + except Exception: # if we get here, -o was already not in the image cmd.append('-o') cmd.append(tmp_imagepath) @@ -185,7 +185,7 @@ def get_image(job_data='testflinger.json'): if 'download_files' in image_keys: for url in testflinger_data.get( 'provision_data').get('download_files'): - download(url) + download(url) if 'url' in image_keys: try: url = testflinger_data.get('provision_data').get('url') @@ -231,7 +231,7 @@ def serve_file(q, filename): port = server.getsockname()[1] q.put(port) server.listen(1) - (client, addr) = server.accept() + (client, _) = server.accept() with open(filename, mode='rb') as f: while True: data = f.read(16 * 1024 * 1024) @@ -257,14 +257,14 @@ def compress_file(filename): os.unlink(compressed_filename) except FileNotFoundError: pass - if filetype(filename) is 'xz': + if filetype(filename) == 'xz': # just hard link it so we can unlink later without special handling os.rename(filename, compressed_filename) - elif filetype(filename) is 'gz': + elif filetype(filename) == 'gz': with lzma.open(compressed_filename, 'wb') as compressed_image: with gzip.GzipFile(filename, 'rb') as old_compressed: shutil.copyfileobj(old_compressed, compressed_image) - elif filetype(filename) is 'bz2': + elif filetype(filename) == 'bz2': with lzma.open(compressed_filename, 'wb') as compressed_image: with bz2.BZ2File(filename, 'rb') as old_compressed: shutil.copyfileobj(old_compressed, compressed_image) @@ -385,13 +385,12 @@ def run_test_cmds(cmds, config=None, env={}): env = os.environ.copy() config_env = config.get('env', {}) env.update(config_env) - if type(cmds) is list: + if isinstance(cmds, list): return _run_test_cmds_list(cmds, config, env) - elif type(cmds) is str: + if isinstance(cmds, str): return _run_test_cmds_str(cmds, config, env) - else: - logmsg(logging.ERROR, "test_cmds field must be a list or string") - return 1 + logmsg(logging.ERROR, "test_cmds field must be a list or string") + return 1 def _process_cmds_template_vars(cmds, config=None): @@ -415,7 +414,7 @@ def vformat(self, format_string, args, kwargs): tokens.extend([literal, '{{}}']) continue # if field name was None, we just add the literal token - if field_name == None: + if field_name is None: tokens.extend([literal]) continue # if conf and spec are not defined, set to '' @@ -428,7 +427,8 @@ def vformat(self, format_string, args, kwargs): tokens.extend([literal, '{', field_name, conv, spec, '}']) else: # If not, the use escaped braces to pass it through - tokens.extend([literal, '{{', field_name, conv, spec, '}}']) + tokens.extend( + [literal, '{{', field_name, conv, spec, '}}']) format_string = ''.join(tokens) return string.Formatter.vformat(self, format_string, args, kwargs) # Ensure config is a dict From 39c8a12ed40e20acfcec04e338ccfa0ac4b6148b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 26 Sep 2019 09:59:21 -0500 Subject: [PATCH 254/569] Add unit tests for recent template parsing changes --- setup.cfg | 2 ++ setup.py | 6 ++++ tests/__init__.py | 15 ++++++++++ tests/test_snappy_device_agents.py | 44 ++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/test_snappy_device_agents.py diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..31ad82b6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[aliases] +test = pytest diff --git a/setup.py b/setup.py index 0ff475bc..2466e81e 100755 --- a/setup.py +++ b/setup.py @@ -28,6 +28,10 @@ datafiles = [(d, [os.path.join(d, f) for f in files]) for d, folders, files in os.walk('data')] +TEST_REQUIRES = [ + "pytest", +] + setup( name='snappy-device-agents', version=VERSION, @@ -39,7 +43,9 @@ license='GPLv3', packages=find_packages(), data_files=datafiles, + setup_requires=['pytest-runner'], install_requires=['guacamole >= 0.9', 'PyYAML>=3.11', 'netifaces>=0.10.4'], + tests_require=TEST_REQUIRES, scripts=['snappy-device-agent'], ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..336bdc25 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,15 @@ +# Copyright (C) 2019 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# diff --git a/tests/test_snappy_device_agents.py b/tests/test_snappy_device_agents.py new file mode 100644 index 00000000..b1d5434a --- /dev/null +++ b/tests/test_snappy_device_agents.py @@ -0,0 +1,44 @@ +# Copyright (C) 2019 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import snappy_device_agents + + +class TestCommandsTemplate: + """ Tests to ensure test_cmds templating works properly """ + + def test_known_config_items(self): + """ Known config items should fill in the expected value""" + cmds = "test {item}" + config = {"item": "foo"} + expected = "test foo" + assert snappy_device_agents._process_cmds_template_vars( + cmds, config) == expected + + def test_unknown_config_items(self): + """ Unknown config items should not cause an error """ + cmds = "test {unknown_item}" + config = {} + assert snappy_device_agents._process_cmds_template_vars( + cmds, config) == cmds + + def test_escaped_braces(self): + """ Escaped braces should be unescaped, not interpreted """ + cmds = "test {{item}}" + config = {"item": "foo"} + expected = "test {item}" + assert snappy_device_agents._process_cmds_template_vars( + cmds, config) == expected From 89a9130ad98daca9c2d4a2c4e969535f12f6ba02 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 26 Sep 2019 13:07:24 -0500 Subject: [PATCH 255/569] flake8 cleanups --- snappy_device_agents/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 951ef4a0..2ceb5dfe 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -415,7 +415,7 @@ def vformat(self, format_string, args, kwargs): tokens.extend([literal, '{{}}']) continue # if field name was None, we just add the literal token - if field_name == None: + if field_name is None: tokens.extend([literal]) continue # if conf and spec are not defined, set to '' @@ -425,10 +425,12 @@ def vformat(self, format_string, args, kwargs): field = field_name.split('[')[0].split('.')[0] # If this field is one we've defined, fill template value if field in kwargs: - tokens.extend([literal, '{', field_name, conv, spec, '}']) + tokens.extend( + [literal, '{', field_name, conv, spec, '}']) else: # If not, the use escaped braces to pass it through - tokens.extend([literal, '{{', field_name, conv, spec, '}}']) + tokens.extend( + [literal, '{{', field_name, conv, spec, '}}']) format_string = ''.join(tokens) return string.Formatter.vformat(self, format_string, args, kwargs) # Ensure config is a dict From 6982459c943967ad2e049675b90d259c49078357 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 29 Oct 2019 15:41:21 -0500 Subject: [PATCH 256/569] Allow agents to declare advertised_queues and report them to the server --- README.rst | 4 ++++ testflinger-agent.conf.example | 4 ++++ testflinger_agent/agent.py | 32 +++++++++++++++++++++++++++++++- testflinger_agent/client.py | 12 ++++++++++++ testflinger_agent/schema.py | 1 + 5 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 58a26d80..e2cff6de 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,10 @@ The following configuration options are supported: - List of queues that can be serviced by this device +- **advertised_queues**: + + - List of public queue names that should be reported to the server to report to users + - **setup_command**: - Command to run for the setup phase diff --git a/testflinger-agent.conf.example b/testflinger-agent.conf.example index f1b8e22b..87056fe6 100644 --- a/testflinger-agent.conf.example +++ b/testflinger-agent.conf.example @@ -30,6 +30,10 @@ # - myqueue # - anotherqueue +# List of advertised queues to show users when they ask +# advertised_queues: +# myqueue: A brief description of myqueue + # Command to run for the setup phase # setup_command: echo setup phase && run-setup-tasks.sh diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 5e67eda6..89ba5bed 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -17,6 +17,7 @@ import multiprocessing import os import shutil +import time from testflinger_agent.job import TestflingerJob from testflinger_agent.errors import TFServerError @@ -27,6 +28,27 @@ class TestflingerAgent: def __init__(self, client): self.client = client + self._state = multiprocessing.Array('c', 16) + self.set_state('waiting') + self.status_proc = multiprocessing.Process(target=self._status_worker) + self.status_proc.daemon = True + self.status_proc.start() + + def _status_worker(self): + # Report advertised queues to testflinger server when we are listening + advertised_queues = self.client.config.get('advertised_queues') + if not advertised_queues: + # Nothing to do unless there are advertised_queues configured + raise SystemExit + + while True: + # Post every 2min unless the agent is offline + if self._state.value.decode('utf-8') != 'offline': + self.client.post_queues(advertised_queues) + time.sleep(120) + + def set_state(self, state): + self._state.value = state.encode('utf-8') def get_offline_file(self): return os.path.join( @@ -46,7 +68,12 @@ def get_restart_files(self): return files def check_offline(self): - return os.path.exists(self.get_offline_file()) + if os.path.exists(self.get_offline_file()): + self.set_state('offline') + return True + else: + self.set_state('waiting') + return False def check_restart(self): possible_files = self.get_restart_files() @@ -55,6 +82,7 @@ def check_restart(self): try: os.unlink(restart_file) logger.info("Restarting agent") + self.set_state('offline') raise SystemExit("Restart Requested") except OSError: logger.error( @@ -108,6 +136,7 @@ def process_jobs(self): job.job_id, {'job_state': phase}) except TFServerError: pass + self.set_state(phase) proc = multiprocessing.Process(target=job.run_test_phase, args=( phase, @@ -155,6 +184,7 @@ def process_jobs(self): logger.exception(e) results_basedir = self.client.config.get('results_basedir') shutil.move(rundir, results_basedir) + self.set_state('waiting') self.check_restart() if self.check_offline(): diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index edaaecf2..e6c7c6f4 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -202,3 +202,15 @@ def post_live_output(self, job_id, data): logger.exception(e) return False return bool(job_request) + + def post_queues(self, data): + """Post the list of advertised queues to testflinger server + + :param data: + dict of queue name and descriptions to send to the server + """ + queues_uri = urljoin(self.server, '/v1/agents/queues') + try: + requests.post(queues_uri, json=data, timeout=30) + except Exception as e: + logger.exception(e) diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index de1106b5..88642482 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -34,6 +34,7 @@ voluptuous.Required('cleanup_command', default=''): str, voluptuous.Optional('global_timeout'): int, voluptuous.Optional('output_timeout'): int, + voluptuous.Optional('advertised_queues'): dict, } From 226cd6c547022af348a6b0c49f3f19caea470773 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 29 Oct 2019 15:49:04 -0500 Subject: [PATCH 257/569] Add a new subcommand to list the advertised queues --- testflinger_cli/__init__.py | 18 ++++++++++++++++++ testflinger_cli/client.py | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 4be6905e..b78b9a8e 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -275,3 +275,21 @@ def get_job_state(conn, job_id): # but we can keep going and retrying pass return 'unknown' + + +@cli.command() +@click.pass_context +def queues(ctx): + conn = ctx.obj['conn'] + try: + queues = conn.get_queues() + except client.HTTPError as e: + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') + except Exception: + raise SystemExit( + 'Error communicating with server, check connection and retry') + print('Advertised queues on this server:') + for name, description in queues.items(): + print(f' {name} - {description}') diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index bd1de3a9..90e02734 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -171,3 +171,12 @@ def get_job_position(self, job_id): """ endpoint = '/v1/job/{}/position'.format(job_id) return self.get(endpoint) + + def get_queues(self): + """Get the advertised queues from the testflinger server""" + endpoint = '/v1/agents/queues' + data = self.get(endpoint) + try: + return json.loads(data) + except ValueError: + return {} From 16136e160267be5020274430c4929271dbe0be00 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 30 Oct 2019 09:36:38 -0500 Subject: [PATCH 258/569] remove f strings because some systems have too old of a python version --- testflinger_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index b78b9a8e..49c02182 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -292,4 +292,4 @@ def queues(ctx): 'Error communicating with server, check connection and retry') print('Advertised queues on this server:') for name, description in queues.items(): - print(f' {name} - {description}') + print(' {} - {}'.format(name, description)) From 32eeda5a1ba05d5e04bf31fdc255bd3a6e63c04d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 Nov 2019 11:27:50 -0600 Subject: [PATCH 259/569] Move some maas2 device config data into instance scope --- devices/maas2/maas2.py | 47 ++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 8051ced9..b20c8b36 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -36,6 +36,10 @@ def __init__(self, config, job_data): self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) + self.maas_user = self.config.get('maas_user') + self.node_id = self.config.get('node_id') + self.agent_name = self.config.get('agent_name') + self.timeout_min = int(self.config.get('timeout_min', 60)) def _logger_debug(self, message): logger.debug("MAAS: {}".format(message)) @@ -53,26 +57,21 @@ def _logger_critical(self, message): logger.critical("MAAS: {}".format(message)) def recover(self): - agent_name = self.config.get('agent_name') - self._logger_info("Releasing node {}".format(agent_name)) + self._logger_info("Releasing node {}".format(self.agent_name)) self.node_release() def provision(self): - maas_user = self.config.get('maas_user') - node_id = self.config.get('node_id') - agent_name = self.config.get('agent_name') - timeout_min = self.config.get('timeout_min') provision_data = self.job_data.get('provision_data') # Default to a safe LTS if no distro is specified distro = provision_data.get('distro', 'xenial') self._logger_info('Acquiring node') - cmd = ['maas', maas_user, 'machines', 'allocate', - 'system_id={}'.format(node_id)] + cmd = ['maas', self.maas_user, 'machines', 'allocate', + 'system_id={}'.format(self.node_id)] # Do not use runcmd for this - we need the output, not the end user subprocess.check_call(cmd) self._logger_info('Starting node {} ' - 'with distro {}'.format(agent_name, distro)) - cmd = ['maas', maas_user, 'machine', 'deploy', node_id, + 'with distro {}'.format(self.agent_name, distro)) + cmd = ['maas', self.maas_user, 'machine', 'deploy', self.node_id, 'distro_series={}'.format(distro)] kernel = provision_data.get('kernel') if kernel: @@ -92,10 +91,9 @@ def provision(self): # Make sure the device is available before returning minutes_spent = 0 - if timeout_min is None: - timeout_min = 60 - self._logger_info("Timeout value: {} minutes.".format(timeout_min)) - while minutes_spent < timeout_min: + self._logger_info("Timeout value: {} minutes.".format( + self.timeout_min)) + while minutes_spent < self.timeout_min: time.sleep(60) minutes_spent += 1 self._logger_info('{} minutes passed ' @@ -114,12 +112,12 @@ def provision(self): self._logger_info('Deployed and booted.') return - self._logger_error('Device {} still in "{}" state, ' - 'deployment failed!'.format(agent_name, status)) + self._logger_error('Device {} still in "{}" state, deployment ' + 'failed!'.format(self.agent_name, status)) self._logger_error(process.stdout.decode()) exception_msg = "Provisioning failed because deployment timeout. " + \ "Deploying for more than " + \ - "{} minutes.".format(timeout_min) + "{} minutes.".format(self.timeout_min) raise ProvisioningError(exception_msg) def check_test_image_booted(self): @@ -131,7 +129,7 @@ def check_test_image_booted(self): try: subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) - except: + except Exception: return False # If we get here, then the above command proved we are booted return True @@ -144,9 +142,7 @@ def node_status(self): Deploying: Deployment in progress Deployed: Node is provisioned and ready for use """ - maas_user = self.config.get('maas_user') - node_id = self.config.get('node_id') - cmd = ['maas', maas_user, 'machine', 'read', node_id] + cmd = ['maas', self.maas_user, 'machine', 'read', self.node_id] # Do not use runcmd for this - we need the output, not the end user output = subprocess.check_output(cmd) data = json.loads(output.decode()) @@ -154,9 +150,7 @@ def node_status(self): def node_release(self): """Release the node to make it available again""" - maas_user = self.config.get('maas_user') - node_id = self.config.get('node_id') - cmd = ['maas', maas_user, 'machine', 'release', node_id] + cmd = ['maas', self.maas_user, 'machine', 'release', self.node_id] subprocess.run(cmd) # Make sure the device is available before returning for timeout in range(0, 10): @@ -164,7 +158,6 @@ def node_release(self): status = self.node_status() if status == 'Ready': return - agent_name = self.config.get('agent_name') - self._logger_error('Device {} still in "{}" state, ' - 'could not recover!'.format(agent_name, status)) + self._logger_error('Device {} still in "{}" state, could not ' + 'recover!'.format(self.agent_name, status)) raise RecoveryError("Device recovery failed!") From 957e5c38be11d84c2ce068cc07c5c7dc4252c756 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 Nov 2019 13:13:50 -0600 Subject: [PATCH 260/569] Refactor maas provisioning a bit --- devices/maas2/maas2.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index b20c8b36..6fce04d8 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -64,6 +64,12 @@ def provision(self): provision_data = self.job_data.get('provision_data') # Default to a safe LTS if no distro is specified distro = provision_data.get('distro', 'xenial') + kernel = provision_data.get('kernel') + user_data = provision_data.get('user_data') + self.deploy_node(distro, kernel, user_data) + + def deploy_node(self, distro='bionic', kernel=None, user_data=None): + # Deploy the node in maas, default to bionic if nothing is specified self._logger_info('Acquiring node') cmd = ['maas', self.maas_user, 'machines', 'allocate', 'system_id={}'.format(self.node_id)] @@ -73,10 +79,8 @@ def provision(self): 'with distro {}'.format(self.agent_name, distro)) cmd = ['maas', self.maas_user, 'machine', 'deploy', self.node_id, 'distro_series={}'.format(distro)] - kernel = provision_data.get('kernel') if kernel: cmd.append('hwe_kernel={}'.format(kernel)) - user_data = provision_data.get('user_data') if user_data: data = base64.b64encode(user_data.encode()).decode() cmd.append('user_data={}'.format(data)) From f85c6edf67843139173cb548d76002d5f7da699c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 Nov 2019 14:57:15 -0600 Subject: [PATCH 261/569] Add 'clear_tpm' option if devices should require clearing tpm before provisioning --- devices/maas2/maas2.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 6fce04d8..2e39609b 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -61,6 +61,9 @@ def recover(self): self.node_release() def provision(self): + # Check if this is a device where we need to clear the tpm (dawson) + if self.config.get('clear_tpm'): + self.clear_tpm() provision_data = self.job_data.get('provision_data') # Default to a safe LTS if no distro is specified distro = provision_data.get('distro', 'xenial') @@ -68,6 +71,37 @@ def provision(self): user_data = provision_data.get('user_data') self.deploy_node(distro, kernel, user_data) + def clear_tpm(self): + self._logger_info("Clearing the TPM before provisioning") + # First see if we can run the command on the current install + if self._run_tpm_clear_cmd(): + return + # If not, then deploy bionic and try again + self.deploy_node() + if not self._run_tpm_clear_cmd(): + raise ProvisioningError("Failed to clear TPM") + + def _run_tpm_clear_cmd(self): + # Run the command to clear the tpm over ssh + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'echo 5 | sudo tee /sys/class/tpm/tpm0/ppi/request'] + try: + subprocess.check_call(cmd, timeout=30) + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'cat /sys/class/tpm/tpm0/ppi/request'] + output = subprocess.check_output(cmd, timeout=30) + # If we now see "5" in that file, then clearing tpm succeeded + if output.decode('utf-8').strip() == "5": + return True + except Exception: + # Fall through if we fail for any reason + pass + return False + def deploy_node(self, distro='bionic', kernel=None, user_data=None): # Deploy the node in maas, default to bionic if nothing is specified self._logger_info('Acquiring node') From b67c3aa09305acc466f015417cf8906c42e5684e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 7 Nov 2019 12:50:00 -0600 Subject: [PATCH 262/569] Move maas device release to deploy_node() so that we may be able to handle clear_tpm on the previous installed system, and to ensure that it gets released before deploying each time it is deployed --- devices/maas2/__init__.py | 2 -- devices/maas2/maas2.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index db55fc75..01959386 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -44,8 +44,6 @@ def invoked(self, ctx): snappy_device_agents.configure_logging(config) device = Maas2(ctx.args.config, ctx.args.job_data) logmsg(logging.INFO, "BEGIN provision") - logmsg(logging.INFO, "Recovering device") - device.recover() logmsg(logging.INFO, "Provisioning device") serial_host = config.get('serial_host') serial_port = config.get('serial_port') diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 2e39609b..4169a66a 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -104,6 +104,7 @@ def _run_tpm_clear_cmd(self): def deploy_node(self, distro='bionic', kernel=None, user_data=None): # Deploy the node in maas, default to bionic if nothing is specified + self.recover() self._logger_info('Acquiring node') cmd = ['maas', self.maas_user, 'machines', 'allocate', 'system_id={}'.format(self.node_id)] From 0cea828ae7d3299c5ef408be482bbcf2a389139e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 11 Nov 2019 14:10:18 -0600 Subject: [PATCH 263/569] Add some basic help strings for subcommands --- testflinger_cli/__init__.py | 44 ++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 49c02182..e93a1ce7 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -46,6 +46,7 @@ def cli(ctx, server): @click.argument('job_id', nargs=1) @click.pass_context def status(ctx, job_id): + """Show the status of a specified JOB_ID""" conn = ctx.obj['conn'] try: job_state = conn.get_status(job_id) @@ -69,6 +70,7 @@ def status(ctx, job_id): @click.argument('job_id', nargs=1) @click.pass_context def cancel(ctx, job_id): + """Tell the server to cancel a specified JOB_ID""" conn = ctx.obj['conn'] try: job_state = conn.get_status(job_id) @@ -97,6 +99,7 @@ def cancel(ctx, job_id): @click.option('--quiet', '-q', is_flag=True) @click.pass_context def submit(ctx, filename, quiet, poll_opt): + """Submit a new test job to the server""" conn = ctx.obj['conn'] if filename == '-': data = sys.stdin.read() @@ -134,6 +137,7 @@ def submit(ctx, filename, quiet, poll_opt): @click.argument('job_id', nargs=1) @click.pass_context def show(ctx, job_id): + """Show the requested job JSON for a specified JOB_ID""" conn = ctx.obj['conn'] try: results = conn.show_job(job_id) @@ -156,6 +160,7 @@ def show(ctx, job_id): @click.argument('job_id', nargs=1) @click.pass_context def results(ctx, job_id): + """Get results JSON for a completed JOB_ID""" conn = ctx.obj['conn'] try: results = conn.get_results(job_id) @@ -183,6 +188,7 @@ def results(ctx, job_id): @click.option('--filename', default='artifacts.tgz') @click.pass_context def artifacts(ctx, job_id, filename): + """Download a tarball of artifacts saved for a specified job""" conn = ctx.obj['conn'] print('Downloading artifacts tarball...') try: @@ -211,6 +217,7 @@ def artifacts(ctx, job_id, filename): help='Get latest output and exit immediately') @click.pass_context def poll(ctx, job_id, oneshot): + """Poll for output from a job until it is complete""" conn = ctx.obj['conn'] if oneshot: try: @@ -246,6 +253,25 @@ def poll(ctx, job_id, oneshot): print(job_state) +@cli.command() +@click.pass_context +def queues(ctx): + """List the advertised queues on the current Testflinger server""" + conn = ctx.obj['conn'] + try: + queues = conn.get_queues() + except client.HTTPError as e: + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you sure ' + 'this is a testflinger server?') + except Exception: + raise SystemExit( + 'Error communicating with server, check connection and retry') + print('Advertised queues on this server:') + for name, description in queues.items(): + print(' {} - {}'.format(name, description)) + + def get_latest_output(conn, job_id): output = '' try: @@ -275,21 +301,3 @@ def get_job_state(conn, job_id): # but we can keep going and retrying pass return 'unknown' - - -@cli.command() -@click.pass_context -def queues(ctx): - conn = ctx.obj['conn'] - try: - queues = conn.get_queues() - except client.HTTPError as e: - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') - print('Advertised queues on this server:') - for name, description in queues.items(): - print(' {} - {}'.format(name, description)) From ed7f4059ffbddd216345a8969ac0ee3133bb4e14 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 5 Dec 2019 16:32:57 -0600 Subject: [PATCH 264/569] checking that a path has the file type you expect > checking that it exists --- testflinger_agent/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index e6c7c6f4..3e6222e6 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -153,7 +153,7 @@ def transmit_job_outcome(self, rundir): # If we find an 'artifacts' dir under rundir, archive it, and transmit # it to the Testflinger server artifacts_dir = os.path.join(rundir, 'artifacts') - if os.path.exists(artifacts_dir): + if os.path.isdir(artifacts_dir): with tempfile.TemporaryDirectory() as tmpdir: artifact_file = os.path.join(tmpdir, 'artifacts') shutil.make_archive(artifact_file, format='gztar', @@ -175,7 +175,7 @@ def transmit_job_outcome(self, rundir): shutil.rmtree(artifacts_dir) # Do not retransmit outcome if it's already been done and removed outcome_file = os.path.join(rundir, 'testflinger-outcome.json') - if os.path.exists(outcome_file): + if os.path.isfile(outcome_file): logger.info('Submitting job outcome for job: %s' % job_id) with open(outcome_file) as f: data = json.load(f) From 35f52247769ce0c9b7e11d686cd3cb39c27b4629 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 9 Dec 2019 23:26:21 -0600 Subject: [PATCH 265/569] reset efi boot order so that it tries booting from NIC first --- devices/maas2/maas2.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 4169a66a..f84fda47 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -21,6 +21,7 @@ import time import yaml +from collections import OrderedDict from devices import (ProvisioningError, RecoveryError) @@ -61,6 +62,8 @@ def recover(self): self.node_release() def provision(self): + if self.config.get('reset_efi'): + self.reset_efi() # Check if this is a device where we need to clear the tpm (dawson) if self.config.get('clear_tpm'): self.clear_tpm() @@ -71,6 +74,50 @@ def provision(self): user_data = provision_data.get('user_data') self.deploy_node(distro, kernel, user_data) + def _get_efi_data(self): + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'sudo efibootmgr'] + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if p.returncode: + return None + # Use OrderedDict because often the NIC entries in EFI are in a good + # order with ipv4 ones coming first + efi_data = OrderedDict() + for line in p.stdout.decode().splitlines(): + k,v = line.split(" ", maxsplit=1) + efi_data[k] = v + return efi_data + + def _set_efi_data(self, boot_order): + # Set the boot order to the comma separated string of entries + self._logger_info('Setting boot order to {}'.format(boot_order)) + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'sudo efibootmgr -o {}'.format(boot_order)] + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if p.returncode: + self._logger_error('Failed to set efi boot order to "{}":\n' + '{}'.format(boot_order, p.stdout.decode())) + + def reset_efi(self): + # Try to reset the boot order so that NICs boot first + self._logger_info('Fixing EFI boot order before provisioning') + efi_data = self._get_efi_data() + if not efi_data: + return + bootlist = efi_data.get('BootOrder:').split(',') + new_boot_order = [] + for k,v in efi_data.items(): + if "NIC" in v and "Boot" in k: + new_boot_order.append(k[4:8]) + for entry in bootlist: + if entry not in new_boot_order: + new_boot_order.append(entry) + self._set_efi_data(','.join(new_boot_order)) + def clear_tpm(self): self._logger_info("Clearing the TPM before provisioning") # First see if we can run the command on the current install From 3f4d6fb810fe9f512ef0847eda304f4353e3b805 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 10 Dec 2019 18:23:10 -0600 Subject: [PATCH 266/569] flake8 fixups --- devices/maas2/maas2.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index f84fda47..cb391e7f 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -79,14 +79,15 @@ def _get_efi_data(self): '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), 'sudo efibootmgr'] - p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + p = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if p.returncode: return None # Use OrderedDict because often the NIC entries in EFI are in a good # order with ipv4 ones coming first efi_data = OrderedDict() for line in p.stdout.decode().splitlines(): - k,v = line.split(" ", maxsplit=1) + k, v = line.split(" ", maxsplit=1) efi_data[k] = v return efi_data @@ -97,7 +98,8 @@ def _set_efi_data(self, boot_order): '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), 'sudo efibootmgr -o {}'.format(boot_order)] - p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + p = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if p.returncode: self._logger_error('Failed to set efi boot order to "{}":\n' '{}'.format(boot_order, p.stdout.decode())) @@ -110,7 +112,7 @@ def reset_efi(self): return bootlist = efi_data.get('BootOrder:').split(',') new_boot_order = [] - for k,v in efi_data.items(): + for k, v in efi_data.items(): if "NIC" in v and "Boot" in k: new_boot_order.append(k[4:8]) for entry in bootlist: From 8d8c2e12723aff3b62a055f9999f746b49d97e9b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 19 Dec 2019 12:05:17 -0600 Subject: [PATCH 267/569] Some EFI entries have PXE instead of NIC, so we need to move those up the list also --- devices/maas2/maas2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index cb391e7f..7a22cddd 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -113,7 +113,7 @@ def reset_efi(self): bootlist = efi_data.get('BootOrder:').split(',') new_boot_order = [] for k, v in efi_data.items(): - if "NIC" in v and "Boot" in k: + if ("NIC" in v or "PXE" in v) and "Boot" in k: new_boot_order.append(k[4:8]) for entry in bootlist: if entry not in new_boot_order: From 84bd4f5c82ac2d131e4b7e2adac07a051cafc9ef Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 26 Nov 2019 13:34:04 -0600 Subject: [PATCH 268/569] Use argparse --- devices/__init__.py | 37 ++++------------- devices/cm3/__init__.py | 32 +++------------ devices/dragonboard/__init__.py | 32 +++------------ devices/maas2/__init__.py | 32 +++------------ devices/muxpi/__init__.py | 32 +++------------ devices/netboot/__init__.py | 38 ++++------------- devices/noprovision/__init__.py | 37 ++++------------- devices/oemrecovery/__init__.py | 32 +++------------ devices/rpi3/__init__.py | 32 +++------------ requirements.txt | 1 - setup.py | 2 +- snappy_device_agents/cmd.py | 73 ++++++++++----------------------- 12 files changed, 84 insertions(+), 296 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 4859b2b4..cab12741 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -12,7 +12,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -import guacamole import imp import logging import multiprocessing @@ -101,19 +100,16 @@ def stop(self): self.proc.terminate() -class DefaultRuntest(guacamole.Command): - - """Tool for running tests on a provisioned device.""" - - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: +class DefaultDevice: + def runtest(self, args): + """Default method for processing test commands""" + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) snappy_device_agents.logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) + args.job_data) test_cmds = test_opportunity.get('test_data').get('test_cmds') serial_host = config.get('serial_host') serial_port = config.get('serial_port') @@ -129,24 +125,15 @@ def invoked(self, ctx): snappy_device_agents.logmsg(logging.INFO, "END testrun") return exitcode - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - -class DefaultReserve(guacamole.Command): - - """Block this system while it is reserved for manual use""" - - def invoked(self, ctx): - with open(ctx.args.config) as configfile: + def reserve(self, args): + """Default method for reserving systems""" + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) snappy_device_agents.logmsg(logging.INFO, "BEGIN reservation") job_data = snappy_device_agents.get_test_opportunity( - ctx.args.job_data) + args.job_data) try: test_username = job_data['test_data']['test_username'] except KeyError: @@ -214,12 +201,6 @@ def invoked(self, ctx): 'cancel {}'.format(job_id)) time.sleep(int(timeout)) - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - def Catch(exception, returnval=0): """ Decorator for catching Exceptions and returning values instead diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py index 91d55c6e..04fd690c 100644 --- a/devices/cm3/__init__.py +++ b/devices/cm3/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,31 +17,28 @@ import logging import yaml -import guacamole - import snappy_device_agents from devices.cm3.cm3 import CM3 from snappy_device_agents import logmsg from devices import (Catch, + DefaultDevice, RecoveryError, - DefaultReserve, - DefaultRuntest, SerialLogger) device_name = "cm3" -class provision(guacamole.Command): +class provision(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, ctx): + def invoked(self, args): """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = CM3(ctx.args.config, ctx.args.job_data) + device = CM3(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") serial_host = config.get('serial_host') @@ -56,20 +53,3 @@ def invoked(self, ctx): finally: serial_proc.stop() logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Ubuntu Raspberry PI cm3""" - - sub_commands = ( - ('provision', provision), - ('reserve', DefaultReserve), - ('runtest', DefaultRuntest), - ) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index f291357f..7bb1532d 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,31 +17,28 @@ import logging import yaml -import guacamole - import snappy_device_agents from devices.dragonboard.dragonboard import Dragonboard from snappy_device_agents import logmsg from devices import (Catch, + DefaultDevice, RecoveryError, - DefaultReserve, - DefaultRuntest, SerialLogger) device_name = "dragonboard" -class provision(guacamole.Command): +class provision(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, ctx): + def invoked(self, args): """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = Dragonboard(ctx.args.config, ctx.args.job_data) + device = Dragonboard(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") serial_host = config.get('serial_host') @@ -56,20 +53,3 @@ def invoked(self, ctx): raise e finally: serial_proc.stop() - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Dragonboard.""" - - sub_commands = ( - ('provision', provision), - ('reserve', DefaultReserve), - ('runtest', DefaultRuntest), - ) diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index 01959386..376b9680 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,32 +17,29 @@ import logging import yaml -import guacamole - import snappy_device_agents from devices.maas2.maas2 import Maas2 from snappy_device_agents import logmsg from devices import (Catch, + DefaultDevice, RecoveryError, ProvisioningError, - DefaultReserve, - DefaultRuntest, SerialLogger) device_name = "maas2" -class provision(guacamole.Command): +class provision(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, ctx): + def invoked(self, args): """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = Maas2(ctx.args.config, ctx.args.job_data) + device = Maas2(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") serial_host = config.get('serial_host') @@ -60,20 +57,3 @@ def invoked(self, ctx): finally: serial_proc.stop() logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Ubuntu MaaS 2.0 CLI.""" - - sub_commands = ( - ('provision', provision), - ('reserve', DefaultReserve), - ('runtest', DefaultRuntest), - ) diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py index f46ceadf..9b31cbae 100644 --- a/devices/muxpi/__init__.py +++ b/devices/muxpi/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,31 +17,28 @@ import logging import yaml -import guacamole - import snappy_device_agents from devices.muxpi.muxpi import MuxPi from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, - DefaultReserve, - DefaultRuntest, + DefaultDevice, SerialLogger) device_name = "muxpi" -class provision(guacamole.Command): +class provision(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, ctx): + def invoked(self, args): """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = MuxPi(ctx.args.config, ctx.args.job_data) + device = MuxPi(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") serial_host = config.get('serial_host') @@ -56,20 +53,3 @@ def invoked(self, ctx): finally: serial_proc.stop() logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Ubuntu Raspberry PI muxpi""" - - sub_commands = ( - ('provision', provision), - ('reserve', DefaultReserve), - ('runtest', DefaultRuntest), - ) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 9ecb43c5..da41bf8f 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,41 +18,38 @@ import multiprocessing import yaml -import guacamole - import snappy_device_agents from devices.netboot.netboot import Netboot from snappy_device_agents import logmsg from devices import (Catch, + DefaultDevice, ProvisioningError, RecoveryError, - DefaultReserve, - DefaultRuntest, SerialLogger) device_name = "netboot" -class provision(guacamole.Command): +class provision(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, ctx): + def invoked(self, args): """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = Netboot(ctx.args.config) - image = snappy_device_agents.get_image(ctx.args.job_data) + device = Netboot(args.config) + image = snappy_device_agents.get_image(args.job_data) if not image: raise ProvisioningError('Error downloading image') server_ip = snappy_device_agents.get_local_ip_addr() test_username = snappy_device_agents.get_test_username( - ctx.args.job_data) + args.job_data) test_password = snappy_device_agents.get_test_password( - ctx.args.job_data) + args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") """Initial recovery process @@ -88,20 +85,3 @@ def invoked(self, ctx): file_server.terminate() serial_proc.stop() logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Netboot.""" - - sub_commands = ( - ('provision', provision), - ('reserve', DefaultReserve), - ('runtest', DefaultRuntest), - ) diff --git a/devices/noprovision/__init__.py b/devices/noprovision/__init__.py index e625fe1f..d6d649a2 100644 --- a/devices/noprovision/__init__.py +++ b/devices/noprovision/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,7 +17,6 @@ import logging import yaml -import guacamole import snappy_device_agents from devices.noprovision.noprovision import Noprovision @@ -25,42 +24,20 @@ from devices import (Catch, RecoveryError, - DefaultReserve, - DefaultRuntest) + DefaultDevice) device_name = "noprovision" -class provision(guacamole.Command): - - """Tool for provisioning baremetal with a given image.""" - +class DeviceAgent(DefaultDevice): @Catch(RecoveryError, 46) - def invoked(self, ctx): - """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: + def provision(self, args): + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = Noprovision(ctx.args.config) + device = Noprovision(args.config) test_username = snappy_device_agents.get_test_username( - ctx.args.job_data) + args.job_data) logmsg(logging.INFO, "BEGIN provision") device.ensure_test_image(test_username) logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Noprovision.""" - - sub_commands = ( - ('provision', provision), - ('reserve', DefaultReserve), - ('runtest', DefaultRuntest), - ) diff --git a/devices/oemrecovery/__init__.py b/devices/oemrecovery/__init__.py index 2f88991f..e6a64cf4 100644 --- a/devices/oemrecovery/__init__.py +++ b/devices/oemrecovery/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2018 Canonical +# Copyright (C) 2018-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,48 +17,28 @@ import logging import yaml -import guacamole - import snappy_device_agents from devices.oemrecovery.oemrecovery import OemRecovery from snappy_device_agents import logmsg from devices import (Catch, RecoveryError, - DefaultReserve, - DefaultRuntest) + DefaultDevice) device_name = "oemrecovery" -class provision(guacamole.Command): +class provision(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, ctx): + def invoked(self, args): """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = OemRecovery(ctx.args.config, ctx.args.job_data) + device = OemRecovery(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") device.provision() logmsg(logging.INFO, "END provision") - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for OEM Recovery""" - - sub_commands = ( - ('provision', provision), - ('reserve', DefaultReserve), - ('runtest', DefaultRuntest), - ) diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py index 1df01276..5ad62f32 100644 --- a/devices/rpi3/__init__.py +++ b/devices/rpi3/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,31 +17,28 @@ import logging import yaml -import guacamole - import snappy_device_agents from devices.rpi3.rpi3 import Rpi3 from snappy_device_agents import logmsg from devices import (Catch, + DefaultDevice, RecoveryError, - DefaultReserve, - DefaultRuntest, SerialLogger) device_name = "rpi3" -class provision(guacamole.Command): +class provision(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, ctx): + def invoked(self, args): """Method called when the command is invoked.""" - with open(ctx.args.config) as configfile: + with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = Rpi3(ctx.args.config, ctx.args.job_data) + device = Rpi3(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") serial_host = config.get('serial_host') @@ -56,20 +53,3 @@ def invoked(self, ctx): raise e finally: serial_proc.stop() - - def register_arguments(self, parser): - """Method called to customize the argument parser.""" - parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - parser.add_argument('job_data', help='Testflinger json data file') - - -class DeviceAgent(guacamole.Command): - - """Device agent for Rpi3.""" - - sub_commands = ( - ('provision', provision), - ('reserve', DefaultReserve), - ('runtest', DefaultRuntest), - ) diff --git a/requirements.txt b/requirements.txt index 0b31b01c..1538acac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ PyYAML==3.11 -guacamole==0.9 netifaces==0.10.4 python-logstash==0.4.5 # For the spi-agent diff --git a/setup.py b/setup.py index 2466e81e..864f92ae 100755 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ packages=find_packages(), data_files=datafiles, setup_requires=['pytest-runner'], - install_requires=['guacamole >= 0.9', 'PyYAML>=3.11', + install_requires=['PyYAML>=3.11', 'netifaces>=0.10.4'], tests_require=TEST_REQUIRES, scripts=['snappy-device-agent'], diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py index 3dbed8df..d1b02b00 100755 --- a/snappy_device_agents/cmd.py +++ b/snappy_device_agents/cmd.py @@ -14,62 +14,33 @@ # along with this program. If not, see . +import argparse import logging -from guacamole import Command -from guacamole.core import Ingredient -from guacamole.recipes.cmd import CommandRecipe -from guacamole.ingredients import ansi -from guacamole.ingredients import argparse -from guacamole.ingredients import cmdtree - from devices import load_devices logger = logging.getLogger() -class CrashLoggingIngredient(Ingredient): - """Use python logging if we Crash - """ - - def dispatch_failed(self, context): - logger.exception("exception") - raise - - -class AgentCommandRecipe(CommandRecipe): - """This is so we can add a custom ingredient - """ - - def get_ingredients(self): - return [ - cmdtree.CommandTreeBuilder(self.command), - cmdtree.CommandTreeDispatcher(), - argparse.AutocompleteIngredient(), - argparse.ParserIngredient(), - ansi.ANSIIngredient(), - CrashLoggingIngredient(), - ] - - -class Agent(Command): - """Main agent command - - This loads subcommands from modules in the devices directory - """ - - sub_commands = load_devices() - - # XXX: Remove for now due to https://github.com/zyga/guacamole/issues/4 - """ - def invoked(self, ctx): - print(ctx.parser.format_help()) - exit(1) - """ - - def main(self, argv=None, exit=True): - return AgentCommandRecipe(self).main(argv, exit) - - def main(): - Agent().main() + devices = load_devices() + parser = argparse.ArgumentParser() + + # First add a subcommand for each supported device type + dev_parser = parser.add_subparsers() + for (dev_name, dev_class) in devices: + dev_subparser = dev_parser.add_parser(dev_name) + dev_module = dev_class() + # Next add the subcommands that can be used and the methods they run + cmd_subparser = dev_subparser.add_subparsers() + for (cmd, func) in (('provision', dev_module.provision), + ('runtest', dev_module.runtest), + ('reserve', dev_module.reserve)): + cmd_parser = cmd_subparser.add_parser(cmd) + cmd_parser.add_argument('-c', '--config', required=True, + help='Config file for this device') + cmd_parser.add_argument('job_data', + help='Testflinger json data file') + cmd_parser.set_defaults(func=func) + args = parser.parse_args() + args.func(args) From d7e262a3c70d93767d373c5499f666bd6b20fd9a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Jan 2020 08:51:38 -0600 Subject: [PATCH 269/569] flake8 fix --- devices/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/devices/__init__.py b/devices/__init__.py index cab12741..6ea2670f 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -125,7 +125,6 @@ def runtest(self, args): snappy_device_agents.logmsg(logging.INFO, "END testrun") return exitcode - def reserve(self, args): """Default method for reserving systems""" with open(args.config) as configfile: From 0b9aab73440282e143b3b6ccb82d55aec6a33a03 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sat, 11 Jan 2020 20:39:48 -0600 Subject: [PATCH 270/569] Add reserve subcommand --- testflinger_cli/__init__.py | 118 +++++++++++++++++++++++++++++++++--- testflinger_cli/client.py | 11 +++- 2 files changed, 120 insertions(+), 9 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index e93a1ce7..440640af 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2019 Canonical +# Copyright (C) 2017-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,6 +16,7 @@ import click +import inspect import json import os import sys @@ -111,6 +112,19 @@ def submit(ctx, filename, quiet, poll_opt): raise SystemExit('File not found: {}'.format(filename)) except Exception: raise SystemExit('Unable to read file: {}'.format(filename)) + job_id = submit_job_data(conn, data) + if quiet: + print(job_id) + else: + print('Job submitted successfully!') + print('job_id: {}'.format(job_id)) + if poll_opt: + ctx.invoke(poll, job_id=job_id) + + +def submit_job_data(conn, data): + """ Submit data that was generated or read from a file as a test job + """ try: job_id = conn.submit_job(data) except client.HTTPError as e: @@ -124,13 +138,7 @@ def submit(ctx, filename, quiet, poll_opt): # This shouldn't happen, so let's get more information raise SystemExit('Unexpected error status from testflinger ' 'server: {}'.format(e.status)) - if quiet: - print(job_id) - else: - print('Job submitted successfully!') - print('job_id: {}'.format(job_id)) - if poll_opt: - ctx.invoke(poll, job_id=job_id) + return job_id @cli.command() @@ -272,6 +280,100 @@ def queues(ctx): print(' {} - {}'.format(name, description)) +@cli.command() +@click.option('--queue', '-q', + help='Name of the queue to use') +@click.option('--image', '-i', + help='Name of the image to use for provisioning') +@click.option('--key', '-k', 'ssh_keys', + help='Ssh key to use for reservation (ex: lp:userid, gh:userid)') +@click.pass_context +def reserve(ctx, queue, image, ssh_keys): + """Install and reserve a system""" + conn = ctx.obj['conn'] + if not queue: + try: + queues = conn.get_queues() + except Exception: + print("WARNING: unable to get a list of queues from the server!") + queues = {} + queue = _get_queue(queues) + if not image: + try: + images = conn.get_images(queue) + except Exception: + print("WARNING: unable to get a list of images from the server!") + images = {} + image = _get_image(images) + if not ssh_keys: + ssh_keys = _get_ssh_keys() + template = inspect.cleandoc("""job_queue: {queue} + provision_data: + url: {image} + reserve_data: + ssh_keys:""") + for ssh_key in ssh_keys: + template += "\n - {}".format(ssh_key) + job_data = template.format(queue=queue, image=image) + print("\nThe following yaml will be submitted:") + print(job_data) + answer = input("Proceed? (Y/n) ") + if answer in ("Y", "y", ""): + job_id = submit_job_data(conn, job_data) + print('Job submitted successfully!') + print('job_id: {}'.format(job_id)) + ctx.invoke(poll, job_id=job_id) + + +def _get_queue(queues): + queue = "" + while not queue or queue == "?": + queue = input("\nWhich queue do you want to use? ('?' to list) ") + if not queue: + continue + if queue == "?": + print("\nAdvertised queues on this server:") + for name, description in queues.items(): + print(" {} - {}".format(name, description)) + queue = _get_queue(queues) + if queue not in queues.keys(): + print("WARNING: '{}' is not in the list of known " + "queues".format(queue)) + answer = input("Do you still want to use it? (y/N) ") + if answer.lower() != "y": + queue = "" + return queue + + +def _get_image(images): + image = "" + while not image or image == "?": + image = input("\nEnter the name of the image you want to use " + "('?' to list) ") + if image == "?": + for image_id in images.keys(): + print(" " + image_id) + continue + if image not in images.keys(): + print("ERROR: '{}' is not in the list of known images for that " + "queue, please select another.".format(image)) + image = "" + return images.get(image) + + +def _get_ssh_keys(): + ssh_keys = "" + while not ssh_keys.strip(): + ssh_keys = input("\nEnter the ssh key(s) you wish to use: " + "(ex: lp:userid, gh:userid) ") + key_list = [ssh_key.strip() for ssh_key in ssh_keys.split(",")] + for ssh_key in key_list: + if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): + ssh_keys = "" + print("Please enter keys in the form lp:userid or gh:userid") + return key_list + + def get_latest_output(conn, job_id): output = '' try: diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index 90e02734..72ef93d4 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2019 Canonical +# Copyright (C) 2017-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -180,3 +180,12 @@ def get_queues(self): return json.loads(data) except ValueError: return {} + + def get_images(self, queue): + """Get the advertised images from the testflinger server""" + endpoint = '/v1/agents/images/' + queue + data = self.get(endpoint) + try: + return json.loads(data) + except ValueError: + return {} From 270dd83e1e8c82d5daca039d377d8238707dce64 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sat, 11 Jan 2020 20:49:50 -0600 Subject: [PATCH 271/569] Add support for configuring advertised images in the agent --- README.rst | 4 ++++ testflinger-agent.conf.example | 6 ++++++ testflinger_agent/agent.py | 10 +++++++--- testflinger_agent/client.py | 14 +++++++++++++- testflinger_agent/schema.py | 3 ++- 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index e2cff6de..f806a8d1 100644 --- a/README.rst +++ b/README.rst @@ -77,6 +77,10 @@ The following configuration options are supported: - List of public queue names that should be reported to the server to report to users +- **advertised_images**: + + - List of images to associate with a queue name so that they can be referenced by name when using testflinger reserve + - **setup_command**: - Command to run for the setup phase diff --git a/testflinger-agent.conf.example b/testflinger-agent.conf.example index 87056fe6..5534121f 100644 --- a/testflinger-agent.conf.example +++ b/testflinger-agent.conf.example @@ -34,6 +34,12 @@ # advertised_queues: # myqueue: A brief description of myqueue +# List of advertised images and the provision_data for using them +# advertised_images: +# myqueue: +# - latest: "url: http://path/to/latest.img.xz" +# - stable: "url: http://path/to/stable.img.xz" + # Command to run for the setup phase # setup_command: echo setup phase && run-setup-tasks.sh diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 89ba5bed..1c0b8878 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -37,14 +37,18 @@ def __init__(self, client): def _status_worker(self): # Report advertised queues to testflinger server when we are listening advertised_queues = self.client.config.get('advertised_queues') - if not advertised_queues: + advertised_images = self.client.config.get('advertised_images') + if not advertised_queues and not advertised_images: # Nothing to do unless there are advertised_queues configured raise SystemExit while True: # Post every 2min unless the agent is offline if self._state.value.decode('utf-8') != 'offline': - self.client.post_queues(advertised_queues) + if advertised_queues: + self.client.post_queues(advertised_queues) + if advertised_images: + self.client.post_images(advertised_images) time.sleep(120) def set_state(self, state): diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 3e6222e6..277d151a 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -214,3 +214,15 @@ def post_queues(self, data): requests.post(queues_uri, json=data, timeout=30) except Exception as e: logger.exception(e) + + def post_images(self, data): + """Post the list of advertised images to testflinger server + + :param data: + dict of queues containing dicts of imgae names and provision data + """ + images_uri = urljoin(self.server, '/v1/agents/images') + try: + requests.post(images_uri, json=data, timeout=30) + except Exception as e: + logger.exception(e) diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index 88642482..a9d167da 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -35,6 +35,7 @@ voluptuous.Optional('global_timeout'): int, voluptuous.Optional('output_timeout'): int, voluptuous.Optional('advertised_queues'): dict, + voluptuous.Optional('advertised_images'): dict, } From 7d33a45b8ad9ecbe47fc660023d3a222c1820aa6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 14 Jan 2020 14:19:59 -0600 Subject: [PATCH 272/569] Fix a small issue with the provision_data and also sort the images and queues before showing them to the user --- testflinger_cli/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 440640af..27aef9d8 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -309,7 +309,7 @@ def reserve(ctx, queue, image, ssh_keys): ssh_keys = _get_ssh_keys() template = inspect.cleandoc("""job_queue: {queue} provision_data: - url: {image} + {image} reserve_data: ssh_keys:""") for ssh_key in ssh_keys: @@ -333,7 +333,7 @@ def _get_queue(queues): continue if queue == "?": print("\nAdvertised queues on this server:") - for name, description in queues.items(): + for name, description in sorted(queues.items()): print(" {} - {}".format(name, description)) queue = _get_queue(queues) if queue not in queues.keys(): @@ -351,7 +351,7 @@ def _get_image(images): image = input("\nEnter the name of the image you want to use " "('?' to list) ") if image == "?": - for image_id in images.keys(): + for image_id in sorted(images.keys()): print(" " + image_id) continue if image not in images.keys(): From fead5556b04326c31943a4db996d8dd86ec72252 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 14 Jan 2020 15:19:54 -0600 Subject: [PATCH 273/569] Somehow missed these changes - but they are needed for the new DeviceAgent classes which are rewritten to use argparse --- devices/cm3/__init__.py | 4 ++-- devices/dragonboard/__init__.py | 4 ++-- devices/maas2/__init__.py | 4 ++-- devices/muxpi/__init__.py | 4 ++-- devices/netboot/__init__.py | 4 ++-- devices/oemrecovery/__init__.py | 4 ++-- devices/rpi3/__init__.py | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py index 04fd690c..9e4291dc 100644 --- a/devices/cm3/__init__.py +++ b/devices/cm3/__init__.py @@ -28,12 +28,12 @@ device_name = "cm3" -class provision(DefaultDevice): +class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, args): + def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 7bb1532d..e32a97eb 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -28,12 +28,12 @@ device_name = "dragonboard" -class provision(DefaultDevice): +class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, args): + def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index 376b9680..98dc3b84 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -29,12 +29,12 @@ device_name = "maas2" -class provision(DefaultDevice): +class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, args): + def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py index 9b31cbae..9cf08492 100644 --- a/devices/muxpi/__init__.py +++ b/devices/muxpi/__init__.py @@ -28,12 +28,12 @@ device_name = "muxpi" -class provision(DefaultDevice): +class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, args): + def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index da41bf8f..a739ace0 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -31,12 +31,12 @@ device_name = "netboot" -class provision(DefaultDevice): +class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, args): + def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) diff --git a/devices/oemrecovery/__init__.py b/devices/oemrecovery/__init__.py index e6a64cf4..478d90be 100644 --- a/devices/oemrecovery/__init__.py +++ b/devices/oemrecovery/__init__.py @@ -27,12 +27,12 @@ device_name = "oemrecovery" -class provision(DefaultDevice): +class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, args): + def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py index 5ad62f32..a2564c16 100644 --- a/devices/rpi3/__init__.py +++ b/devices/rpi3/__init__.py @@ -28,12 +28,12 @@ device_name = "rpi3" -class provision(DefaultDevice): +class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @Catch(RecoveryError, 46) - def invoked(self, args): + def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) From 03f29d969b730db17b9d2259478fa0aa527ca5aa Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 15 Jan 2020 15:52:03 -0600 Subject: [PATCH 274/569] Small change to also sort the output of the queues subcommand --- testflinger_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 27aef9d8..ca791f27 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -276,7 +276,7 @@ def queues(ctx): raise SystemExit( 'Error communicating with server, check connection and retry') print('Advertised queues on this server:') - for name, description in queues.items(): + for name, description in sorted(queues.items()): print(' {} - {}'.format(name, description)) From a430f26e003ad57584cca1d1abfa0b0b22ae55d8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 21 Jan 2020 07:43:47 -0600 Subject: [PATCH 275/569] Allow multiple keys to be specified on the command line, and return an iterable --- testflinger_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index ca791f27..187c9851 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -285,7 +285,7 @@ def queues(ctx): help='Name of the queue to use') @click.option('--image', '-i', help='Name of the image to use for provisioning') -@click.option('--key', '-k', 'ssh_keys', +@click.option('--key', '-k', 'ssh_keys', multiple=True, help='Ssh key to use for reservation (ex: lp:userid, gh:userid)') @click.pass_context def reserve(ctx, queue, image, ssh_keys): From 5b450323979b41165f28d5500e5188b8a52f72e7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 21 Jan 2020 07:52:55 -0600 Subject: [PATCH 276/569] Rename the "queues" command to "list-queues" --- testflinger_cli/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index ca791f27..e3956a15 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -261,9 +261,9 @@ def poll(ctx, job_id, oneshot): print(job_state) -@cli.command() +@cli.command(name='list-queues') @click.pass_context -def queues(ctx): +def list_queues(ctx): """List the advertised queues on the current Testflinger server""" conn = ctx.obj['conn'] try: From e0f605e10d0e0095d58280e3a3a2d484ccafa559 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 21 Jan 2020 09:45:26 -0600 Subject: [PATCH 277/569] Add help to show usage of multiple -k options to reserve command --- testflinger_cli/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 187c9851..30ec803d 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -286,7 +286,8 @@ def queues(ctx): @click.option('--image', '-i', help='Name of the image to use for provisioning') @click.option('--key', '-k', 'ssh_keys', multiple=True, - help='Ssh key to use for reservation (ex: lp:userid, gh:userid)') + help='Ssh key(s) to use for reservation ' + '(ex: -k lp:userid -k gh:userid)') @click.pass_context def reserve(ctx, queue, image, ssh_keys): """Install and reserve a system""" From 7ed2beff3c5f6b60157d630f30588425f3a09fbb Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 21 Jan 2020 14:34:13 -0600 Subject: [PATCH 278/569] Better handling of bad data being input when running testflinger reserve with options --- testflinger_cli/__init__.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 43f3d066..563b2ddf 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -292,22 +292,37 @@ def list_queues(ctx): def reserve(ctx, queue, image, ssh_keys): """Install and reserve a system""" conn = ctx.obj['conn'] + try: + queues = conn.get_queues() + except Exception: + print("WARNING: unable to get a list of queues from the server!") + queues = {} if not queue: - try: - queues = conn.get_queues() - except Exception: - print("WARNING: unable to get a list of queues from the server!") - queues = {} queue = _get_queue(queues) + else: + if queue not in queues.keys(): + print("WARNING: '{}' is not in the list of known " + "queues".format(queue)) + try: + images = conn.get_images(queue) + except Exception: + print("WARNING: unable to get a list of images from the server!") + images = {} if not image: - try: - images = conn.get_images(queue) - except Exception: - print("WARNING: unable to get a list of images from the server!") - images = {} image = _get_image(images) + else: + if image not in images.keys(): + raise SystemExit("ERROR: '{}' is not in the list of known " + "images for that queue, please select " + "another.".format(image)) + image = images[image] if not ssh_keys: ssh_keys = _get_ssh_keys() + else: + for ssh_key in ssh_keys: + if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): + raise SystemExit("Please enter keys in the form lp:userid or " + "gh:userid") template = inspect.cleandoc("""job_queue: {queue} provision_data: {image} From 2fa3d76086536f21f5165ae2ec28b2d763af1722 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 14 Feb 2020 12:14:31 -0600 Subject: [PATCH 279/569] Add support for a post_provision_script section in the device config --- devices/muxpi/muxpi.py | 11 +++++++++++ devices/rpi3/rpi3.py | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 64ae07d4..95dcfa5c 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -90,6 +90,7 @@ def provision(self): self.create_user() logger.info("Booting Test Image") self.unmount_writable_partition() + self.run_post_provision_script() self._run_control('stm -dut') self.check_test_image_booted() except Exception: @@ -211,3 +212,13 @@ def check_test_image_booted(self): pass # If we get here, then we didn't boot in time raise ProvisioningError("Failed to boot test image!") + + def run_post_provision_script(self): + # Run post provision commands on control host if there are any, but + # don't fail the provisioning step if any of them don't work + for cmd in self.config.get('post_provision_script'): + logger.info("Running %s", cmd) + try: + self._run_control(cmd) + except Exception: + logger.warn("Error running %s", cmd) diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index 5001af4c..1e873d60 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -345,6 +345,16 @@ def wipe_test_device(self): # would just add to the noise pass + def run_post_provision_script(self): + # Run post provision commands on control host if there are any, but + # don't fail the provisioning step if any of them don't work + for cmd in self.config.get('post_provision_script'): + logger.info("Running %s", cmd) + try: + self._run_control(cmd) + except Exception: + logger.warn("Error running %s", cmd) + def provision(self): """Provision the device""" url = self.job_data['provision_data'].get('url') @@ -371,6 +381,7 @@ def provision(self): file_server.terminate() logger.info("Creating Test User") self.create_user() + self.run_post_provision_script() logger.info("Booting Test Image") self.ensure_test_image(test_username, test_password) except: From de1746ecace9d5dc2f39b4494490d501ea621b04 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 2 Mar 2020 14:37:22 -0600 Subject: [PATCH 280/569] if the phase method returns a value, exit with that value --- snappy_device_agents/cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py index d1b02b00..218641e2 100755 --- a/snappy_device_agents/cmd.py +++ b/snappy_device_agents/cmd.py @@ -43,4 +43,4 @@ def main(): help='Testflinger json data file') cmd_parser.set_defaults(func=func) args = parser.parse_args() - args.func(args) + raise SystemExit(args.func(args)) From a5168b6c9c018cc9594dc658adb82d02f45e64fa Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Mar 2020 23:18:21 -0500 Subject: [PATCH 281/569] Ignore post_provision_script if we don't have one --- devices/muxpi/muxpi.py | 2 +- devices/rpi3/rpi3.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 95dcfa5c..4c093106 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -216,7 +216,7 @@ def check_test_image_booted(self): def run_post_provision_script(self): # Run post provision commands on control host if there are any, but # don't fail the provisioning step if any of them don't work - for cmd in self.config.get('post_provision_script'): + for cmd in self.config.get('post_provision_script', []): logger.info("Running %s", cmd) try: self._run_control(cmd) diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index 1e873d60..bafbd5c8 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -348,7 +348,7 @@ def wipe_test_device(self): def run_post_provision_script(self): # Run post provision commands on control host if there are any, but # don't fail the provisioning step if any of them don't work - for cmd in self.config.get('post_provision_script'): + for cmd in self.config.get('post_provision_script', []): logger.info("Running %s", cmd) try: self._run_control(cmd) From 05d1d96310d3023f614f2b5dde2fa7f580a70d06 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 18 Mar 2020 20:45:28 -0500 Subject: [PATCH 282/569] only activate the status worker if there are queues or images to advertise --- testflinger_agent/agent.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 1c0b8878..3cd50c67 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -30,25 +30,22 @@ def __init__(self, client): self.client = client self._state = multiprocessing.Array('c', 16) self.set_state('waiting') - self.status_proc = multiprocessing.Process(target=self._status_worker) - self.status_proc.daemon = True - self.status_proc.start() + self.advertised_queues = self.client.config.get('advertised_queues') + self.advertised_images = self.client.config.get('advertised_images') + if self.advertised_queues or self.advertised_images: + self.status_proc = multiprocessing.Process(target=self._status_worker) + self.status_proc.daemon = True + self.status_proc.start() def _status_worker(self): # Report advertised queues to testflinger server when we are listening - advertised_queues = self.client.config.get('advertised_queues') - advertised_images = self.client.config.get('advertised_images') - if not advertised_queues and not advertised_images: - # Nothing to do unless there are advertised_queues configured - raise SystemExit - while True: # Post every 2min unless the agent is offline if self._state.value.decode('utf-8') != 'offline': - if advertised_queues: - self.client.post_queues(advertised_queues) - if advertised_images: - self.client.post_images(advertised_images) + if self.advertised_queues: + self.client.post_queues(self.advertised_queues) + if self.advertised_images: + self.client.post_images(self.advertised_images) time.sleep(120) def set_state(self, state): From 9bac40d413cb9e8b077b2a0cc7f312741845b564 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 4 May 2020 14:41:09 -0500 Subject: [PATCH 283/569] Give a more helpful error when specifying a server without http or https (fixes lp:1876801) --- testflinger_cli/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 563b2ddf..f8aae934 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -40,6 +40,8 @@ def cli(ctx, server): env_server = os.environ.get('TESTFLINGER_SERVER') if env_server: server = env_server + if not server.startswith(('http://','https://')): + raise SystemExit('Server must start with "http://" or "https://"') ctx.obj['conn'] = client.Client(server) From 8e5bbf6df592e3bfd5a20c8dfc9fcc1424f4e32f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 5 May 2020 11:58:36 -0500 Subject: [PATCH 284/569] Add support for core20 images in muxpi --- devices/muxpi/muxpi.py | 129 ++++++++++++++++++++++++++++------------- 1 file changed, 90 insertions(+), 39 deletions(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 4c093106..39c3aee2 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,6 +24,8 @@ import snappy_device_agents +from contextlib import contextmanager + from devices import (ProvisioningError, RecoveryError) @@ -34,6 +36,10 @@ class MuxPi: """Device Agent for MuxPi.""" + IMAGE_PATH_IDS = {'etc': 'ubuntu', + 'system-data': 'core', + 'snaps': 'core20'} + def __init__(self, config, job_data): with open(config) as configfile: self.config = yaml.safe_load(configfile) @@ -86,11 +92,12 @@ def provision(self): try: self.flash_test_image(server_ip, server_port) file_server.terminate() - logger.info("Creating Test User") - self.create_user() - logger.info("Booting Test Image") - self.unmount_writable_partition() - self.run_post_provision_script() + image_type, image_dev = self.get_image_type() + with self.remote_mount(image_dev): + logger.info("Creating Test User") + self.create_user(image_type) + logger.info("Booting Test Image") + self.run_post_provision_script() self._run_control('stm -dut') self.check_test_image_booted() except Exception: @@ -132,6 +139,41 @@ def flash_test_image(self, server_ip, server_port): raise ProvisioningError("Unable to run hdparm to rescan " "partitions") + @contextmanager + def remote_mount(self, remote_device, mount_point='/mnt'): + self._run_control( + 'sudo mount /dev/{} {}'.format(remote_device, mount_point)) + try: + yield mount_point + finally: + self._run_control('sudo umount {}'.format(mount_point)) + + def get_image_type(self): + """ + Figure out which kind of image is on the configured block device + + :returns: + tuple of image type and device as strings + """ + dev = self.config['test_device'] + lsblk_data = self._run_control('lsblk -J {}'.format(dev)) + lsblk_json = json.loads(lsblk_data.decode()) + dev_list = [x.get('name') + for x in lsblk_json['blockdevices'][0]['children'] + if x.get('name')] + for dev in dev_list: + try: + with self.remote_mount(dev): + dirs = self._run_control('ls /mnt') + for path, img_type in self.IMAGE_PATH_IDS.items(): + if path in dirs.decode().split(): + return img_type, dev + except Exception: + # If unmountable or any other error, go on to the next one + continue + # We have no idea what kind of image this is + return 'unknown', dev + def unmount_writable_partition(self): try: self._run_control( @@ -143,23 +185,8 @@ def unmount_writable_partition(self): # We might not be mounted, so expect this to fail sometimes pass - def mount_writable_partition(self): - # Mount the writable partition - try: - self._run_control('sudo mount {} /mnt'.format( - self.config['snappy_writable_partition'])) - except KeyError: - raise RecoveryError( - "Device config missing snappy_writable_partition") - except Exception: - err = ("Error mounting writable partition on test image {}. " - "Check device configuration".format( - self.config['snappy_writable_partition'])) - raise ProvisioningError(err) - - def create_user(self): + def create_user(self, image_type): """Create user account for default ubuntu user""" - self.mount_writable_partition() metadata = 'instance_id: cloud-image' userdata = ('#cloud-config\n' 'password: ubuntu\n' @@ -168,25 +195,49 @@ def create_user(self): ' - ubuntu:ubuntu\n' ' expire: False\n' 'ssh_pwauth: True') + # For core20: + uc20_ci_data = ('#cloud-config\n' + 'datasource_list: [ NoCloud, None ]\n' + 'datasource:\n' + ' NoCloud:\n' + ' user-data: |\n' + ' #cloud-config\n' + ' password: ubuntu\n' + ' chpasswd:\n' + ' list:\n' + ' - ubuntu:ubuntu\n' + ' expire: False\n' + ' ssh_pwauth: True\n' + ' meta-data: |\n' + ' instance_id: cloud-image') + + base = '/mnt' + if image_type == 'core': + base = '/mnt/system-data' try: - output = self._run_control('ls /mnt') - if 'system-data' in str(output): - base = '/mnt/system-data' + if image_type == 'core20': + ci_path = os.path.join(base, '/data/etc/cloud/cloud.cfg.d') + self._run_control('sudo mkdir -p {}'.format(ci_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(uc20_ci_data, ci_path, '99_nocloud.cfg')) else: - base = '/mnt' - cloud_path = os.path.join( - base, 'var/lib/cloud/seed/nocloud-net') - self._run_control('sudo mkdir -p {}'.format(cloud_path)) - write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" - self._run_control( - write_cmd.format(metadata, cloud_path, 'meta-data')) - self._run_control( - write_cmd.format(userdata, cloud_path, 'user-data')) - # This needs to be removed on eoan for rpi, else cloud-init - # won't find the user-data we give it - rm_cmd = "sudo rm -f {}".format( - os.path.join(base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) - self._run_control(rm_cmd) + # For core or ubuntu classic images + ci_path = os.path.join( + base, 'var/lib/cloud/seed/nocloud-net') + self._run_control('sudo mkdir -p {}'.format(ci_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(metadata, ci_path, 'meta-data')) + self._run_control( + write_cmd.format(userdata, ci_path, 'user-data')) + if image_type == 'ubuntu': + # This needs to be removed on classic for rpi, else + # cloud-init won't find the user-data we give it + rm_cmd = "sudo rm -f {}".format( + os.path.join( + base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) + self._run_control(rm_cmd) except Exception: raise ProvisioningError("Error creating user files") From 1457269ff770bda4e6611857c0ee7d7c17bf4791 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 May 2020 13:34:01 -0500 Subject: [PATCH 285/569] Add core20 support for rpi provisioning type --- devices/rpi3/rpi3.py | 155 ++++++++++++++++++++++++++++--------------- 1 file changed, 101 insertions(+), 54 deletions(-) diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index bafbd5c8..fc768ebf 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -22,6 +22,8 @@ import time import yaml +from contextlib import contextmanager + import snappy_device_agents from devices import (ProvisioningError, RecoveryError) @@ -33,6 +35,10 @@ class Rpi3: """Snappy Device Agent for Rpi3.""" + IMAGE_PATH_IDS = {'etc': 'ubuntu', + 'system-data': 'core', + 'snaps': 'core20'} + def __init__(self, config, job_data): with open(config) as configfile: self.config = yaml.safe_load(configfile) @@ -61,6 +67,41 @@ def _run_control(self, cmd, timeout=60): raise ProvisioningError(e.output) return output + @contextmanager + def remote_mount(self, remote_device, mount_point='/mnt'): + self._run_control( + 'sudo mount /dev/{} {}'.format(remote_device, mount_point)) + try: + yield mount_point + finally: + self._run_control('sudo umount {}'.format(mount_point)) + + def get_image_type(self): + """ + Figure out which kind of image is on the configured block device + + :returns: + tuple of image type and device as strings + """ + dev = self.config['test_device'] + lsblk_data = self._run_control('lsblk -J {}'.format(dev)) + lsblk_json = json.loads(lsblk_data.decode()) + dev_list = [x.get('name') + for x in lsblk_json['blockdevices'][0]['children'] + if x.get('name')] + for dev in dev_list: + try: + with self.remote_mount(dev): + dirs = self._run_control('ls /mnt') + for path, img_type in self.IMAGE_PATH_IDS.items(): + if path in dirs.decode().split(): + return img_type, dev + except Exception: + # If unmountable or any other error, go on to the next one + continue + # We have no idea what kind of image this is + return 'unknown', dev + def setboot(self, mode): """ Set the boot mode of the device. @@ -82,7 +123,7 @@ def setboot(self, mode): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) - except: + except Exception: raise ProvisioningError("timeout reaching control host!") def hardreset(self): @@ -100,7 +141,7 @@ def hardreset(self): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) - except: + except Exception: raise RecoveryError("timeout reaching control host!") def ensure_test_image(self, test_username, test_password): @@ -118,7 +159,7 @@ def ensure_test_image(self, test_username, test_password): self.setboot('test') try: self._run_control('sudo /sbin/reboot') - except: + except Exception: pass time.sleep(60) @@ -134,7 +175,7 @@ def ensure_test_image(self, test_username, test_password): '{}@{}'.format(test_username, self.config['device_ip'])] subprocess.check_call(cmd) test_image_booted = self.is_test_image_booted() - except: + except Exception: pass if test_image_booted: break @@ -161,7 +202,7 @@ def is_test_image_booted(self): try: subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) - except: + except Exception: return False # If we get here, then the above command proved we are in snappy return True @@ -180,7 +221,7 @@ def is_master_image_booted(self): logger.info("Checking if master image booted.") try: output = self._run_control('cat /etc/issue') - except: + except Exception: logger.info("Error checking device state. Forcing reboot...") return False if 'GNU' in str(output): @@ -252,7 +293,7 @@ def flash_test_image(self, server_ip, server_port): timeout=30) except KeyError: raise RecoveryError("Device config missing test_device") - except: + except Exception: # We might not be mounted, so expect this to fail sometimes pass cmd = 'nc.traditional {} {}| xzcat| sudo dd of={} bs=16M'.format( @@ -261,11 +302,11 @@ def flash_test_image(self, server_ip, server_port): try: # XXX: I hope 30 min is enough? but maybe not! self._run_control(cmd, timeout=1800) - except: + except Exception: raise ProvisioningError("timeout reached while flashing image!") try: self._run_control('sync') - except: + except Exception: # Nothing should go wrong here, but let's sleep if it does logger.warn("Something went wrong with the sync, sleeping...") time.sleep(30) @@ -273,27 +314,12 @@ def flash_test_image(self, server_ip, server_port): self._run_control( 'sudo hdparm -z {}'.format(self.config['test_device']), timeout=30) - except: + except Exception: raise ProvisioningError("Unable to run hdparm to rescan " "partitions") - def mount_writable_partition(self): - # Mount the writable partition - try: - self._run_control('sudo mount {} /mnt'.format( - self.config['snappy_writable_partition'])) - except KeyError: - raise RecoveryError( - "Device config missing snappy_writable_partition") - except: - err = ("Error mounting writable partition on test image {}. " - "Check device configuration".format( - self.config['snappy_writable_partition'])) - raise ProvisioningError(err) - - def create_user(self): + def create_user(self, image_type): """Create user account for default ubuntu user""" - self.mount_writable_partition() metadata = 'instance_id: cloud-image' userdata = ('#cloud-config\n' 'password: ubuntu\n' @@ -302,30 +328,49 @@ def create_user(self): ' - ubuntu:ubuntu\n' ' expire: False\n' 'ssh_pwauth: True') - with open('meta-data', 'w') as mdata: - mdata.write(metadata) - with open('user-data', 'w') as udata: - udata.write(userdata) + # For core20: + uc20_ci_data = ('#cloud-config\n' + 'datasource_list: [ NoCloud, None ]\n' + 'datasource:\n' + ' NoCloud:\n' + ' user-data: |\n' + ' #cloud-config\n' + ' password: ubuntu\n' + ' chpasswd:\n' + ' list:\n' + ' - ubuntu:ubuntu\n' + ' expire: False\n' + ' ssh_pwauth: True\n' + ' meta-data: |\n' + ' instance_id: cloud-image') + base = '/mnt' + if image_type == 'core': + base = '/mnt/system-data' try: - output = self._run_control('ls /mnt') - if 'system-data' in str(output): - base = '/mnt/system-data' + if image_type == 'core20': + ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') + self._run_control('sudo mkdir -p {}'.format(ci_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(uc20_ci_data, ci_path, '99_nocloud.cfg')) else: - base = '/mnt' - cloud_path = os.path.join( - base, 'var/lib/cloud/seed/nocloud-net') - self._run_control('sudo mkdir -p {}'.format(cloud_path)) - write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" - self._run_control( - write_cmd.format(metadata, cloud_path, 'meta-data')) - self._run_control( - write_cmd.format(userdata, cloud_path, 'user-data')) - # This needs to be removed on eoan for rpi, else cloud-init - # won't find the user-data we give it - rm_cmd = "sudo rm -f {}".format( - os.path.join(base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) - self._run_control(rm_cmd) - except: + # For core or ubuntu classic images + ci_path = os.path.join( + base, 'var/lib/cloud/seed/nocloud-net') + self._run_control('sudo mkdir -p {}'.format(ci_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(metadata, ci_path, 'meta-data')) + self._run_control( + write_cmd.format(userdata, ci_path, 'user-data')) + if image_type == 'ubuntu': + # This needs to be removed on classic for rpi, else + # cloud-init won't find the user-data we give it + rm_cmd = "sudo rm -f {}".format( + os.path.join( + base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) + self._run_control(rm_cmd) + except Exception: raise ProvisioningError("Error creating user files") def wipe_test_device(self): @@ -340,7 +385,7 @@ def wipe_test_device(self): logger.error("Failed to write image, cleaning up...") self._run_control( 'sudo wipefs -af {}'.format(test_device)) - except: + except Exception: # This is an attempt to salvage a bad run, further tracebacks # would just add to the noise pass @@ -379,12 +424,14 @@ def provision(self): try: self.flash_test_image(server_ip, server_port) file_server.terminate() - logger.info("Creating Test User") - self.create_user() - self.run_post_provision_script() + image_type, image_dev = self.get_image_type() + with self.remote_mount(image_dev): + logger.info("Creating Test User") + self.create_user(image_type) + self.run_post_provision_script() logger.info("Booting Test Image") self.ensure_test_image(test_username, test_password) - except: + except Exception: # wipe out whatever we installed if things go badly self.wipe_test_device() raise From cafdeab61f2b6afd11f166b61ce50c83ac6a9159 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 May 2020 17:32:01 -0500 Subject: [PATCH 286/569] Small fix for a path in muxpi core20 --- devices/muxpi/muxpi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 39c3aee2..39adcabe 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -216,7 +216,7 @@ def create_user(self, image_type): base = '/mnt/system-data' try: if image_type == 'core20': - ci_path = os.path.join(base, '/data/etc/cloud/cloud.cfg.d') + ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') self._run_control('sudo mkdir -p {}'.format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( From 32bb469a882dad6c5afd6e1c542f17b7a7f2abcd Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 7 May 2020 14:49:02 -0500 Subject: [PATCH 287/569] Add core20 support for cm3 --- devices/cm3/cm3.py | 108 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 3 deletions(-) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index f65de7a6..412ff5db 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2017-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,10 +16,13 @@ import json import logging +import os import subprocess import time import yaml +from contextlib import contextmanager + from devices import (ProvisioningError, RecoveryError) @@ -30,6 +33,10 @@ class CM3: """Device Agent for CM3.""" + IMAGE_PATH_IDS = {'etc': 'ubuntu', + 'system-data': 'core', + 'snaps': 'core20'} + def __init__(self, config, job_data): with open(config) as configfile: self.config = yaml.safe_load(configfile) @@ -74,6 +81,10 @@ def provision(self): out = self._run_control('sudo cm3-installer {}'.format(url), timeout=900) logger.info(out) + image_type, image_dev = self.get_image_type() + with self.remote_mount(image_dev): + logger.info("Creating Test User") + self.create_user(image_type) self._run_control('sudo sync') time.sleep(5) out = self._run_control('sudo udisksctl power-off -b /dev/sda ') @@ -89,6 +100,41 @@ def provision(self): 'failed!', agent_name) raise ProvisioningError("Provisioning failed!") + @contextmanager + def remote_mount(self, remote_device, mount_point='/mnt'): + self._run_control( + 'sudo mount /dev/{} {}'.format(remote_device, mount_point)) + try: + yield mount_point + finally: + self._run_control('sudo umount {}'.format(mount_point)) + + def get_image_type(self): + """ + Figure out which kind of image is on the configured block device + + :returns: + tuple of image type and device as strings + """ + dev = self.config['test_device'] + lsblk_data = self._run_control('lsblk -J {}'.format(dev)) + lsblk_json = json.loads(lsblk_data.decode()) + dev_list = [x.get('name') + for x in lsblk_json['blockdevices'][0]['children'] + if x.get('name')] + for dev in dev_list: + try: + with self.remote_mount(dev): + dirs = self._run_control('ls /mnt') + for path, img_type in self.IMAGE_PATH_IDS.items(): + if path in dirs.decode().split(): + return img_type, dev + except Exception: + # If unmountable or any other error, go on to the next one + continue + # We have no idea what kind of image this is + return 'unknown', dev + def check_test_image_booted(self): logger.info("Checking if test image booted.") started = time.time() @@ -107,11 +153,67 @@ def check_test_image_booted(self): subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) return True - except: + except Exception: pass # If we get here, then we didn't boot in time raise ProvisioningError("Failed to boot test image!") + def create_user(self, image_type): + """Create user account for default ubuntu user""" + metadata = 'instance_id: cloud-image' + userdata = ('#cloud-config\n' + 'password: ubuntu\n' + 'chpasswd:\n' + ' list:\n' + ' - ubuntu:ubuntu\n' + ' expire: False\n' + 'ssh_pwauth: True') + # For core20: + uc20_ci_data = ('#cloud-config\n' + 'datasource_list: [ NoCloud, None ]\n' + 'datasource:\n' + ' NoCloud:\n' + ' user-data: |\n' + ' #cloud-config\n' + ' password: ubuntu\n' + ' chpasswd:\n' + ' list:\n' + ' - ubuntu:ubuntu\n' + ' expire: False\n' + ' ssh_pwauth: True\n' + ' meta-data: |\n' + ' instance_id: cloud-image') + + base = '/mnt' + if image_type == 'core': + base = '/mnt/system-data' + try: + if image_type == 'core20': + ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') + self._run_control('sudo mkdir -p {}'.format(ci_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(uc20_ci_data, ci_path, '99_nocloud.cfg')) + else: + # For core or ubuntu classic images + ci_path = os.path.join( + base, 'var/lib/cloud/seed/nocloud-net') + self._run_control('sudo mkdir -p {}'.format(ci_path)) + write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" + self._run_control( + write_cmd.format(metadata, ci_path, 'meta-data')) + self._run_control( + write_cmd.format(userdata, ci_path, 'user-data')) + if image_type == 'ubuntu': + # This needs to be removed on classic for rpi, else + # cloud-init won't find the user-data we give it + rm_cmd = "sudo rm -f {}".format( + os.path.join( + base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) + self._run_control(rm_cmd) + except Exception: + raise ProvisioningError("Error creating user files") + def hardreset(self): """ Reboot the device. @@ -127,5 +229,5 @@ def hardreset(self): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) - except: + except Exception: raise RecoveryError("timeout reaching control host!") From e21364905696b67e97d4c9ac7259998457ce356e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 21 May 2020 12:45:59 -0500 Subject: [PATCH 288/569] Move post_provision_script processing outside the context manager for mounts, because it might need different partitions mounted --- devices/muxpi/muxpi.py | 4 ++-- devices/rpi3/rpi3.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 39adcabe..d0c66276 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -96,8 +96,8 @@ def provision(self): with self.remote_mount(image_dev): logger.info("Creating Test User") self.create_user(image_type) - logger.info("Booting Test Image") - self.run_post_provision_script() + self.run_post_provision_script() + logger.info("Booting Test Image") self._run_control('stm -dut') self.check_test_image_booted() except Exception: diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index fc768ebf..57e6a9e5 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -428,7 +428,7 @@ def provision(self): with self.remote_mount(image_dev): logger.info("Creating Test User") self.create_user(image_type) - self.run_post_provision_script() + self.run_post_provision_script() logger.info("Booting Test Image") self.ensure_test_image(test_username, test_password) except Exception: From 9abacd0e3d700fb1059bc619cde8cfb1c91149e6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 20 May 2020 15:58:34 -0500 Subject: [PATCH 289/569] Support sdwire device with the muxpi device agent --- devices/muxpi/muxpi.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index d0c66276..ba3cd8a3 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -78,7 +78,9 @@ def provision(self): raise ProvisioningError('You must specify a "url" value in ' 'the "provision_data" section of ' 'your job_data') - self._run_control('stm -ts') + cmd = self.config.get('control_switch_local_cmd', + 'stm -ts') + self._run_control(cmd) time.sleep(5) logger.info('Flashing Test image') image_file = snappy_device_agents.compress_file('snappy.img') @@ -98,7 +100,9 @@ def provision(self): self.create_user(image_type) self.run_post_provision_script() logger.info("Booting Test Image") - self._run_control('stm -dut') + cmd = self.config.get('control_switch_device_cmd', + 'stm -dut') + self._run_control(cmd) self.check_test_image_booted() except Exception: raise From 75664678c7c0c12ce9ff6ef4c32653da342f0b03 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 27 May 2020 22:35:09 -0500 Subject: [PATCH 290/569] Add hardreset to muxpi if it is configured, so that external power control is possible --- devices/muxpi/muxpi.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index ba3cd8a3..e95340f6 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -103,6 +103,7 @@ def provision(self): cmd = self.config.get('control_switch_device_cmd', 'stm -dut') self._run_control(cmd) + self.hardreset() self.check_test_image_booted() except Exception: raise @@ -152,6 +153,24 @@ def remote_mount(self, remote_device, mount_point='/mnt'): finally: self._run_control('sudo umount {}'.format(mount_point)) + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config['reboot_script']: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=60) + except Exception: + raise RecoveryError("timeout reaching control host!") + def get_image_type(self): """ Figure out which kind of image is on the configured block device From 341e16f1adf35dae0d2f4befd68d121909590538 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 28 May 2020 09:31:54 -0500 Subject: [PATCH 291/569] Give more time for checking that the image is booted --- devices/cm3/cm3.py | 2 +- devices/dragonboard/dragonboard.py | 2 +- devices/netboot/netboot.py | 2 +- devices/rpi3/rpi3.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index 412ff5db..186fd0b5 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -143,7 +143,7 @@ def check_test_image_booted(self): 'test_data', {}).get('test_username', 'ubuntu') test_password = self.job_data.get( 'test_data', {}).get('test_password', 'ubuntu') - while time.time() - started < 300: + while time.time() - started < 600: try: time.sleep(10) cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index dc3e84c9..f34dc097 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -125,7 +125,7 @@ def ensure_test_image(self, test_username, test_password): started = time.time() # Retry for a while since we might still be rebooting test_image_booted = False - while time.time() - started < 300: + while time.time() - started < 600: try: time.sleep(10) cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 7fad98f4..27c4d7e8 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -191,7 +191,7 @@ def ensure_master_image(self): self.hardreset() started = time.time() - while time.time() - started < 300: + while time.time() - started < 600: time.sleep(10) master_is_booted = self.is_master_image_booted() if master_is_booted: diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index 57e6a9e5..f7d46eda 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -166,7 +166,7 @@ def ensure_test_image(self, test_username, test_password): started = time.time() # Retry for a while since we might still be rebooting test_image_booted = False - while time.time() - started < 300: + while time.time() - started < 600: try: time.sleep(10) cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', From 9a5cc4708008d2f24890c3f9dbd1545639b3a1d6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 11 Jun 2020 08:11:49 -0500 Subject: [PATCH 292/569] muxpi - fix crash if reboot_script is empty --- devices/muxpi/muxpi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index e95340f6..279f4abe 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -164,7 +164,7 @@ def hardreset(self): This function runs the commands specified in 'reboot_script' in the config yaml. """ - for cmd in self.config['reboot_script']: + for cmd in self.config.get('reboot_script', []): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) From e880ae2b666e033c350c509af90ef67b00eb5b0d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 30 Jun 2020 21:46:30 -0500 Subject: [PATCH 293/569] Update tests so that they work again --- pytest.ini | 2 + setup.cfg | 2 + setup.py | 6 +- testflinger_agent/agent.py | 3 +- testflinger_agent/tests/test_agent.py | 245 +++++++++---------------- testflinger_agent/tests/test_client.py | 33 ++-- testflinger_agent/tests/test_job.py | 68 ++++--- 7 files changed, 139 insertions(+), 220 deletions(-) create mode 100644 pytest.ini create mode 100644 setup.cfg diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..fc824acd --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --doctest-modules --ignore=setup.py --ignore=.eggs --flake8 --cov=testflinger_agent diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..31ad82b6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[aliases] +test = pytest diff --git a/setup.py b/setup.py index 01cb3c7c..b1c81c88 100755 --- a/setup.py +++ b/setup.py @@ -26,6 +26,10 @@ TEST_REQUIRES = [ "mock", + "pytest", + "pytest-cov", + "pytest-flake8", + "requests-mock", ] setup( @@ -35,7 +39,7 @@ packages=['testflinger_agent'], zip_safe=False, install_requires=INSTALL_REQUIRES, - test_suite='testflinger_agent.tests', tests_require=TEST_REQUIRES, + setup_requires=['pytest-runner'], scripts=['testflinger-agent'], ) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 3cd50c67..f989a6f2 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -33,7 +33,8 @@ def __init__(self, client): self.advertised_queues = self.client.config.get('advertised_queues') self.advertised_images = self.client.config.get('advertised_images') if self.advertised_queues or self.advertised_images: - self.status_proc = multiprocessing.Process(target=self._status_worker) + self.status_proc = multiprocessing.Process( + target=self._status_worker) self.status_proc.daemon = True self.status_proc.start() diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index e62550ef..4fb36655 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -1,21 +1,22 @@ import json import os -import requests import shutil import tempfile import uuid +import requests_mock as rmock +import pytest -from mock import (patch, MagicMock) -from unittest import TestCase +from mock import patch import testflinger_agent from testflinger_agent.errors import TFServerError -from testflinger_agent.client import TestflingerClient -from testflinger_agent.agent import TestflingerAgent +from testflinger_agent.client import TestflingerClient as _TestflingerClient +from testflinger_agent.agent import TestflingerAgent as _TestflingerAgent -class ClientRunTests(TestCase): - def setUp(self): +class TestClient: + @pytest.fixture + def agent(self): self.tmpdir = tempfile.mkdtemp() self.config = {'agent_id': 'test01', 'polling_interval': '2', @@ -26,211 +27,131 @@ def setUp(self): 'results_basedir': os.path.join(self.tmpdir, 'results') } testflinger_agent.configure_logging(self.config) - - def get_agent(self): - client = TestflingerClient(self.config) - return TestflingerAgent(client) - - def tearDown(self): + client = _TestflingerClient(self.config) + yield _TestflingerAgent(client) + # Inside tests, we patch rmtree so that we can check files after the + # run, so we need to clean up the tmpdirs here shutil.rmtree(self.tmpdir) - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_check_and_run_setup(self, mock_requests_get, mock_requests_post, - mock_rmtree): + def test_check_and_run_setup(self, agent, requests_mock): self.config['setup_command'] = 'echo setup1' - agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test - mock_requests_post.return_value = MagicMock(status_code=200) - agent.process_jobs() + requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, + {'text': '{}'}]) + requests_mock.post(rmock.ANY, status_code=200) + with patch('shutil.rmtree'): + agent.process_jobs() setuplog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'setup.log')).read() - self.assertEqual('setup1', setuplog.splitlines()[-1].strip()) + assert('setup1' == setuplog.splitlines()[-1].strip()) - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_check_and_run_provision(self, mock_requests_get, - mock_requests_post, mock_rmtree): + def test_check_and_run_provision(self, agent, requests_mock): self.config['provision_command'] = 'echo provision1' - agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test', 'provision_data': ''} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test - mock_requests_post.return_value = MagicMock(status_code=200) - agent.process_jobs() + requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, + {'text': '{}'}]) + requests_mock.post(rmock.ANY, status_code=200) + with patch('shutil.rmtree'): + agent.process_jobs() provisionlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'provision.log')).read() - self.assertEqual('provision1', provisionlog.splitlines()[-1].strip()) + assert('provision1' == provisionlog.splitlines()[-1].strip()) - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_check_and_run_test(self, mock_requests_get, mock_requests_post, - mock_rmtree): + def test_check_and_run_test(self, agent, requests_mock): self.config['test_command'] = 'echo test1' - agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test - mock_requests_post.return_value = MagicMock(status_code=200) - agent.process_jobs() + 'job_queue': 'test', + 'test_data': ''} + requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, + {'text': '{}'}]) + requests_mock.post(rmock.ANY, status_code=200) + with patch('shutil.rmtree'): + agent.process_jobs() testlog = open(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'test.log')).read() - self.assertEqual('test1', testlog.splitlines()[-1].strip()) + assert('test1' == testlog.splitlines()[-1].strip()) - @patch('testflinger_agent.client.os.unlink') - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_phase_failed(self, mock_requests_get, mock_requests_post, - mock_rmtree, mock_unlink): - """Make sure we stop running after a failed phase""" + def test_phase_failed(self, agent, requests_mock): + # Make sure we stop running after a failed phase self.config['provision_command'] = '/bin/false' self.config['test_command'] = 'echo test1' - agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test', 'provision_data': '', 'test_data': ''} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] - # Make sure we return good status when posting the outcome - # shutil.rmtree is mocked so that we avoid removing the files - # before finishing the test - mock_requests_post.return_value = MagicMock(status_code=200) - agent.process_jobs() + requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, + {'text': '{}'}]) + requests_mock.post(rmock.ANY, status_code=200) + with patch('shutil.rmtree'), patch('os.unlink'): + agent.process_jobs() outcome_file = os.path.join(os.path.join(self.tmpdir, fake_job_data.get('job_id'), 'testflinger-outcome.json')) with open(outcome_file) as f: outcome_data = json.load(f) - self.assertEqual(1, outcome_data.get('provision_status')) - self.assertEqual(None, outcome_data.get('test_status')) + assert(outcome_data.get('provision_status') == 1) + assert(outcome_data.get('test_status') is None) - @patch('testflinger_agent.client.logger.exception') - @patch.object(testflinger_agent.client.TestflingerClient, - 'transmit_job_outcome') - @patch('requests.get') - @patch('requests.post') - def test_retry_transmit(self, mock_requests_post, mock_requests_get, - mock_transmit_job_outcome, - mock_logger_exception): - """Make sure we retry sending test results""" + def test_retry_transmit(self, agent, requests_mock): + # Make sure we retry sending test results self.config['provision_command'] = '/bin/false' self.config['test_command'] = 'echo test1' - agent = self.get_agent() fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - # Send an extra terminator since we will be calling get 3 times - mock_requests_get.side_effect = [fake_response, terminator, terminator] - # Make sure we fail the first time when transmitting the results - mock_transmit_job_outcome.side_effect = [TFServerError(404), - terminator, terminator] - mock_requests_post.return_value = MagicMock(status_code=200) - agent.process_jobs() - first_dir = os.path.join( - self.config.get('execution_basedir'), - fake_job_data.get('job_id')) - mock_transmit_job_outcome.assert_called_with(first_dir) - # Try processing the jobs again, now it should be in results_basedir - agent.process_jobs() - retry_dir = os.path.join( - self.config.get('results_basedir'), - fake_job_data.get('job_id')) - mock_transmit_job_outcome.assert_called_with(retry_dir) + # Send an extra empty data since we will be calling get 3 times + requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, + {'text': '{}'}, + {'text': '{}'}]) + requests_mock.post(rmock.ANY, status_code=200) + with patch.object( + testflinger_agent.client.TestflingerClient, + 'transmit_job_outcome') as mock_transmit_job_outcome: + # Make sure we fail the first time when transmitting the results + mock_transmit_job_outcome.side_effect = [TFServerError(404), ''] + agent.process_jobs() + first_dir = os.path.join( + self.config.get('execution_basedir'), + fake_job_data.get('job_id')) + mock_transmit_job_outcome.assert_called_with(first_dir) + # Try processing jobs again, now it should be in results_basedir + agent.process_jobs() + retry_dir = os.path.join( + self.config.get('results_basedir'), + fake_job_data.get('job_id')) + mock_transmit_job_outcome.assert_called_with(retry_dir) - @patch('testflinger_agent.client.logger.exception') - @patch('requests.post') - @patch('requests.get') - def test_post_artifact(self, mock_requests_get, - mock_requests_post, - mock_logger_exception): - """Test posting files from the artifact directory""" - # Create an artifact as part of the test process - self.config['test_command'] = ('mkdir artifacts && ' - 'echo test1 > artifacts/t') - agent = self.get_agent() - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test', - 'test_data': ''} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - # Send an extra terminator since we will be calling get 3 times - mock_requests_get.side_effect = [fake_response, terminator, terminator] - # Make sure we fail the first time when transmitting the results - mock_requests_post.return_value = MagicMock(status_code=200) - agent.process_jobs() - # The last request should have the 'files' value we are looking for - self.assertTrue('files' in str(mock_requests_post.mock_calls[-1])) - - @patch('shutil.rmtree') - @patch('requests.post') - @patch('requests.get') - def test_recovery_failed(self, mock_requests_get, mock_requests_post, - mock_rmtree): - """Make sure we stop processing jobs after a device recovery error""" + def test_recovery_failed(self, agent, requests_mock): + # Make sure we stop processing jobs after a device recovery error OFFLINE_FILE = '/tmp/TESTFLINGER-DEVICE-OFFLINE-test001' if os.path.exists(OFFLINE_FILE): os.unlink(OFFLINE_FILE) self.config['agent_id'] = 'test001' self.config['provision_command'] = 'exit 46' self.config['test_command'] = 'echo test1' - agent = self.get_agent() - fake_job_data = {'job_id': str(uuid.uuid1()), + job_id = str(uuid.uuid1()) + fake_job_data = {'job_id': job_id, 'job_queue': 'test', 'provision_data': '', 'test_data': ''} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - terminator = requests.Response() - terminator._content = {} - mock_requests_get.side_effect = [fake_response, terminator] # In this case we are making sure that the repost job request # gets good status - mock_requests_post.return_value = MagicMock(status_code=200) - agent.process_jobs() - self.assertEqual(True, agent.check_offline()) - # These are the args we would expect when it reposts the job - repost_args = ('http://127.0.0.1:8000/v1/job') - repost_kwargs = dict(json=fake_job_data) - mock_requests_post.assert_called_with(repost_args, **repost_kwargs) + with rmock.Mocker() as m: + m.get('http://127.0.0.1:8000/v1/job?queue=test', + json=fake_job_data) + m.get('http://127.0.0.1:8000/v1/result/'+job_id, text='{}') + m.post('http://127.0.0.1:8000/v1/result/'+job_id, text='{}') + m.post('http://127.0.0.1:8000/v1/result/'+job_id+'/output', + text='{}') + m.post('http://127.0.0.1:8000/v1/job', json={'job_id': job_id}) + agent.process_jobs() + assert(agent.check_offline() is True) + # These are the args we would expect when it reposts the job + assert(m.last_request.json() == fake_job_data) if os.path.exists(OFFLINE_FILE): os.unlink(OFFLINE_FILE) diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 2d0d8cc2..a3b6eb3d 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -12,32 +12,27 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import json -import requests - +import pytest import uuid -from mock import patch -from unittest import TestCase +import requests_mock as rmock + +from testflinger_agent.client import TestflingerClient as _TestflingerClient -from testflinger_agent.client import TestflingerClient +class TestClient(): + @pytest.fixture + def client(self): + yield _TestflingerClient({'server_address': '127.0.0.1:8000'}) -class ClientTest(TestCase): - @patch('requests.get') - def test_check_jobs_empty(self, mock_requests_get): - client = TestflingerClient({'server_address': ''}) - mock_requests_get.return_value = requests.Response() + def test_check_jobs_empty(self, client, requests_mock): + requests_mock.get(rmock.ANY, status_code=200) job_data = client.check_jobs() - self.assertEqual(job_data, None) + assert(job_data is None) - @patch('requests.get') - def test_check_jobs_with_job(self, mock_requests_get): - client = TestflingerClient({'server_address': ''}) + def test_check_jobs_with_job(self, client, requests_mock): fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test_queue'} - fake_response = requests.Response() - fake_response._content = json.dumps(fake_job_data).encode() - mock_requests_get.return_value = fake_response + requests_mock.get(rmock.ANY, json=fake_job_data) job_data = client.check_jobs() - self.assertEqual(job_data, fake_job_data) + assert(job_data == fake_job_data) diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 5e94c56f..7ab25bb5 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -1,17 +1,18 @@ import os +import pytest import shutil import tempfile -from mock import MagicMock -from unittest import TestCase +import requests_mock as rmock import testflinger_agent -from testflinger_agent.client import TestflingerClient -from testflinger_agent.job import TestflingerJob +from testflinger_agent.client import TestflingerClient as _TestflingerClient +from testflinger_agent.job import TestflingerJob as _TestflingerJob -class JobTests(TestCase): - def setUp(self): +class TestJob(): + @pytest.fixture + def client(self): self.tmpdir = tempfile.mkdtemp() self.config = {'agent_id': 'test01', 'polling_interval': '2', @@ -22,97 +23,90 @@ def setUp(self): 'results_basedir': os.path.join(self.tmpdir, 'results') } testflinger_agent.configure_logging(self.config) - - def tearDown(self): + yield _TestflingerClient(self.config) shutil.rmtree(self.tmpdir) - def test_skip_missing_provision_data(self): + def test_skip_missing_provision_data(self, client): """Test that provision phase is skipped when provision_data is absent """ self.config['provision_command'] = '/bin/true' - client = TestflingerClient(self.config) fake_job_data = {'global_timeout': 1} - job = TestflingerJob(fake_job_data, client) + job = _TestflingerJob(fake_job_data, client) job.run_test_phase('provision', None) logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') with open(logfile) as log: log_output = log.read() - self.assertIn("No provision_data defined in job data", log_output) + assert("No provision_data defined in job data" in log_output) - def test_job_global_timeout(self): + def test_job_global_timeout(self, client, requests_mock): """Test that timeout from job_data is respected""" timeout_str = '\nERROR: Global timeout reached! (1s)\n' logfile = os.path.join(self.tmpdir, 'testlog') - client = TestflingerClient(self.config) fake_job_data = {'global_timeout': 1} - client.post_live_output = MagicMock() - job = TestflingerJob(fake_job_data, client) + requests_mock.post(rmock.ANY, status_code=200) + job = _TestflingerJob(fake_job_data, client) job.phase = 'test' job.run_with_log('sleep 3', logfile) with open(logfile) as log: log_data = log.read() - self.assertEqual(timeout_str, log_data) + assert(timeout_str == log_data) - def test_config_global_timeout(self): + def test_config_global_timeout(self, client, requests_mock): """Test that timeout from device config is preferred""" timeout_str = '\nERROR: Global timeout reached! (1s)\n' logfile = os.path.join(self.tmpdir, 'testlog') self.config['global_timeout'] = 1 - client = TestflingerClient(self.config) fake_job_data = {'global_timeout': 3} - client.post_live_output = MagicMock() - job = TestflingerJob(fake_job_data, client) + requests_mock.post(rmock.ANY, status_code=200) + job = _TestflingerJob(fake_job_data, client) job.phase = 'test' job.run_with_log('sleep 3', logfile) with open(logfile) as log: log_data = log.read() - self.assertEqual(timeout_str, log_data) + assert(timeout_str == log_data) - def test_job_output_timeout(self): + def test_job_output_timeout(self, client, requests_mock): """Test that output timeout from job_data is respected""" timeout_str = '\nERROR: Output timeout reached! (1s)\n' logfile = os.path.join(self.tmpdir, 'testlog') - client = TestflingerClient(self.config) fake_job_data = {'output_timeout': 1} - client.post_live_output = MagicMock() - job = TestflingerJob(fake_job_data, client) + requests_mock.post(rmock.ANY, status_code=200) + job = _TestflingerJob(fake_job_data, client) job.phase = 'test' # unfortunately, we need to sleep for longer that 10 seconds here # or else we fall under the polling time job.run_with_log('sleep 12', logfile) with open(logfile) as log: log_data = log.read() - self.assertEqual(timeout_str, log_data) + assert(timeout_str == log_data) - def test_config_output_timeout(self): + def test_config_output_timeout(self, client, requests_mock): """Test that output timeout from device config is preferred""" timeout_str = '\nERROR: Output timeout reached! (1s)\n' logfile = os.path.join(self.tmpdir, 'testlog') self.config['output_timeout'] = 1 - client = TestflingerClient(self.config) fake_job_data = {'output_timeout': 30} - client.post_live_output = MagicMock() - job = TestflingerJob(fake_job_data, client) + requests_mock.post(rmock.ANY, status_code=200) + job = _TestflingerJob(fake_job_data, client) job.phase = 'test' # unfortunately, we need to sleep for longer that 10 seconds here # or else we fall under the polling time job.run_with_log('sleep 12', logfile) with open(logfile) as log: log_data = log.read() - self.assertEqual(timeout_str, log_data) + assert(timeout_str == log_data) - def test_no_output_timeout_in_provision(self): + def test_no_output_timeout_in_provision(self, client, requests_mock): """Test that output timeout is ignored when not in test phase""" timeout_str = 'complete\n' logfile = os.path.join(self.tmpdir, 'testlog') - client = TestflingerClient(self.config) fake_job_data = {'output_timeout': 1} - client.post_live_output = MagicMock() - job = TestflingerJob(fake_job_data, client) + requests_mock.post(rmock.ANY, status_code=200) + job = _TestflingerJob(fake_job_data, client) job.phase = 'provision' # unfortunately, we need to sleep for longer that 10 seconds here # or else we fall under the polling time job.run_with_log('sleep 12 && echo complete', logfile) with open(logfile) as log: log_data = log.read() - self.assertEqual(timeout_str, log_data) + assert(timeout_str == log_data) From acf77c5ecfe932dd5b97d36055a9eab7af7a32e1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 30 Jun 2020 21:51:32 -0500 Subject: [PATCH 294/569] Add .pmr-merge-hook --- .pmr-merge-hook | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100755 .pmr-merge-hook diff --git a/.pmr-merge-hook b/.pmr-merge-hook new file mode 100755 index 00000000..c1ce1eeb --- /dev/null +++ b/.pmr-merge-hook @@ -0,0 +1,8 @@ +#!/bin/sh + +set -e + +rm -rf tfenv +virtualenv -qp python3 tfenv +. tfenv/bin/activate +./setup.py test From 56bdf17b3d8478808c798c1679a080d2784a984f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 2 Jul 2020 15:35:13 -0500 Subject: [PATCH 295/569] Use an older mock version so we can run unit tests on older series --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b1c81c88..0af4c0ca 100755 --- a/setup.py +++ b/setup.py @@ -25,7 +25,9 @@ ] TEST_REQUIRES = [ - "mock", + # newer mock requires python3.8 features, use an older one so we + # can run unit tests on older systems + "mock==3.0.5", "pytest", "pytest-cov", "pytest-flake8", From cdda95ce02a7dae3c99a08fbae8bb2e749f30541 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 2 Jul 2020 22:36:13 -0500 Subject: [PATCH 296/569] Create env vars for config items when running phase commands --- testflinger_agent/job.py | 6 +++++- testflinger_agent/tests/test_agent.py | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 725f0001..bccf21b8 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -103,6 +103,10 @@ def run_with_log(self, cmd, logfile, cwd=None): :return: returncode from the process """ + env = os.environ.copy() + # Make sure there all values we add are strings + env.update({k: v for k, v in self.client.config.items() + if isinstance(v, str)}) global_timeout = self.get_global_timeout() output_timeout = self.get_output_timeout() start_time = time.time() @@ -112,7 +116,7 @@ def run_with_log(self, cmd, logfile, cwd=None): buffer_timeout = time.time() process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - shell=True, cwd=cwd) + shell=True, cwd=cwd, env=env) def cleanup(signum, frame): process.kill() diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 4fb36655..72cbfb36 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -24,7 +24,8 @@ def agent(self): 'job_queues': ['test'], 'execution_basedir': self.tmpdir, 'logging_basedir': self.tmpdir, - 'results_basedir': os.path.join(self.tmpdir, 'results') + 'results_basedir': os.path.join(self.tmpdir, 'results'), + 'test_string': 'ThisIsATest' } testflinger_agent.configure_logging(self.config) client = _TestflingerClient(self.config) @@ -77,6 +78,21 @@ def test_check_and_run_test(self, agent, requests_mock): 'test.log')).read() assert('test1' == testlog.splitlines()[-1].strip()) + def test_config_vars_in_env(self, agent, requests_mock): + self.config['test_command'] = 'echo test_string is $test_string' + fake_job_data = {'job_id': str(uuid.uuid1()), + 'job_queue': 'test', + 'test_data': ''} + requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, + {'text': '{}'}]) + requests_mock.post(rmock.ANY, status_code=200) + with patch('shutil.rmtree'): + agent.process_jobs() + testlog = open(os.path.join(self.tmpdir, + fake_job_data.get('job_id'), + 'test.log')).read() + assert("ThisIsATest" in testlog) + def test_phase_failed(self, agent, requests_mock): # Make sure we stop running after a failed phase self.config['provision_command'] = '/bin/false' From b8ca1b4cfbd188f370118e5ffb245f6d1b5bb6c7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 3 Jul 2020 15:19:50 -0500 Subject: [PATCH 297/569] Add a config option for provision_type --- testflinger_agent/schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index a9d167da..830419a5 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -32,6 +32,7 @@ voluptuous.Required('test_command', default=''): str, voluptuous.Required('reserve_command', default=''): str, voluptuous.Required('cleanup_command', default=''): str, + voluptuous.Optional('provision_type'): str, voluptuous.Optional('global_timeout'): int, voluptuous.Optional('output_timeout'): int, voluptuous.Optional('advertised_queues'): dict, From 114af3d8c566b73619abaacbc62dfb3af890c351 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Jul 2020 16:05:16 -0500 Subject: [PATCH 298/569] Allow a longer timeout for oemrecovery commands --- devices/oemrecovery/oemrecovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 0cfbb8b6..48e1ff07 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -124,7 +124,7 @@ def _run_cmd_list(self, cmdlist): for cmd in cmdlist: logger.info("Running %s", cmd) try: - output = self._run_device(cmd, timeout=90) + output = self._run_device(cmd, timeout=600) except TimeoutError: raise ProvisioningError("timeout reaching control host!") logger.info(output) From fc3337e0015ec167e84951ba9c743db8239ad6e8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Jul 2020 12:43:58 -0500 Subject: [PATCH 299/569] If output files are larger than 1MB, truncate them --- testflinger_agent/job.py | 23 +++++++++++++++++++++-- testflinger_agent/tests/test_job.py | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index bccf21b8..7c0cd738 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -80,10 +80,12 @@ def run_test_phase(self, phase, rundir): with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: outcome_data = json.load(f) if os.path.exists(output_log): - with open(output_log, encoding='utf-8') as f: + with open(output_log, 'r+', encoding='utf-8') as f: + self._set_truncate(f) outcome_data[phase+'_output'] = f.read() if os.path.exists(serial_log): - with open(serial_log, encoding='utf-8') as f: + with open(serial_log, 'r+', encoding='utf-8') as f: + self._set_truncate(f) outcome_data[phase+'_serial'] = f.read() outcome_data[phase+'_status'] = exitcode with open(os.path.join(rundir, 'testflinger-outcome.json'), @@ -91,6 +93,23 @@ def run_test_phase(self, phase, rundir): json.dump(outcome_data, f) sys.exit(exitcode) + def _set_truncate(self, f, size=1024*1024): + """Set up an open file so that we don't read more than a specified + size. We want to read from the end of the file rather than the + beginning. Write a warning at the end of the file if it was too big. + + :param f: + The file object, which should be opened for read/write + :param size: + Maximum number of bytes we want to allow from reading the file + """ + end = f.seek(0, 2) + if end > size: + f.write('\nWARNING: File has been truncated due to length!') + f.seek(end-size, 0) + else: + f.seek(0, 0) + def run_with_log(self, cmd, logfile, cwd=None): """Execute command in a subprocess and log the output diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 7ab25bb5..481e7b47 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -110,3 +110,22 @@ def test_no_output_timeout_in_provision(self, client, requests_mock): with open(logfile) as log: log_data = log.read() assert(timeout_str == log_data) + + def test_set_truncate(self, client): + """Test the _set_truncate method of TestflingerJob""" + job = _TestflingerJob({}, client) + with tempfile.TemporaryFile(mode="r+") as f: + # First check that a small file doesn't get truncated + f.write("x" * 100) + job._set_truncate(f, size=100) + contents = f.read() + assert(len(contents) == 100) + assert("WARNING" not in contents) + + # Now check that a larger file does get truncated + f.write("x" * 100) + job._set_truncate(f, size=100) + contents = f.read() + # It won't be exactly 100 bytes, because a warning is added + assert(len(contents) < 150) + assert("WARNING" in contents) From 45c1366007f1954107b44b6a06b4314b2b8ca96d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 18 Aug 2020 15:40:48 -0500 Subject: [PATCH 300/569] Try installing the efitools snap if efibootmgr fails to run --- devices/maas2/maas2.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 7a22cddd..61cd0e56 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -74,6 +74,18 @@ def provision(self): user_data = provision_data.get('user_data') self.deploy_node(distro, kernel, user_data) + def _install_efitools_snap(self): + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'sudo snap install efi-tools-ijohnson --devmode --edge'] + subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'sudo snap alias efi-tools-ijohnson.efibootmgr efibootmgr'] + subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + def _get_efi_data(self): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', @@ -81,6 +93,15 @@ def _get_efi_data(self): 'sudo efibootmgr'] p = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + # If it fails the first time, try installing efitools snap + if p.returncode: + self._install_efitools_snap() + cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + 'ubuntu@{}'.format(self.config['device_ip']), + 'sudo efibootmgr'] + p = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if p.returncode: return None # Use OrderedDict because often the NIC entries in EFI are in a good From 4557eb33ef712365ffc9b7f0aa824514efea2e26 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 19 Aug 2020 11:35:19 -0500 Subject: [PATCH 301/569] Allow for per-agent mountpoints on muxpi in case we have one controller with many devices --- devices/muxpi/muxpi.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 279f4abe..2a32c1b8 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -45,6 +45,8 @@ def __init__(self, config, job_data): self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) + self.agent_name = config.get('agent_name') + self.mount_point = os.path.join('/mnt', self.agent_name) def _run_control(self, cmd, timeout=60): """ @@ -145,13 +147,15 @@ def flash_test_image(self, server_ip, server_port): "partitions") @contextmanager - def remote_mount(self, remote_device, mount_point='/mnt'): + def remote_mount(self, remote_device): self._run_control( - 'sudo mount /dev/{} {}'.format(remote_device, mount_point)) + 'sudo mkdir -p {}'.format(self.mount_point)) + self._run_control( + 'sudo mount /dev/{} {}'.format(remote_device, self.mount_point)) try: - yield mount_point + yield self.mount_point finally: - self._run_control('sudo umount {}'.format(mount_point)) + self._run_control('sudo umount {}'.format(self.mount_point)) def hardreset(self): """ @@ -187,7 +191,7 @@ def get_image_type(self): for dev in dev_list: try: with self.remote_mount(dev): - dirs = self._run_control('ls /mnt') + dirs = self._run_control('ls {}'.format(self.mount_point)) for path, img_type in self.IMAGE_PATH_IDS.items(): if path in dirs.decode().split(): return img_type, dev @@ -234,9 +238,9 @@ def create_user(self, image_type): ' meta-data: |\n' ' instance_id: cloud-image') - base = '/mnt' + base = self.mount_point if image_type == 'core': - base = '/mnt/system-data' + base = os.path.join(base, 'system-data') try: if image_type == 'core20': ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') From 956059f578444f20bebb617e783303e08146dd74 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 19 Aug 2020 14:04:11 -0500 Subject: [PATCH 302/569] missing self --- devices/muxpi/muxpi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 2a32c1b8..81b5a512 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -45,7 +45,7 @@ def __init__(self, config, job_data): self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) - self.agent_name = config.get('agent_name') + self.agent_name = self.config.get('agent_name') self.mount_point = os.path.join('/mnt', self.agent_name) def _run_control(self, cmd, timeout=60): From 350cfa0bde850884c1769eb0875bc89c1e743533 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 24 Aug 2020 11:33:57 -0500 Subject: [PATCH 303/569] Don't spam the logs with warnings about logstash if it's not available --- snappy_device_agents/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 8385b1d3..0455ccb6 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -303,8 +303,7 @@ def filter(self, record): try: import logstash except ImportError: - print( - 'Install python-logstash if you want to use logstash logging') + pass else: logger.addHandler(logstash.LogstashHandler(logstash_host, 5959, 1)) From bc3dd80e87640bea245575b50a82dd9d34a6c604 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 3 Sep 2020 11:46:03 -0500 Subject: [PATCH 304/569] Exit polling when a job is cancelled (LP:1861651) --- testflinger_cli/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index f8aae934..f58ae8a4 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -242,6 +242,8 @@ def poll(ctx, job_id, oneshot): print('This job is currently waiting on a node to become available.') prev_queue_pos = None while job_state != 'complete': + if job_state == 'cancelled': + break if job_state == 'waiting': try: queue_pos = conn.get_job_position(job_id) From ab0007e18a58bced7d7f3356907fe0b5ac610a1c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 3 Sep 2020 11:47:39 -0500 Subject: [PATCH 305/569] Small fixes to snapcraft.yaml --- snapcraft.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/snapcraft.yaml b/snapcraft.yaml index 9d0db772..2a5d669b 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -1,11 +1,12 @@ name: testflinger-cli -version: 0.1 +version: '0.1' summary: testflinger-cli description: | The testflinger-cli tool is used for interacting with the testflinger server for submitting test jobs, checking status, getting results, and streaming output. confinement: strict +base: core18 apps: testflinger-cli: From 51a858783c37acebe559dc0db0c6bfb03442a015 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 9 Sep 2020 11:13:22 -0500 Subject: [PATCH 306/569] use argparse instead of click --- setup.py | 2 +- testflinger_cli/__init__.py | 739 ++++++++++++++++++------------------ 2 files changed, 373 insertions(+), 368 deletions(-) diff --git a/setup.py b/setup.py index a5517520..736eddc4 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ # from setuptools import setup -INSTALL_REQUIRES = ['click', 'pyyaml', 'requests'] +INSTALL_REQUIRES = ['pyyaml', 'requests'] TEST_REQUIRES = [] setup( diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index f58ae8a4..e7295248 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -15,13 +15,13 @@ # -import click import inspect import json import os import sys import time +from argparse import ArgumentParser from testflinger_cli import client @@ -31,395 +31,400 @@ sys.path.insert(0, basedir) -@click.group() -@click.option('--server', default='https://testflinger.canonical.com', - help='Testflinger server to use') -@click.pass_context -def cli(ctx, server): - ctx.obj = {} - env_server = os.environ.get('TESTFLINGER_SERVER') - if env_server: - server = env_server - if not server.startswith(('http://','https://')): - raise SystemExit('Server must start with "http://" or "https://"') - ctx.obj['conn'] = client.Client(server) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.pass_context -def status(ctx, job_id): - """Show the status of a specified JOB_ID""" - conn = ctx.obj['conn'] - try: - job_state = conn.get_status(job_id) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('No data found for that job id. Check the job ' - 'id to be sure it is correct') - if e.status == 400: - raise SystemExit('Invalid job id specified. Check the job id to ' - 'be sure it is correct') - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') - print(job_state) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.pass_context -def cancel(ctx, job_id): - """Tell the server to cancel a specified JOB_ID""" - conn = ctx.obj['conn'] - try: - job_state = conn.get_status(job_id) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('Job {} not found. Check the job id to be sure ' - 'it is correct.'.format(job_id)) - if e.status == 400: - raise SystemExit('Invalid job id specified. Check the job id to ' - 'be sure it is correct.') - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') - if job_state in ('complete', 'cancelled'): - raise SystemExit('Job {} is already in {} state and cannot be ' - 'cancelled.'.format(job_id, job_state)) - conn.post_job_state(job_id, 'cancelled') - - -@cli.command() -@click.argument('filename', nargs=1) -@click.option('--poll', '-p', 'poll_opt', is_flag=True) -@click.option('--quiet', '-q', is_flag=True) -@click.pass_context -def submit(ctx, filename, quiet, poll_opt): - """Submit a new test job to the server""" - conn = ctx.obj['conn'] - if filename == '-': - data = sys.stdin.read() - else: +def cli(): + tfcli = TestflingerCli() + tfcli.run() + + +class TestflingerCli: + def __init__(self): + self.get_args() + server = os.environ.get('TESTFLINGER_SERVER') or self.args.server + if not server.startswith(('http://', 'https://')): + raise SystemExit('Server must start with "http://" or "https://"') + self.client = client.Client(self.args.server) + + def run(self): + if hasattr(self.args, 'func'): + raise SystemExit(self.args.func()) + print(self.help) + + def get_args(self): + parser = ArgumentParser() + parser.add_argument('--server', + default='https://testflinger.canonical.com', + help='Testflinger server to use') + sub = parser.add_subparsers() + arg_artifacts = sub.add_parser( + 'artifacts', + help='Download a tarball of artifacts saved for a specified job') + arg_artifacts.set_defaults(func=self.artifacts) + arg_artifacts.add_argument('--filename', default='artifacts.tgz') + arg_artifacts.add_argument('job_id') + arg_cancel = sub.add_parser( + 'cancel', help='Tell the server to cancel a specified JOB_ID') + arg_cancel.set_defaults(func=self.cancel) + arg_cancel.add_argument('job_id') + arg_list_queues = sub.add_parser( + 'list-queues', + help='List the advertised queues on the Testflinger server') + arg_list_queues.set_defaults(func=self.list_queues) + arg_poll = sub.add_parser( + 'poll', help='Poll for output from a job until it is complete') + arg_poll.set_defaults(func=self.poll) + arg_poll.add_argument('--oneshot', '-o', action='store_true', + help='Get latest output and exit immediately') + arg_poll.add_argument('job_id') + arg_reserve = sub.add_parser( + 'reserve', help='Install and reserve a system') + arg_reserve.set_defaults(func=self.reserve) + arg_reserve.add_argument('--queue', '-q', + help='Name of the queue to use') + arg_reserve.add_argument( + '--image', '-i', help='Name of the image to use for provisioning') + arg_reserve.add_argument( + '--key', '-k', nargs='*', + help=('Ssh key(s) to use for reservation ' + '(ex: -k lp:userid -k gh:userid)')) + arg_results = sub.add_parser( + 'results', help='Get results JSON for a completed JOB_ID') + arg_results.set_defaults(func=self.results) + arg_results.add_argument('job_id') + arg_show = sub.add_parser( + 'show', help='Show the requested job JSON for a specified JOB_ID') + arg_show.set_defaults(func=self.show) + arg_show.add_argument('job_id') + arg_status = sub.add_parser( + 'status', help='Show the status of a specified JOB_ID') + arg_status.set_defaults(func=self.status) + arg_status.add_argument('job_id') + arg_submit = sub.add_parser( + 'submit', help='Submit a new test job to the server') + arg_submit.set_defaults(func=self.submit) + arg_submit.add_argument('--poll', '-p', action='store_true') + arg_submit.add_argument('--quiet', '-q', action='store_true') + arg_submit.add_argument('filename') + + self.args = parser.parse_args() + self.help = parser.format_help() + + def status(self): + """Show the status of a specified JOB_ID""" try: - with open(filename) as f: - data = f.read() - except FileNotFoundError: - raise SystemExit('File not found: {}'.format(filename)) + job_state = self.client.get_status(self.args.job_id) + except client.HTTPError as e: + if e.status == 204: + raise SystemExit('No data found for that job id. Check the ' + 'job id to be sure it is correct') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job ' + 'id to be sure it is correct') + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you ' + 'sure this is a testflinger server?') except Exception: - raise SystemExit('Unable to read file: {}'.format(filename)) - job_id = submit_job_data(conn, data) - if quiet: - print(job_id) - else: - print('Job submitted successfully!') - print('job_id: {}'.format(job_id)) - if poll_opt: - ctx.invoke(poll, job_id=job_id) - - -def submit_job_data(conn, data): - """ Submit data that was generated or read from a file as a test job - """ - try: - job_id = conn.submit_job(data) - except client.HTTPError as e: - if e.status == 400: - raise SystemExit('The job you submitted contained bad data or ' - 'bad formatting, or did not specify a ' - 'job_queue.') - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - # This shouldn't happen, so let's get more information - raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) - return job_id - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.pass_context -def show(ctx, job_id): - """Show the requested job JSON for a specified JOB_ID""" - conn = ctx.obj['conn'] - try: - results = conn.show_job(job_id) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('No data found for that job id.') - if e.status == 400: - raise SystemExit('Invalid job id specified. Check the job id to ' - 'be sure it is correct') - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - # This shouldn't happen, so let's get more information - raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) - print(json.dumps(results, sort_keys=True, indent=4)) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.pass_context -def results(ctx, job_id): - """Get results JSON for a completed JOB_ID""" - conn = ctx.obj['conn'] - try: - results = conn.get_results(job_id) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('No results found for that job id.') - if e.status == 400: - raise SystemExit('Invalid job id specified. Check the job id to ' - 'be sure it is correct') - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - # This shouldn't happen, so let's get more information - raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') - - print(json.dumps(results, sort_keys=True, indent=4)) - + raise SystemExit( + 'Error communicating with server, check connection and retry') + print(job_state) -@cli.command() -@click.argument('job_id', nargs=1) -@click.option('--filename', default='artifacts.tgz') -@click.pass_context -def artifacts(ctx, job_id, filename): - """Download a tarball of artifacts saved for a specified job""" - conn = ctx.obj['conn'] - print('Downloading artifacts tarball...') - try: - conn.get_artifact(job_id, filename) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('No artifacts tarball found for that job id.') - if e.status == 400: - raise SystemExit('Invalid job id specified. Check the job id to ' - 'be sure it is correct') - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - # This shouldn't happen, so let's get more information - raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') - print('Artifacts downloaded to {}'.format(filename)) - - -@cli.command() -@click.argument('job_id', nargs=1) -@click.option('--oneshot', '-o', is_flag=True, - help='Get latest output and exit immediately') -@click.pass_context -def poll(ctx, job_id, oneshot): - """Poll for output from a job until it is complete""" - conn = ctx.obj['conn'] - if oneshot: + def cancel(self): + """Tell the server to cancel a specified JOB_ID""" try: - output = get_latest_output(conn, job_id) + job_state = self.client.get_status(self.args.job_id) + except client.HTTPError as e: + if e.status == 204: + raise SystemExit('Job {} not found. Check the job ' + 'id to be sure it is ' + 'correct.'.format(self.args.job_id)) + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job ' + 'id to be sure it is correct.') + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you ' + 'sure this is a testflinger server?') except Exception: - sys.exit(1) - if output: - print(output, end='', flush=True) - sys.exit(0) - job_state = get_job_state(conn, job_id) - if job_state == 'waiting': - print('This job is currently waiting on a node to become available.') - prev_queue_pos = None - while job_state != 'complete': - if job_state == 'cancelled': - break - if job_state == 'waiting': + raise SystemExit( + 'Error communicating with server, check connection and retry') + if job_state in ('complete', 'cancelled'): + raise SystemExit('Job {} is already in {} state and cannot be ' + 'cancelled.'.format(self.args.job_id, job_state)) + self.client.post_job_state(self.args.job_id, 'cancelled') + + def submit(self): + """Submit a new test job to the server""" + if self.args.filename == '-': + data = sys.stdin.read() + else: try: - queue_pos = conn.get_job_position(job_id) - if int(queue_pos) != prev_queue_pos: - prev_queue_pos = int(queue_pos) - print('Jobs ahead in queue: {}'.format(queue_pos)) + with open(self.args.filename) as f: + data = f.read() + except FileNotFoundError: + raise SystemExit( + 'File not found: {}'.format(self.args.filename)) except Exception: - # Ignore any bad response, this will retry - pass - time.sleep(10) - output = '' + raise SystemExit( + 'Unable to read file: {}'.format(self.args.filename)) + job_id = self.submit_job_data(data) + if self.args.quiet: + print(job_id) + else: + print('Job submitted successfully!') + print('job_id: {}'.format(job_id)) + if self.args.poll: + self.do_poll(job_id) + + def submit_job_data(self, data): + """ Submit data that was generated or read from a file as a test job + """ + try: + job_id = self.client.submit_job(data) + except client.HTTPError as e: + if e.status == 400: + raise SystemExit('The job you submitted contained bad data or ' + 'bad formatting, or did not specify a ' + 'job_queue.') + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you ' + 'sure this is a testflinger server?') + # This shouldn't happen, so let's get more information + raise SystemExit('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) + return job_id + + def show(self): + """Show the requested job JSON for a specified JOB_ID""" + try: + results = self.client.show_job(self.args.job_id) + except client.HTTPError as e: + if e.status == 204: + raise SystemExit('No data found for that job id.') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id ' + 'to be sure it is correct') + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you ' + 'sure this is a testflinger server?') + # This shouldn't happen, so let's get more information + raise SystemExit('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) + print(json.dumps(results, sort_keys=True, indent=4)) + + def results(self): + """Get results JSON for a completed JOB_ID""" try: - output = get_latest_output(conn, job_id) + results = self.client.get_results(self.args.job_id) + except client.HTTPError as e: + if e.status == 204: + raise SystemExit('No results found for that job id.') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id ' + 'to be sure it is correct') + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you ' + 'sure this is a testflinger server?') + # This shouldn't happen, so let's get more information + raise SystemExit('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) except Exception: - continue - if output: - print(output, end='', flush=True) - job_state = get_job_state(conn, job_id) - print(job_state) + raise SystemExit( + 'Error communicating with server, check connection and retry') + print(json.dumps(results, sort_keys=True, indent=4)) -@cli.command(name='list-queues') -@click.pass_context -def list_queues(ctx): - """List the advertised queues on the current Testflinger server""" - conn = ctx.obj['conn'] - try: - queues = conn.get_queues() - except client.HTTPError as e: - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') - print('Advertised queues on this server:') - for name, description in sorted(queues.items()): - print(' {} - {}'.format(name, description)) - + def artifacts(self): + """Download a tarball of artifacts saved for a specified job""" + print('Downloading artifacts tarball...') + try: + self.client.get_artifact(self.args.job_id, self.args.filename) + except client.HTTPError as e: + if e.status == 204: + raise SystemExit('No artifacts tarball found for that job id.') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id ' + 'to be sure it is correct') + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you ' + 'sure this is a testflinger server?') + # This shouldn't happen, so let's get more information + raise SystemExit('Unexpected error status from testflinger ' + 'server: {}'.format(e.status)) + except Exception: + raise SystemExit( + 'Error communicating with server, check connection and retry') + print('Artifacts downloaded to {}'.format(self.args.filename)) -@cli.command() -@click.option('--queue', '-q', - help='Name of the queue to use') -@click.option('--image', '-i', - help='Name of the image to use for provisioning') -@click.option('--key', '-k', 'ssh_keys', multiple=True, - help='Ssh key(s) to use for reservation ' - '(ex: -k lp:userid -k gh:userid)') -@click.pass_context -def reserve(ctx, queue, image, ssh_keys): - """Install and reserve a system""" - conn = ctx.obj['conn'] - try: - queues = conn.get_queues() - except Exception: - print("WARNING: unable to get a list of queues from the server!") - queues = {} - if not queue: - queue = _get_queue(queues) - else: + def poll(self): + """Poll for output from a job until it is complete""" + if self.args.oneshot: + try: + output = self.get_latest_output(self.args.job_id) + except Exception: + sys.exit(1) + if output: + print(output, end='', flush=True) + sys.exit(0) + self.do_poll(self.args.job_id) + + def do_poll(self, job_id): + job_state = self.get_job_state(job_id) + if job_state == 'waiting': + print('This job is waiting on a node to become available.') + prev_queue_pos = None + while job_state != 'complete': + if job_state == 'cancelled': + break + if job_state == 'waiting': + try: + queue_pos = self.client.get_job_position(job_id) + if int(queue_pos) != prev_queue_pos: + prev_queue_pos = int(queue_pos) + print('Jobs ahead in queue: {}'.format(queue_pos)) + except Exception: + # Ignore any bad response, this will retry + pass + time.sleep(10) + output = '' + try: + output = self.get_latest_output(job_id) + except Exception: + continue + if output: + print(output, end='', flush=True) + job_state = self.get_job_state(job_id) + print(job_state) + + def list_queues(self): + """List the advertised queues on the current Testflinger server""" + try: + queues = self.client.get_queues() + except client.HTTPError as e: + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you ' + 'sure this is a testflinger server?') + except Exception: + raise SystemExit( + 'Error communicating with server, check connection and retry') + print('Advertised queues on this server:') + for name, description in sorted(queues.items()): + print(' {} - {}'.format(name, description)) + + def reserve(self): + """Install and reserve a system""" + try: + queues = self.client.get_queues() + except Exception: + print("WARNING: unable to get a list of queues from the server!") + queues = {} + queue = self.args.queue or self._get_queue(queues) if queue not in queues.keys(): print("WARNING: '{}' is not in the list of known " "queues".format(queue)) - try: - images = conn.get_images(queue) - except Exception: - print("WARNING: unable to get a list of images from the server!") - images = {} - if not image: - image = _get_image(images) - else: + try: + images = self.client.get_images(queue) + except Exception: + print("WARNING: unable to get a list of images from the server!") + images = {} + image = self.args.image or self._get_image(images) if image not in images.keys(): raise SystemExit("ERROR: '{}' is not in the list of known " "images for that queue, please select " "another.".format(image)) image = images[image] - if not ssh_keys: - ssh_keys = _get_ssh_keys() - else: + ssh_keys = self.args.key or self._get_ssh_keys() for ssh_key in ssh_keys: if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): raise SystemExit("Please enter keys in the form lp:userid or " "gh:userid") - template = inspect.cleandoc("""job_queue: {queue} - provision_data: - {image} - reserve_data: - ssh_keys:""") - for ssh_key in ssh_keys: - template += "\n - {}".format(ssh_key) - job_data = template.format(queue=queue, image=image) - print("\nThe following yaml will be submitted:") - print(job_data) - answer = input("Proceed? (Y/n) ") - if answer in ("Y", "y", ""): - job_id = submit_job_data(conn, job_data) - print('Job submitted successfully!') - print('job_id: {}'.format(job_id)) - ctx.invoke(poll, job_id=job_id) - - -def _get_queue(queues): - queue = "" - while not queue or queue == "?": - queue = input("\nWhich queue do you want to use? ('?' to list) ") - if not queue: - continue - if queue == "?": - print("\nAdvertised queues on this server:") - for name, description in sorted(queues.items()): - print(" {} - {}".format(name, description)) - queue = _get_queue(queues) - if queue not in queues.keys(): - print("WARNING: '{}' is not in the list of known " - "queues".format(queue)) - answer = input("Do you still want to use it? (y/N) ") - if answer.lower() != "y": - queue = "" - return queue - - -def _get_image(images): - image = "" - while not image or image == "?": - image = input("\nEnter the name of the image you want to use " - "('?' to list) ") - if image == "?": - for image_id in sorted(images.keys()): - print(" " + image_id) - continue - if image not in images.keys(): - print("ERROR: '{}' is not in the list of known images for that " - "queue, please select another.".format(image)) - image = "" - return images.get(image) - - -def _get_ssh_keys(): - ssh_keys = "" - while not ssh_keys.strip(): - ssh_keys = input("\nEnter the ssh key(s) you wish to use: " - "(ex: lp:userid, gh:userid) ") - key_list = [ssh_key.strip() for ssh_key in ssh_keys.split(",")] - for ssh_key in key_list: - if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): - ssh_keys = "" - print("Please enter keys in the form lp:userid or gh:userid") - return key_list - + template = inspect.cleandoc("""job_queue: {queue} + provision_data: + {image} + reserve_data: + ssh_keys:""") + for ssh_key in ssh_keys: + template += "\n - {}".format(ssh_key) + job_data = template.format(queue=queue, image=image) + print("\nThe following yaml will be submitted:") + print(job_data) + answer = input("Proceed? (Y/n) ") + if answer in ("Y", "y", ""): + job_id = self.submit_job_data(job_data) + print('Job submitted successfully!') + print('job_id: {}'.format(job_id)) + self.do_poll(job_id) + + def _get_queue(self, queues): + queue = "" + while not queue or queue == "?": + queue = input("\nWhich queue do you want to use? ('?' to list) ") + if not queue: + continue + if queue == "?": + print("\nAdvertised queues on this server:") + for name, description in sorted(queues.items()): + print(" {} - {}".format(name, description)) + queue = self._get_queue(queues) + if queue not in queues.keys(): + print("WARNING: '{}' is not in the list of known " + "queues".format(queue)) + answer = input("Do you still want to use it? (y/N) ") + if answer.lower() != "y": + queue = "" + return queue + + def _get_image(self, images): + image = "" + while not image or image == "?": + image = input("\nEnter the name of the image you want to use " + "('?' to list) ") + if image == "?": + for image_id in sorted(images.keys()): + print(" " + image_id) + continue + if image not in images.keys(): + print("ERROR: '{}' is not in the list of known images for " + "that queue, please select another.".format(image)) + image = "" + return image + + def _get_ssh_keys(self): + ssh_keys = "" + while not ssh_keys.strip(): + ssh_keys = input("\nEnter the ssh key(s) you wish to use: " + "(ex: lp:userid, gh:userid) ") + key_list = [ssh_key.strip() for ssh_key in ssh_keys.split(",")] + for ssh_key in key_list: + if (not ssh_key.startswith("lp:") and + not ssh_key.startswith("gh:")): + ssh_keys = "" + print("Please enter keys in the form lp:userid " + "or gh:userid") + return key_list + + def get_latest_output(self, job_id): + output = '' + try: + output = self.client.get_output(job_id) + except client.HTTPError as e: + if e.status == 204: + # We are still waiting for the job to start + pass + return output -def get_latest_output(conn, job_id): - output = '' - try: - output = conn.get_output(job_id) - except client.HTTPError as e: - if e.status == 204: - # We are still waiting for the job to start + def get_job_state(self, job_id): + try: + return self.client.get_status(job_id) + except client.HTTPError as e: + if e.status == 204: + raise SystemExit('No data found for that job id. Check the ' + 'job id to be sure it is correct') + if e.status == 400: + raise SystemExit('Invalid job id specified. Check the job id ' + 'to be sure it is correct') + if e.status == 404: + raise SystemExit('Received 404 error from server. Are you ' + 'sure this is a testflinger server?') + except Exception: + # If we fail to get the job_state here, it could be because of + # timeout but we can keep going and retrying pass - return output - - -def get_job_state(conn, job_id): - try: - return conn.get_status(job_id) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('No data found for that job id. Check the job ' - 'id to be sure it is correct') - if e.status == 400: - raise SystemExit('Invalid job id specified. Check the job id to ' - 'be sure it is correct') - if e.status == 404: - raise SystemExit('Received 404 error from server. Are you sure ' - 'this is a testflinger server?') - except Exception: - # If we fail to get the job_state here, it could be because of timeout - # but we can keep going and retrying - pass - return 'unknown' + return 'unknown' From 7ccdf58bde75f4d6a6bb13c80bf967e9c1caeff6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 9 Sep 2020 15:31:31 -0500 Subject: [PATCH 307/569] Use testflinger server specified by env var or by args --- testflinger_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index e7295248..f989d119 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -42,7 +42,7 @@ def __init__(self): server = os.environ.get('TESTFLINGER_SERVER') or self.args.server if not server.startswith(('http://', 'https://')): raise SystemExit('Server must start with "http://" or "https://"') - self.client = client.Client(self.args.server) + self.client = client.Client(server) def run(self): if hasattr(self.args, 'func'): From 0851cf0c608a6d4d35618c735ac2b63698e4e178 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 10 Sep 2020 08:56:39 -0500 Subject: [PATCH 308/569] Handle keyboard interrupts --- testflinger_cli/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index f989d119..a0fe783c 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -32,8 +32,11 @@ def cli(): - tfcli = TestflingerCli() - tfcli.run() + try: + tfcli = TestflingerCli() + tfcli.run() + except KeyboardInterrupt: + raise SystemExit class TestflingerCli: From 3266cc66679902e1b0643eb4056f57c46b5913d6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 10 Sep 2020 16:52:28 -0500 Subject: [PATCH 309/569] Add support for a testflinger-cli.conf file --- setup.py | 4 ++-- testflinger_cli/__init__.py | 41 +++++++++++++++++++++++++++++---- testflinger_cli/config.py | 46 +++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 testflinger_cli/config.py diff --git a/setup.py b/setup.py index 736eddc4..8120e8cd 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2017-2019 Canonical +# Copyright (C) 2017-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,7 +16,7 @@ # from setuptools import setup -INSTALL_REQUIRES = ['pyyaml', 'requests'] +INSTALL_REQUIRES = ['pyyaml', 'requests', 'xdg'] TEST_REQUIRES = [] setup( diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index a0fe783c..83cae3b2 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -22,7 +22,7 @@ import time from argparse import ArgumentParser -from testflinger_cli import client +from testflinger_cli import (client, config) # Make it easier to run from a checkout @@ -42,9 +42,19 @@ def cli(): class TestflingerCli: def __init__(self): self.get_args() - server = os.environ.get('TESTFLINGER_SERVER') or self.args.server + self.config = config.TestflingerCliConfig(self.args.configfile) + server = ( + self.args.server or + self.config.get('server') or + os.environ.get('TESTFLINGER_SERVER') or + 'https://testflinger.canonical.com' + ) + # Allow config subcommand without worrying about server or client + if self.args.func == self.configure: + return if not server.startswith(('http://', 'https://')): - raise SystemExit('Server must start with "http://" or "https://"') + raise SystemExit('Server must start with "http://" or "https://" ' + '- currently set to: "{}"'.format(server)) self.client = client.Client(server) def run(self): @@ -54,8 +64,9 @@ def run(self): def get_args(self): parser = ArgumentParser() - parser.add_argument('--server', - default='https://testflinger.canonical.com', + parser.add_argument('-c', '--configfile', default=None, + help='Configuration file to use') + parser.add_argument('--server', default=None, help='Testflinger server to use') sub = parser.add_subparsers() arg_artifacts = sub.add_parser( @@ -68,6 +79,10 @@ def get_args(self): 'cancel', help='Tell the server to cancel a specified JOB_ID') arg_cancel.set_defaults(func=self.cancel) arg_cancel.add_argument('job_id') + arg_config = sub.add_parser( + 'config', help='Get or set configuration options') + arg_config.set_defaults(func=self.configure) + arg_config.add_argument('setting', nargs='?', help='setting=value') arg_list_queues = sub.add_parser( 'list-queues', help='List the advertised queues on the Testflinger server') @@ -153,6 +168,22 @@ def cancel(self): 'cancelled.'.format(self.args.job_id, job_state)) self.client.post_job_state(self.args.job_id, 'cancelled') + def configure(self): + if self.args.setting: + setting = self.args.setting.split('=') + if len(setting) == 2: + self.config.set(*setting) + return + if len(setting) == 1: + print("{} = {}".format( + setting[0], self.config.get(setting[0]))) + return + print("Current Configuration") + print("---------------------") + for k, v in self.config.data.items(): + print("{} = {}".format(k, v)) + print() + def submit(self): """Submit a new test job to the server""" if self.args.filename == '-': diff --git a/testflinger_cli/config.py b/testflinger_cli/config.py new file mode 100644 index 00000000..8407be8c --- /dev/null +++ b/testflinger_cli/config.py @@ -0,0 +1,46 @@ +# Copyright (C) 2020 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import configparser +import os +import xdg + + +class TestflingerCliConfig: + def __init__(self, configfile=None): + config = configparser.ConfigParser() + if not configfile: + configfile = os.path.join( + xdg.XDG_CONFIG_HOME, "testflinger-cli.conf") + config.read(configfile) + # Default empty config in case there's no config file + self.data = dict() + if 'testflinger-cli' in config.sections(): + self.data = dict(config['testflinger-cli']) + self.configfile = configfile + + def get(self, key): + return self.data.get(key) + + def set(self, key, value): + self.data[key] = value + self._save() + + def _save(self): + config = configparser.ConfigParser() + config.read_dict({'testflinger-cli': self.data}) + with open(self.configfile, 'w') as f: + config.write(f) From ab9272a3513cf4488f573e2141be668be30ffd11 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 11 Sep 2020 07:26:09 -0500 Subject: [PATCH 310/569] use OrderedDict for config data --- testflinger_cli/config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testflinger_cli/config.py b/testflinger_cli/config.py index 8407be8c..983699d3 100644 --- a/testflinger_cli/config.py +++ b/testflinger_cli/config.py @@ -17,6 +17,7 @@ import configparser import os import xdg +from collections import OrderedDict class TestflingerCliConfig: @@ -27,9 +28,9 @@ def __init__(self, configfile=None): xdg.XDG_CONFIG_HOME, "testflinger-cli.conf") config.read(configfile) # Default empty config in case there's no config file - self.data = dict() + self.data = OrderedDict() if 'testflinger-cli' in config.sections(): - self.data = dict(config['testflinger-cli']) + self.data = OrderedDict(config['testflinger-cli']) self.configfile = configfile def get(self, key): From 058c982542f81457fc551ad6908fc590c83d049b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 11 Sep 2020 11:26:17 -0500 Subject: [PATCH 311/569] xdg breaks on python3.5, which is still used on the jenkins host. Revert for now --- setup.py | 4 ++-- testflinger_cli/__init__.py | 41 ++++---------------------------- testflinger_cli/config.py | 47 ------------------------------------- 3 files changed, 7 insertions(+), 85 deletions(-) delete mode 100644 testflinger_cli/config.py diff --git a/setup.py b/setup.py index 8120e8cd..736eddc4 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2017-2020 Canonical +# Copyright (C) 2017-2019 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,7 +16,7 @@ # from setuptools import setup -INSTALL_REQUIRES = ['pyyaml', 'requests', 'xdg'] +INSTALL_REQUIRES = ['pyyaml', 'requests'] TEST_REQUIRES = [] setup( diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 83cae3b2..a0fe783c 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -22,7 +22,7 @@ import time from argparse import ArgumentParser -from testflinger_cli import (client, config) +from testflinger_cli import client # Make it easier to run from a checkout @@ -42,19 +42,9 @@ def cli(): class TestflingerCli: def __init__(self): self.get_args() - self.config = config.TestflingerCliConfig(self.args.configfile) - server = ( - self.args.server or - self.config.get('server') or - os.environ.get('TESTFLINGER_SERVER') or - 'https://testflinger.canonical.com' - ) - # Allow config subcommand without worrying about server or client - if self.args.func == self.configure: - return + server = os.environ.get('TESTFLINGER_SERVER') or self.args.server if not server.startswith(('http://', 'https://')): - raise SystemExit('Server must start with "http://" or "https://" ' - '- currently set to: "{}"'.format(server)) + raise SystemExit('Server must start with "http://" or "https://"') self.client = client.Client(server) def run(self): @@ -64,9 +54,8 @@ def run(self): def get_args(self): parser = ArgumentParser() - parser.add_argument('-c', '--configfile', default=None, - help='Configuration file to use') - parser.add_argument('--server', default=None, + parser.add_argument('--server', + default='https://testflinger.canonical.com', help='Testflinger server to use') sub = parser.add_subparsers() arg_artifacts = sub.add_parser( @@ -79,10 +68,6 @@ def get_args(self): 'cancel', help='Tell the server to cancel a specified JOB_ID') arg_cancel.set_defaults(func=self.cancel) arg_cancel.add_argument('job_id') - arg_config = sub.add_parser( - 'config', help='Get or set configuration options') - arg_config.set_defaults(func=self.configure) - arg_config.add_argument('setting', nargs='?', help='setting=value') arg_list_queues = sub.add_parser( 'list-queues', help='List the advertised queues on the Testflinger server') @@ -168,22 +153,6 @@ def cancel(self): 'cancelled.'.format(self.args.job_id, job_state)) self.client.post_job_state(self.args.job_id, 'cancelled') - def configure(self): - if self.args.setting: - setting = self.args.setting.split('=') - if len(setting) == 2: - self.config.set(*setting) - return - if len(setting) == 1: - print("{} = {}".format( - setting[0], self.config.get(setting[0]))) - return - print("Current Configuration") - print("---------------------") - for k, v in self.config.data.items(): - print("{} = {}".format(k, v)) - print() - def submit(self): """Submit a new test job to the server""" if self.args.filename == '-': diff --git a/testflinger_cli/config.py b/testflinger_cli/config.py deleted file mode 100644 index 983699d3..00000000 --- a/testflinger_cli/config.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (C) 2020 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# - -import configparser -import os -import xdg -from collections import OrderedDict - - -class TestflingerCliConfig: - def __init__(self, configfile=None): - config = configparser.ConfigParser() - if not configfile: - configfile = os.path.join( - xdg.XDG_CONFIG_HOME, "testflinger-cli.conf") - config.read(configfile) - # Default empty config in case there's no config file - self.data = OrderedDict() - if 'testflinger-cli' in config.sections(): - self.data = OrderedDict(config['testflinger-cli']) - self.configfile = configfile - - def get(self, key): - return self.data.get(key) - - def set(self, key, value): - self.data[key] = value - self._save() - - def _save(self): - config = configparser.ConfigParser() - config.read_dict({'testflinger-cli': self.data}) - with open(self.configfile, 'w') as f: - config.write(f) From aaaa5b133da4a10aeaa4f80734b78aaafd32e137 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 11 Sep 2020 12:05:23 -0500 Subject: [PATCH 312/569] Redo the config change with xdg at a lower version for now --- setup.py | 5 ++-- testflinger_cli/__init__.py | 41 ++++++++++++++++++++++++++++---- testflinger_cli/config.py | 47 +++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 testflinger_cli/config.py diff --git a/setup.py b/setup.py index 736eddc4..ce134433 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2017-2019 Canonical +# Copyright (C) 2017-2020 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,7 +16,7 @@ # from setuptools import setup -INSTALL_REQUIRES = ['pyyaml', 'requests'] +INSTALL_REQUIRES = ['pyyaml', 'requests', 'xdg<4.0'] TEST_REQUIRES = [] setup( @@ -35,4 +35,3 @@ ''', ) - diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index a0fe783c..e647e401 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -22,7 +22,7 @@ import time from argparse import ArgumentParser -from testflinger_cli import client +from testflinger_cli import (client, config) # Make it easier to run from a checkout @@ -42,9 +42,19 @@ def cli(): class TestflingerCli: def __init__(self): self.get_args() - server = os.environ.get('TESTFLINGER_SERVER') or self.args.server + self.config = config.TestflingerCliConfig(self.args.configfile) + server = ( + self.args.server or + self.config.get('server') or + os.environ.get('TESTFLINGER_SERVER') or + 'https://testflinger.canonical.com' + ) + # Allow config subcommand without worrying about server or client + if hasattr(self.args, 'func') and self.args.func == self.configure: + return if not server.startswith(('http://', 'https://')): - raise SystemExit('Server must start with "http://" or "https://"') + raise SystemExit('Server must start with "http://" or "https://" ' + '- currently set to: "{}"'.format(server)) self.client = client.Client(server) def run(self): @@ -54,8 +64,9 @@ def run(self): def get_args(self): parser = ArgumentParser() - parser.add_argument('--server', - default='https://testflinger.canonical.com', + parser.add_argument('-c', '--configfile', default=None, + help='Configuration file to use') + parser.add_argument('--server', default=None, help='Testflinger server to use') sub = parser.add_subparsers() arg_artifacts = sub.add_parser( @@ -68,6 +79,10 @@ def get_args(self): 'cancel', help='Tell the server to cancel a specified JOB_ID') arg_cancel.set_defaults(func=self.cancel) arg_cancel.add_argument('job_id') + arg_config = sub.add_parser( + 'config', help='Get or set configuration options') + arg_config.set_defaults(func=self.configure) + arg_config.add_argument('setting', nargs='?', help='setting=value') arg_list_queues = sub.add_parser( 'list-queues', help='List the advertised queues on the Testflinger server') @@ -153,6 +168,22 @@ def cancel(self): 'cancelled.'.format(self.args.job_id, job_state)) self.client.post_job_state(self.args.job_id, 'cancelled') + def configure(self): + if self.args.setting: + setting = self.args.setting.split('=') + if len(setting) == 2: + self.config.set(*setting) + return + if len(setting) == 1: + print("{} = {}".format( + setting[0], self.config.get(setting[0]))) + return + print("Current Configuration") + print("---------------------") + for k, v in self.config.data.items(): + print("{} = {}".format(k, v)) + print() + def submit(self): """Submit a new test job to the server""" if self.args.filename == '-': diff --git a/testflinger_cli/config.py b/testflinger_cli/config.py new file mode 100644 index 00000000..983699d3 --- /dev/null +++ b/testflinger_cli/config.py @@ -0,0 +1,47 @@ +# Copyright (C) 2020 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import configparser +import os +import xdg +from collections import OrderedDict + + +class TestflingerCliConfig: + def __init__(self, configfile=None): + config = configparser.ConfigParser() + if not configfile: + configfile = os.path.join( + xdg.XDG_CONFIG_HOME, "testflinger-cli.conf") + config.read(configfile) + # Default empty config in case there's no config file + self.data = OrderedDict() + if 'testflinger-cli' in config.sections(): + self.data = OrderedDict(config['testflinger-cli']) + self.configfile = configfile + + def get(self, key): + return self.data.get(key) + + def set(self, key, value): + self.data[key] = value + self._save() + + def _save(self): + config = configparser.ConfigParser() + config.read_dict({'testflinger-cli': self.data}) + with open(self.configfile, 'w') as f: + config.write(f) From f51d9b0f756e3fa99db26986d8890329b979a733 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 15 Sep 2020 08:41:58 -0500 Subject: [PATCH 313/569] Add jobs subcommand to show recent job history --- testflinger_cli/__init__.py | 50 +++++++++++++++++++++++++++++++- testflinger_cli/history.py | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 testflinger_cli/history.py diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index e647e401..5f7a1418 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -20,9 +20,11 @@ import os import sys import time +import yaml from argparse import ArgumentParser -from testflinger_cli import (client, config) +from datetime import datetime +from testflinger_cli import (client, config, history) # Make it easier to run from a checkout @@ -56,6 +58,7 @@ def __init__(self): raise SystemExit('Server must start with "http://" or "https://" ' '- currently set to: "{}"'.format(server)) self.client = client.Client(server) + self.history = history.TestflingerCliHistory() def run(self): if hasattr(self.args, 'func'): @@ -83,6 +86,13 @@ def get_args(self): 'config', help='Get or set configuration options') arg_config.set_defaults(func=self.configure) arg_config.add_argument('setting', nargs='?', help='setting=value') + arg_jobs = sub.add_parser( + 'jobs', + help='List the previously started test jobs' + ) + arg_jobs.set_defaults(func=self.jobs) + arg_jobs.add_argument('--status', '-s', action='store_true', + help='Include job status (may add delay)') arg_list_queues = sub.add_parser( 'list-queues', help='List the advertised queues on the Testflinger server') @@ -130,6 +140,7 @@ def status(self): """Show the status of a specified JOB_ID""" try: job_state = self.client.get_status(self.args.job_id) + self.history.update(self.args.job_id, job_state) except client.HTTPError as e: if e.status == 204: raise SystemExit('No data found for that job id. Check the ' @@ -149,6 +160,7 @@ def cancel(self): """Tell the server to cancel a specified JOB_ID""" try: job_state = self.client.get_status(self.args.job_id) + self.history.update(self.args.job_id, job_state) except client.HTTPError as e: if e.status == 204: raise SystemExit('Job {} not found. Check the job ' @@ -167,6 +179,7 @@ def cancel(self): raise SystemExit('Job {} is already in {} state and cannot be ' 'cancelled.'.format(self.args.job_id, job_state)) self.client.post_job_state(self.args.job_id, 'cancelled') + self.history.update(self.args.job_id, 'cancelled') def configure(self): if self.args.setting: @@ -199,6 +212,8 @@ def submit(self): raise SystemExit( 'Unable to read file: {}'.format(self.args.filename)) job_id = self.submit_job_data(data) + queue = yaml.safe_load(data).get('job_queue') + self.history.new(job_id, queue) if self.args.quiet: print(job_id) else: @@ -301,6 +316,7 @@ def poll(self): def do_poll(self, job_id): job_state = self.get_job_state(job_id) + self.history.update(job_id, job_state) if job_state == 'waiting': print('This job is waiting on a node to become available.') prev_queue_pos = None @@ -325,8 +341,40 @@ def do_poll(self, job_id): if output: print(output, end='', flush=True) job_state = self.get_job_state(job_id) + self.history.update(job_id, job_state) print(job_state) + def jobs(self): + """List the previously started test jobs""" + if self.args.status: + # Getting job state may be slow, only include if requested + status_text = 'Status' + else: + status_text = '' + print('{:36} {:9} {} {}'.format( + 'Job ID', + status_text, + 'Submission Time', + 'Queue' + )) + print('-'*79) + for job_id, jobdata in self.history.history.items(): + if self.args.status: + job_state = jobdata.get('job_state') + if job_state not in ('cancelled', 'complete'): + job_state = self.get_job_state(job_id) + self.history.update(job_id, job_state) + else: + job_state = '' + print('{} {:9} {} {}'.format( + job_id, + job_state, + datetime.fromtimestamp( + jobdata.get('submission_time')).strftime('%a %b %m %H:%M'), + jobdata.get('queue') + )) + print() + def list_queues(self): """List the advertised queues on the current Testflinger server""" try: diff --git a/testflinger_cli/history.py b/testflinger_cli/history.py new file mode 100644 index 00000000..9df3453f --- /dev/null +++ b/testflinger_cli/history.py @@ -0,0 +1,57 @@ +# Copyright (C) 2020 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import json +import os +import xdg +from collections import OrderedDict +from datetime import datetime + + +class TestflingerCliHistory: + def __init__(self): + self.historyfile = os.path.join( + xdg.XDG_DATA_HOME, "testflinger-cli-history.json") + self.load() + + def new(self, job_id, queue): + submission_time = datetime.now().timestamp() + self.history[job_id] = dict( + queue=queue, + submission_time=submission_time, + job_state='unknown' + ) + self.save() + + def load(self): + if not hasattr(self, 'history'): + self.history = OrderedDict() + if os.path.exists(self.historyfile): + with open(self.historyfile) as f: + try: + self.history.update(json.load(f)) + except Exception: + # If there's any error loading the history, ignore it + return + + def save(self): + with open(self.historyfile, 'w') as f: + json.dump(self.history, f, indent=2) + + def update(self, job_id, state): + if job_id in self.history: + self.history[job_id]['job_state'] = state + self.save() From dbcccbd09f2ca58c88c424eba4f00117c0f00168 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 15 Sep 2020 10:32:23 -0500 Subject: [PATCH 314/569] Only keep the last 10 jobs in history for now --- testflinger_cli/history.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testflinger_cli/history.py b/testflinger_cli/history.py index 9df3453f..86b64caa 100644 --- a/testflinger_cli/history.py +++ b/testflinger_cli/history.py @@ -34,6 +34,9 @@ def new(self, job_id, queue): submission_time=submission_time, job_state='unknown' ) + # limit job history to last 10 jobs + if len(self.history) > 10: + self.history.popitem(last=False) self.save() def load(self): From c87290caa3c8c3688738b90c3c2465b971db07e0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 16 Sep 2020 08:10:12 -0500 Subject: [PATCH 315/569] Make config and history directories if they do not exist --- testflinger_cli/config.py | 1 + testflinger_cli/history.py | 1 + 2 files changed, 2 insertions(+) diff --git a/testflinger_cli/config.py b/testflinger_cli/config.py index 983699d3..c45f5b04 100644 --- a/testflinger_cli/config.py +++ b/testflinger_cli/config.py @@ -24,6 +24,7 @@ class TestflingerCliConfig: def __init__(self, configfile=None): config = configparser.ConfigParser() if not configfile: + os.makedirs(xdg.XDG_CONFIG_HOME, exist_ok=True) configfile = os.path.join( xdg.XDG_CONFIG_HOME, "testflinger-cli.conf") config.read(configfile) diff --git a/testflinger_cli/history.py b/testflinger_cli/history.py index 86b64caa..1d57d882 100644 --- a/testflinger_cli/history.py +++ b/testflinger_cli/history.py @@ -23,6 +23,7 @@ class TestflingerCliHistory: def __init__(self): + os.makedirs(xdg.XDG_DATA_HOME, exist_ok=True) self.historyfile = os.path.join( xdg.XDG_DATA_HOME, "testflinger-cli-history.json") self.load() From 367634caef3cde9025ab3bbb14b15563c14f2146 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 18 Sep 2020 15:02:18 -0500 Subject: [PATCH 316/569] opportunistic update to .gitignore --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c26700c5..da344c16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ +*~ +.coverage +.vscode/ +build/ +env/ __pycache__/ *.py[cod] *$py.class -env/ *.conf *.egg* +*.bak +.swp From b09429008e0171e80528db689cf0ce559e63aea6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 18 Sep 2020 15:22:53 -0500 Subject: [PATCH 317/569] Handle offline files with or without dashes --- testflinger_agent/__init__.py | 6 +++--- testflinger_agent/agent.py | 30 +++++++++++++++++++----------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 230cffe0..8cdf7ea0 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -34,11 +34,11 @@ def main(): client = TestflingerClient(config) agent = TestflingerAgent(client) while True: - if agent.check_offline(): + offline_file = agent.check_offline + if offline_file: logger.error("Agent %s is offline, not processing jobs! " "Remove %s to resume processing" % - (config.get('agent_id'), - agent.get_offline_file())) + (config.get('agent_id'), offline_file)) while agent.check_offline(): time.sleep(check_interval) logger.info("Checking jobs") diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index f989a6f2..659df634 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -52,10 +52,17 @@ def _status_worker(self): def set_state(self, state): self._state.value = state.encode('utf-8') - def get_offline_file(self): - return os.path.join( - '/tmp', 'TESTFLINGER-DEVICE-OFFLINE-{}'.format( - self.client.config.get('agent_id'))) + def get_offline_files(self): + # Return possible restart filenames with and without dashes + # i.e. support both: + # TESTFLINGER-DEVICE-OFFLINE-devname-001 + # TESTFLINGER-DEVICE-OFFLINE-devname001 + agent = self.client.config.get('agent_id') + files = [ + '/tmp/TESTFLINGER-DEVICE-OFFLINE-{}'.format(agent), + '/tmp/TESTFLINGER-DEVICE-OFFLINE-{}'.format(agent.replace('-', '')) + ] + return files def get_restart_files(self): # Return possible restart filenames with and without dashes @@ -70,12 +77,13 @@ def get_restart_files(self): return files def check_offline(self): - if os.path.exists(self.get_offline_file()): - self.set_state('offline') - return True - else: - self.set_state('waiting') - return False + possible_files = self.get_offline_files() + for offline_file in possible_files: + if os.path.exists(offline_file): + self.set_state('offline') + return offline_file + self.set_state('waiting') + return '' def check_restart(self): possible_files = self.get_restart_files() @@ -98,7 +106,7 @@ def check_job_state(self, job_id): def mark_device_offline(self): # Create the offline file, this should work even if it exists - open(self.get_offline_file(), 'w').close() + open(self.get_offline_files()[0], 'w').close() def process_jobs(self): """Coordinate checking for new jobs and handling them if they exists""" From c28be47a5b21408d504f9d21e23fdcd6117da261 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 21 Sep 2020 08:31:31 -0500 Subject: [PATCH 318/569] Fix testcase for check_offline --- testflinger_agent/tests/test_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 72cbfb36..496dbe30 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -166,7 +166,7 @@ def test_recovery_failed(self, agent, requests_mock): text='{}') m.post('http://127.0.0.1:8000/v1/job', json={'job_id': job_id}) agent.process_jobs() - assert(agent.check_offline() is True) + assert(agent.check_offline()) # These are the args we would expect when it reposts the job assert(m.last_request.json() == fake_job_data) if os.path.exists(OFFLINE_FILE): From 1b73e6ff203864b8d3999e94026d91442b588319 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 6 Oct 2020 12:07:14 -0500 Subject: [PATCH 319/569] Fix stupid typo when getting the offline filename --- testflinger_agent/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 8cdf7ea0..b03e70a1 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -34,7 +34,7 @@ def main(): client = TestflingerClient(config) agent = TestflingerAgent(client) while True: - offline_file = agent.check_offline + offline_file = agent.check_offline() if offline_file: logger.error("Agent %s is offline, not processing jobs! " "Remove %s to resume processing" % From ab27d5c224ba72296c7248a7ecd00ef25b07ad5f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 19 Oct 2020 17:07:26 -0500 Subject: [PATCH 320/569] Mount all partitions when provisioning muxpi --- devices/muxpi/muxpi.py | 73 +++++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 81b5a512..509bab28 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -36,9 +36,9 @@ class MuxPi: """Device Agent for MuxPi.""" - IMAGE_PATH_IDS = {'etc': 'ubuntu', - 'system-data': 'core', - 'snaps': 'core20'} + IMAGE_PATH_IDS = {'writable/etc': 'ubuntu', + 'writable/system-data': 'core', + 'ubuntu-seed/snaps': 'core20'} def __init__(self, config, job_data): with open(config) as configfile: @@ -96,8 +96,8 @@ def provision(self): try: self.flash_test_image(server_ip, server_port) file_server.terminate() - image_type, image_dev = self.get_image_type() - with self.remote_mount(image_dev): + with self.remote_mount(): + image_type = self.get_image_type() logger.info("Creating Test User") self.create_user(image_type) self.run_post_provision_script() @@ -147,15 +147,30 @@ def flash_test_image(self, server_ip, server_port): "partitions") @contextmanager - def remote_mount(self, remote_device): - self._run_control( - 'sudo mkdir -p {}'.format(self.mount_point)) - self._run_control( - 'sudo mount /dev/{} {}'.format(remote_device, self.mount_point)) + def remote_mount(self): + test_device = self.config['test_device'] + lsblk_data = self._run_control( + 'lsblk -o NAME,LABEL -J {}'.format(test_device)) + lsblk_json = json.loads(lsblk_data.decode()) + # List of (name, label) pairs + mount_list = [(x.get('name'), + os.path.join(self.mount_point, x.get('label'))) + for x in lsblk_json['blockdevices'][0]['children'] + if x.get('name') and x.get('label')] + for dev, mount in mount_list: + try: + self._run_control('sudo mkdir -p {}'.format(mount)) + self._run_control( + 'sudo mount /dev/{} {}'.format(dev, mount)) + except Exception: + # If unmountable or any other error, go on to the next one + mount_list.remove((dev, mount)) + continue try: yield self.mount_point finally: - self._run_control('sudo umount {}'.format(self.mount_point)) + for _, mount in mount_list: + self._run_control('sudo umount {}'.format(mount)) def hardreset(self): """ @@ -180,26 +195,23 @@ def get_image_type(self): Figure out which kind of image is on the configured block device :returns: - tuple of image type and device as strings + image type as a string """ - dev = self.config['test_device'] - lsblk_data = self._run_control('lsblk -J {}'.format(dev)) - lsblk_json = json.loads(lsblk_data.decode()) - dev_list = [x.get('name') - for x in lsblk_json['blockdevices'][0]['children'] - if x.get('name')] - for dev in dev_list: + def check_path(dir): + self._run_control('test -e {}'.format(dir)) + + for path, img_type in self.IMAGE_PATH_IDS.items(): try: - with self.remote_mount(dev): - dirs = self._run_control('ls {}'.format(self.mount_point)) - for path, img_type in self.IMAGE_PATH_IDS.items(): - if path in dirs.decode().split(): - return img_type, dev + path = os.path.join(self.mount_point, path) + check_path(path) + logger.info( + "Image type detected: {}".format(img_type)) + return img_type except Exception: - # If unmountable or any other error, go on to the next one + # Path was not found, continue trying others continue # We have no idea what kind of image this is - return 'unknown', dev + return 'unknown' def unmount_writable_partition(self): try: @@ -239,10 +251,9 @@ def create_user(self, image_type): ' instance_id: cloud-image') base = self.mount_point - if image_type == 'core': - base = os.path.join(base, 'system-data') try: if image_type == 'core20': + base = os.path.join(self.mount_point, 'ubuntu-seed') ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') self._run_control('sudo mkdir -p {}'.format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" @@ -250,8 +261,10 @@ def create_user(self, image_type): write_cmd.format(uc20_ci_data, ci_path, '99_nocloud.cfg')) else: # For core or ubuntu classic images - ci_path = os.path.join( - base, 'var/lib/cloud/seed/nocloud-net') + base = os.path.join(self.mount_point, 'writable') + if image_type == 'core': + base = os.path.join(base, 'system-data') + ci_path = os.path.join(base, 'var/lib/cloud/seed/nocloud-net') self._run_control('sudo mkdir -p {}'.format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( From 3163f4d1b063a83afbe085b8accf48f90ba166ff Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 26 Oct 2020 07:35:43 -0500 Subject: [PATCH 321/569] Add support for pi-desktop --- data/pi-desktop/oem-config.service | 26 +++++++ data/pi-desktop/preseed.cfg | 105 +++++++++++++++++++++++++++++ devices/muxpi/muxpi.py | 66 +++++++++++++++++- 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 data/pi-desktop/oem-config.service create mode 100644 data/pi-desktop/preseed.cfg diff --git a/data/pi-desktop/oem-config.service b/data/pi-desktop/oem-config.service new file mode 100644 index 00000000..ca83e860 --- /dev/null +++ b/data/pi-desktop/oem-config.service @@ -0,0 +1,26 @@ +[Unit] +Description=End-user configuration after initial OEM installation +ConditionFileIsExecutable=/usr/sbin/oem-config-firstboot +ConditionPathExists=/dev/tty1 + +# We never want to run the oem-config job in the live environment (as is the +# case in some custom configurations) or in recovery mode. +ConditionKernelCommandLine=!boot=casper +ConditionKernelCommandLine=!single +ConditionKernelCommandLine=!rescue +ConditionKernelCommandLine=!emergency + +[Service] +Type=oneshot +StandardInput=tty +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +ExecStart=/bin/sh -ec '\ + debconf-set-selections /preseed.cfg; \ + exec oem-config-firstboot --automatic' + +[Install] +WantedBy=oem-config.target diff --git a/data/pi-desktop/preseed.cfg b/data/pi-desktop/preseed.cfg new file mode 100644 index 00000000..45b8ed4a --- /dev/null +++ b/data/pi-desktop/preseed.cfg @@ -0,0 +1,105 @@ +#### Contents of the preconfiguration file (for groovy) +### Localization +# Preseeding only locale sets language, country and locale. +d-i localechooser/languagelist select en +d-i debian-installer/locale string en_US.UTF-8 + +# The values can also be preseeded individually for greater flexibility. +#d-i debian-installer/language string en +#d-i debian-installer/country string NL +#d-i debian-installer/locale string en_GB.UTF-8 +# Optionally specify additional locales to be generated. +#d-i localechooser/supported-locales multiselect en_US.UTF-8, nl_NL.UTF-8 + +# Keyboard selection. +# Disable automatic (interactive) keymap detection. +d-i console-setup/ask_detect boolean false +d-i keyboard-configuration/xkb-keymap select us +# To select a variant of the selected layout: +#d-i keyboard-configuration/xkb-keymap select us(dvorak) +# d-i keyboard-configuration/toggle select No toggling + +# netcfg will choose an interface that has link if possible. This makes it +# skip displaying a list if there is more than one interface. +d-i netcfg/choose_interface select auto +# Any hostname and domain names assigned from dhcp take precedence over +# values set here. However, setting the values still prevents the questions +# from being shown, even if values come from dhcp. +d-i netcfg/get_hostname string unassigned-hostname +d-i netcfg/get_domain string unassigned-domain +# Disable that annoying WEP key dialog. +d-i netcfg/wireless_wep string +# The wacky dhcp hostname that some ISPs use as a password of sorts. +#d-i netcfg/dhcp_hostname string radish + +### Mirror settings +# If you select ftp, the mirror/country string does not need to be set. +#d-i mirror/protocol string ftp +d-i mirror/country string manual +d-i mirror/http/hostname string archive.ubuntu.com +d-i mirror/http/directory string /ubuntu +d-i mirror/http/proxy string + + +# Set to true if you want to encrypt the first user's home directory. +d-i user-setup/encrypt-home boolean false + +### Clock and time zone setup +# Controls whether or not the hardware clock is set to UTC. +d-i clock-setup/utc boolean true + +# You may set this to any valid setting for $TZ; see the contents of +# /usr/share/zoneinfo/ for valid values. +d-i time/zone string US/Eastern + +# Controls whether to use NTP to set the clock during the install +d-i clock-setup/ntp boolean true +# NTP server to use. The default is almost always fine here. +#d-i clock-setup/ntp-server string ntp.example.com + +### Package selection +tasksel tasksel/first multiselect ubuntu-desktop +#tasksel tasksel/first multiselect lamp-server, print-server +#tasksel tasksel/first multiselect kubuntu-desktop + +# Avoid that last message about the install being complete. +d-i finish-install/reboot_in_progress note + +d-i netcfg/get_hostname string ubuntu +d-i mirror/http/hostname string archive.ubuntu.com +d-i passwd/auto-login boolean true +d-i passwd/user-fullname string Ubuntu User +d-i passwd/username string ubuntu +d-i passwd/user-password password ubuntu +d-i passwd/user-password-again password ubuntu +d-i user-setup/allow-password-weak boolean true +d-i debian-installer/allow_unauthenticated boolean true +d-i preseed/late_command string \ + systemctl start NetworkManager; \ + apt-get update; \ + apt-get install -y --force-yes ssh; \ + apt-get clean + +d-i console-setup/ask_detect boolean false +d-i console-setup/layoutcode string us +d-i debian-installer/locale string en_US +d-i keyboard-configuration/ask_detect boolean false +d-i keyboard-configuration/layoutcode string us +d-i keyboard-configuration/xkb-keymap select us +ubiquity countrychooser/shortlist select US +ubiquity languagechooser/language-name select English +ubiquity localechooser/supported-locales multiselect en_US.UTF-8 + +ubiquity ubiquity/summary note +ubiquity ubiquity/reboot boolean true +ubiquity ubiquity/poweroff boolean true +ubiquity ubiquity/success_command string \ + systemctl start NetworkManager; \ + apt-get update; \ + apt-get install -y --force-yes ssh; \ + apt-get clean + +# Enable extras.ubuntu.com. +d-i apt-setup/extras boolean true +# Install the Ubuntu desktop. +tasksel tasksel/first multiselect ubuntu-desktop diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 509bab28..17fb1b70 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -36,7 +36,8 @@ class MuxPi: """Device Agent for MuxPi.""" - IMAGE_PATH_IDS = {'writable/etc': 'ubuntu', + IMAGE_PATH_IDS = {'writable/usr/bin/firefox': 'pi-desktop', + 'writable/etc': 'ubuntu', 'writable/system-data': 'core', 'ubuntu-seed/snaps': 'core20'} @@ -72,6 +73,28 @@ def _run_control(self, cmd, timeout=60): raise ProvisioningError(e.output) return output + def _copy_to_control(self, local_file, remote_file): + """ + Copy a file to the control host over ssh + + :param local_file: + Local filename + :param remote_file: + Remote filename + """ + control_host = self.config.get('control_host') + control_user = self.config.get('control_user', 'ubuntu') + ssh_cmd = ['scp', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + local_file, + '{}@{}:{}'.format(control_user, control_host, remote_file)] + try: + output = subprocess.check_output( + ssh_cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + raise ProvisioningError(e.output) + return output + def provision(self): try: url = self.job_data['provision_data']['url'] @@ -252,6 +275,47 @@ def create_user(self, image_type): base = self.mount_point try: + if image_type == 'pi-desktop': + # make a spot to scp files to + remote_tmp = os.path.join('/tmp', self.agent_name) + self._run_control('mkdir -p {}'.format(remote_tmp)) + + data_path = os.path.join(os.path.dirname(__file__), + '../../data/pi-desktop') + + # Override oem-config so that it uses the preseed + self._copy_to_control( + os.path.join(data_path, 'oem-config.service'), remote_tmp) + cmd = ( + 'sudo cp {}/oem-config.service ' + '{}/writable/lib/systemd/system/' + 'oem-config.service'.format(remote_tmp, self.mount_point) + ) + self._run_control(cmd) + + # Copy the preseed + self._copy_to_control(os.path.join(data_path, 'preseed.cfg'), + remote_tmp) + cmd = 'sudo cp {}/preseed.cfg {}/writable/preseed.cfg'.format( + remote_tmp, self.mount_point) + self._run_control(cmd) + + # Make sure NetworkManager is started + cmd = ('sudo cp -a ' + '{}/writable/etc/systemd/system/multi-user.target.wants' + '/NetworkManager.service ' + '{}/writable/etc/systemd/system/' + 'oem-config.target.wants'.format(self.mount_point, + self.mount_point)) + self._run_control(cmd) + + # Setup sudoers data + sudo_data = 'ubuntu ALL=(ALL) NOPASSWD:ALL' + sudo_path = '{}/writable/etc/sudoers.d/ubuntu'.format( + self.mount_point) + self._run_control('sudo bash -c "echo \'{}\' > {}"'.format( + sudo_data, sudo_path)) + return if image_type == 'core20': base = os.path.join(self.mount_point, 'ubuntu-seed') ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') From ff92133e4a1f2a9cdab13ac71c55cd6bd2b67f7d Mon Sep 17 00:00:00 2001 From: Lukas Waymann Date: Mon, 2 Nov 2020 16:57:14 +0800 Subject: [PATCH 322/569] Fix `testflinger-cli jobs` giving incorrect submission dates Use "%d" instead of "%m". The latter is the month as a number whereas the former is the day of the month as a number. Before: $ testflinger-cli jobs Job ID Submission Time Queue ------------------------------------------------------------------------------- 7e6ed931-a3aa-4aa9-8d4c-fbe96e3e1a17 Mon Nov 11 16:45 cert-rpi4b4g-cs Note that both "Nov" and "11" refer to the month here. After: $ testflinger-cli jobs Job ID Submission Time Queue ------------------------------------------------------------------------------- 7e6ed931-a3aa-4aa9-8d4c-fbe96e3e1a17 Mon Nov 02 16:45 cert-rpi4b4g-cs https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes --- testflinger_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 5f7a1418..fee80906 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -370,7 +370,7 @@ def jobs(self): job_id, job_state, datetime.fromtimestamp( - jobdata.get('submission_time')).strftime('%a %b %m %H:%M'), + jobdata.get('submission_time')).strftime('%a %b %d %H:%M'), jobdata.get('queue') )) print() From 3b2684b58673b225d8b7bb3f79f675424d085c52 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 2 Nov 2020 22:33:07 -0600 Subject: [PATCH 323/569] retry the debconf-set-selections for preseeding pi-desktop --- data/pi-desktop/oem-config.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/pi-desktop/oem-config.service b/data/pi-desktop/oem-config.service index ca83e860..758caa5a 100644 --- a/data/pi-desktop/oem-config.service +++ b/data/pi-desktop/oem-config.service @@ -19,7 +19,7 @@ TTYPath=/dev/tty1 TTYReset=yes TTYVHangup=yes ExecStart=/bin/sh -ec '\ - debconf-set-selections /preseed.cfg; \ + while ! debconf-set-selections /preseed.cfg; do sleep 30;done; \ exec oem-config-firstboot --automatic' [Install] From 2566caf6ecfd093a8272b338690361ee4b37cbb7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 18 Nov 2020 10:49:57 -0600 Subject: [PATCH 324/569] Extend timeout for checking that the image booted on muxpi to 20min --- devices/muxpi/muxpi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 17fb1b70..a8ee6670 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -353,7 +353,7 @@ def check_test_image_booted(self): 'test_data', {}).get('test_username', 'ubuntu') test_password = self.job_data.get( 'test_data', {}).get('test_password', 'ubuntu') - while time.time() - started < 600: + while time.time() - started < 1200: try: time.sleep(10) cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', From 56aabbef07990357b8afba7728cf7d2ca5c6a39b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 19 Nov 2020 16:19:44 -0600 Subject: [PATCH 325/569] Allow specitying a url instead of an image name in 'testflinger reserve' --- testflinger_cli/__init__.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index fee80906..0bd12e7f 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -407,11 +407,15 @@ def reserve(self): print("WARNING: unable to get a list of images from the server!") images = {} image = self.args.image or self._get_image(images) - if image not in images.keys(): + if (not image.startswith(("http://", "https://")) and + image not in images.keys()): raise SystemExit("ERROR: '{}' is not in the list of known " "images for that queue, please select " "another.".format(image)) - image = images[image] + if image.startswith(("http://", "https://")): + image = "url: " + image + else: + image = images[image] ssh_keys = self.args.key or self._get_ssh_keys() for ssh_key in ssh_keys: if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): @@ -455,13 +459,18 @@ def _get_queue(self, queues): def _get_image(self, images): image = "" + flex_url = "" + if images and images[list(images.keys())[0]].startswith('url:'): + flex_url = "or URL for a valid image starting with http(s)://... " while not image or image == "?": - image = input("\nEnter the name of the image you want to use " - "('?' to list) ") + image = input("\nEnter the name of the image you want to use " + + flex_url + "('?' to list) ") if image == "?": for image_id in sorted(images.keys()): print(" " + image_id) continue + if image.startswith(('http://', 'https://')): + return image if image not in images.keys(): print("ERROR: '{}' is not in the list of known images for " "that queue, please select another.".format(image)) From 32b046b781ceb1c9e7113b4dec7635e5e4cd08b2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Nov 2020 13:12:08 -0600 Subject: [PATCH 326/569] Quick comment for the previous change to allow custom URLs --- testflinger_cli/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 0bd12e7f..1b353884 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -461,6 +461,8 @@ def _get_image(self, images): image = "" flex_url = "" if images and images[list(images.keys())[0]].startswith('url:'): + # If this device can take URLs, offer to let the user enter one + # instead of just using the known images flex_url = "or URL for a valid image starting with http(s)://... " while not image or image == "?": image = input("\nEnter the name of the image you want to use " + From a2ec8186917cfce50803767a22aa69dd8c85c24c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Nov 2020 13:58:26 -0600 Subject: [PATCH 327/569] Offer to cancel the job when detecting KeyboardInterrupt from poll --- testflinger_cli/__init__.py | 54 ++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 1b353884..862f51da 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -178,8 +178,11 @@ def cancel(self): if job_state in ('complete', 'cancelled'): raise SystemExit('Job {} is already in {} state and cannot be ' 'cancelled.'.format(self.args.job_id, job_state)) - self.client.post_job_state(self.args.job_id, 'cancelled') - self.history.update(self.args.job_id, 'cancelled') + self.do_cancel(self.args.job_id) + + def do_cancel(self, job_id): + self.client.post_job_state(job_id, 'cancelled') + self.history.update(job_id, 'cancelled') def configure(self): if self.args.setting: @@ -323,25 +326,38 @@ def do_poll(self, job_id): while job_state != 'complete': if job_state == 'cancelled': break - if job_state == 'waiting': + try: + if job_state == 'waiting': + try: + queue_pos = self.client.get_job_position(job_id) + if int(queue_pos) != prev_queue_pos: + prev_queue_pos = int(queue_pos) + print('Jobs ahead in queue: {}'.format(queue_pos)) + except Exception: + # Ignore any bad response, this will retry + pass + time.sleep(10) + output = '' try: - queue_pos = self.client.get_job_position(job_id) - if int(queue_pos) != prev_queue_pos: - prev_queue_pos = int(queue_pos) - print('Jobs ahead in queue: {}'.format(queue_pos)) + output = self.get_latest_output(job_id) except Exception: - # Ignore any bad response, this will retry - pass - time.sleep(10) - output = '' - try: - output = self.get_latest_output(job_id) - except Exception: - continue - if output: - print(output, end='', flush=True) - job_state = self.get_job_state(job_id) - self.history.update(job_id, job_state) + continue + if output: + print(output, end='', flush=True) + job_state = self.get_job_state(job_id) + self.history.update(job_id, job_state) + except KeyboardInterrupt: + choice = input('\nCancel job {} before exiting ' + '(y)es/(N)o/(c)ontinue? '.format(job_id)) + if choice: + choice = choice[0].lower() + if choice == 'c': + continue + if choice == 'y': + self.do_cancel(job_id) + # Both y and n will allow the external handler deal with it + raise + print(job_state) def jobs(self): From 7fe82578e487508991004a992063049da09eb9ba Mon Sep 17 00:00:00 2001 From: Dimitri John Ledkov Date: Mon, 30 Nov 2020 17:55:06 +0000 Subject: [PATCH 328/569] compress_file: add support for QCOW2 Signed-off-by: Dimitri John Ledkov --- snappy_device_agents/__init__.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 0455ccb6..36844f79 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -55,7 +55,9 @@ def filetype(filename): magic_headers = { b"\x1f\x8b\x08": "gz", b"\x42\x5a\x68": "bz2", - b"\xfd\x37\x7a\x58\x5a\x00": "xz"} + b"\xfd\x37\x7a\x58\x5a\x00": "xz", + b"\x51\x46\x49\xfb": "qcow2", + } with open(filename, 'rb') as f: filehead = f.read(1024) filetype = "unknown" @@ -268,6 +270,23 @@ def compress_file(filename): with lzma.open(compressed_filename, 'wb') as compressed_image: with bz2.BZ2File(filename, 'rb') as old_compressed: shutil.copyfileobj(old_compressed, compressed_image) + elif filetype(filename) == 'qcow2': + raw_filename = '{}.raw'.format(filename) + try: + # Remove the original file, unless we already did + os.unlink(raw_filename) + except FileNotFoundError: + pass + cmd = ['qemu-img', 'convert', '-O', 'raw', filename, raw_filename] + try: + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + logger.error('Image Conversion Output:\n %s', e.output) + raise + with open(raw_filename, 'rb') as uncompressed_image: + with lzma.open(compressed_filename, 'wb') as compressed_image: + shutil.copyfileobj(uncompressed_image, compressed_image) + os.unlink(raw_filename) else: # filetype is 'unknown' so assumed to be raw image with open(filename, 'rb') as uncompressed_image: From 6eee7c578ffc41c4e6325d8b7a8ed59677d00a3c Mon Sep 17 00:00:00 2001 From: Dimitri John Ledkov Date: Tue, 1 Dec 2020 16:15:45 +0000 Subject: [PATCH 329/569] muxpi: handle ubuntu-cpc images Signed-off-by: Dimitri John Ledkov --- devices/muxpi/muxpi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index a8ee6670..5cf49f19 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -39,7 +39,8 @@ class MuxPi: IMAGE_PATH_IDS = {'writable/usr/bin/firefox': 'pi-desktop', 'writable/etc': 'ubuntu', 'writable/system-data': 'core', - 'ubuntu-seed/snaps': 'core20'} + 'ubuntu-seed/snaps': 'core20', + 'cloudimg-rootfs/etc/cloud/cloud.cfg': 'ubuntu-cpc'} def __init__(self, config, job_data): with open(config) as configfile: @@ -328,6 +329,8 @@ def create_user(self, image_type): base = os.path.join(self.mount_point, 'writable') if image_type == 'core': base = os.path.join(base, 'system-data') + if image_type == 'ubuntu-cpc': + base = os.path.join(self.mount_point, 'cloudimg-rootfs') ci_path = os.path.join(base, 'var/lib/cloud/seed/nocloud-net') self._run_control('sudo mkdir -p {}'.format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" From 7521e14e19bf48f1b23a31e275aeec8d2b5ea7b8 Mon Sep 17 00:00:00 2001 From: Dimitri John Ledkov Date: Tue, 1 Dec 2020 16:15:45 +0000 Subject: [PATCH 330/569] muxpi: handle ubuntu-cpc images Signed-off-by: Dimitri John Ledkov --- devices/muxpi/muxpi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index a8ee6670..5cf49f19 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -39,7 +39,8 @@ class MuxPi: IMAGE_PATH_IDS = {'writable/usr/bin/firefox': 'pi-desktop', 'writable/etc': 'ubuntu', 'writable/system-data': 'core', - 'ubuntu-seed/snaps': 'core20'} + 'ubuntu-seed/snaps': 'core20', + 'cloudimg-rootfs/etc/cloud/cloud.cfg': 'ubuntu-cpc'} def __init__(self, config, job_data): with open(config) as configfile: @@ -328,6 +329,8 @@ def create_user(self, image_type): base = os.path.join(self.mount_point, 'writable') if image_type == 'core': base = os.path.join(base, 'system-data') + if image_type == 'ubuntu-cpc': + base = os.path.join(self.mount_point, 'cloudimg-rootfs') ci_path = os.path.join(base, 'var/lib/cloud/seed/nocloud-net') self._run_control('sudo mkdir -p {}'.format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" From 9954a463c26c04e72dde90c72b87f28425e955c0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 4 Jan 2021 14:58:44 -0600 Subject: [PATCH 331/569] Wait before reading process status --- testflinger_agent/job.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 7c0cd738..b76dfdc6 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -187,7 +187,11 @@ def cleanup(signum, frame): f.write(buf) if live_output_buffer: self.client.post_live_output(self.job_id, live_output_buffer) - return process.returncode + try: + status = process.wait(10) # process.returncode + except TimeoutError: + status = 99 # Default in case something goes wrong + return status def get_global_timeout(self): """Get the global timeout for the test run in seconds From 320bbff5b8f4524b9c6f5a760b8c3175562ceed4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 5 Jan 2021 14:50:52 -0600 Subject: [PATCH 332/569] Minor update to .pmr-merge-script because some newer virtualenv versions complain --- .pmr-merge-hook | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pmr-merge-hook b/.pmr-merge-hook index c1ce1eeb..394df162 100755 --- a/.pmr-merge-hook +++ b/.pmr-merge-hook @@ -3,6 +3,6 @@ set -e rm -rf tfenv -virtualenv -qp python3 tfenv +virtualenv -q -p python3 tfenv . tfenv/bin/activate ./setup.py test From 62f582c11bbd9c940f0f1ad7acb5f99953044cc8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 11 Jan 2021 08:55:06 -0600 Subject: [PATCH 333/569] Warn the user if there are no known images for reservation, and a small fix for a possible undefined var. --- testflinger_cli/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 862f51da..e7d43329 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -320,9 +320,9 @@ def poll(self): def do_poll(self, job_id): job_state = self.get_job_state(job_id) self.history.update(job_id, job_state) + prev_queue_pos = None if job_state == 'waiting': print('This job is waiting on a node to become available.') - prev_queue_pos = None while job_state != 'complete': if job_state == 'cancelled': break @@ -484,6 +484,11 @@ def _get_image(self, images): image = input("\nEnter the name of the image you want to use " + flex_url + "('?' to list) ") if image == "?": + if not images: + print("WARNING: There are no images defined for this " + "device. You may also provide the URL to an image " + "that can be booted with this device though.") + continue for image_id in sorted(images.keys()): print(" " + image_id) continue From a48373b49c18c3af7268764a1f6797774957cf50 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 28 Apr 2021 10:30:13 -0500 Subject: [PATCH 334/569] Fix a possible unassigned value in netboot device agent --- devices/netboot/netboot.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 27c4d7e8..b9963e3f 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -219,11 +219,10 @@ def flash_test_image(self, server_ip, server_port): try: # XXX: I hope 30 min is enough? but maybe not! req = urllib.request.urlopen(url, timeout=1800) - except Exception: - raise ProvisioningError("Error while flashing image!") - finally: logger.info("Image write output:") logger.info(str(req.read())) + except Exception: + raise ProvisioningError("Error while flashing image!") # Run post-flash hooks post_flash_cmds = self.config.get('post_flash_cmds') From 06e3dd30bdda58e39ff45789c5832a4f1fe55e92 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 21 May 2021 14:29:17 -0500 Subject: [PATCH 335/569] Handle possible situation where /dev/sda is not a block device --- devices/cm3/cm3.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index 186fd0b5..fbc3c7cd 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -74,6 +74,8 @@ def provision(self): raise ProvisioningError('You must specify a "url" value in ' 'the "provision_data" section of ' 'your job_data') + # Remove /dev/sda if somehow it's a normal file + self._run_control('test -f /dev/sda && sudo rm -f /dev/sda') self._run_control('sudo pi3gpio set high 16') time.sleep(5) self.hardreset() From bae72fec2229dffad963f42e27687a9dc9822dbf Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 27 May 2021 22:12:33 -0500 Subject: [PATCH 336/569] Improved fix for the rogue /dev/sda file problem on cm3 --- devices/cm3/cm3.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index fbc3c7cd..d0d1a72d 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -75,7 +75,12 @@ def provision(self): 'the "provision_data" section of ' 'your job_data') # Remove /dev/sda if somehow it's a normal file - self._run_control('test -f /dev/sda && sudo rm -f /dev/sda') + try: + self._run_control('test -f /dev/sda') + # paranoid, but be really certain we're not running locally + self._run_control('sudo rm -f /dev/sda') + except Exception: + pass self._run_control('sudo pi3gpio set high 16') time.sleep(5) self.hardreset() From 5e40523e2f504e80629557709a9a0f5276546dc8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 23 Jun 2021 15:25:14 -0500 Subject: [PATCH 337/569] Allow provisioning to select a sensible default user/pass --- devices/netboot/__init__.py | 9 +++++++-- snappy_device_agents/__init__.py | 24 ++++++++++++++++-------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index a739ace0..388bbc64 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -46,10 +46,15 @@ def provision(self, args): if not image: raise ProvisioningError('Error downloading image') server_ip = snappy_device_agents.get_local_ip_addr() + # Ideally the default user/pass should be metadata about an image, + # but we don't currently have any concept of that stored. For now, + # we can give a reasonable guess based on the provisioning method. test_username = snappy_device_agents.get_test_username( - args.job_data) + job_data=args.job_data, + default='admin') test_password = snappy_device_agents.get_test_password( - args.job_data) + job_data=args.job_data, + default='admin') logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") """Initial recovery process diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 36844f79..79d9fa58 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -149,28 +149,36 @@ def udf_create_image(params): return(imagepath) -def get_test_username(job_data='testflinger.json'): +def get_test_username(job_data='testflinger.json', default='ubuntu'): """ - Read the json data for a test opportunity from SPI and return the - username in specified for the test image (default: ubuntu) + If the test_data specifies a default username, use it. Otherwise + allow the provisioning method pick a default, or use ubuntu as a safe bet :return username: Returns the test image username """ testflinger_data = get_test_opportunity(job_data) - return testflinger_data.get('test_data').get('test_username', 'ubuntu') + try: + user = testflinger_data['test_data']['test_username'] + except Exception: + user = default + return user -def get_test_password(job_data='testflinger.json'): +def get_test_password(job_data='testflinger.json', default='ubuntu'): """ - Read the json data for a test opportunity from SPI and return the - password in specified for the test image (default: ubuntu) + If the test_data specifies a default password, use it. Otherwise + allow the provisioning method pick a default, or use ubuntu as a safe bet :return password: Returns the test image password """ testflinger_data = get_test_opportunity(job_data) - return testflinger_data.get('test_data').get('test_password', 'ubuntu') + try: + password = testflinger_data['test_data']['test_password'] + except Exception: + password = default + return password def get_image(job_data='testflinger.json'): From f6212c6bf4fb092606e3bd7d3a769802a04e61ee Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 30 Jul 2021 14:29:13 -0500 Subject: [PATCH 338/569] Using special rc values shouldn't be needed anymore --- snappy_device_agents/__init__.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 79d9fa58..cd013fe2 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -476,11 +476,9 @@ def _run_test_cmds_list(cmds, config=None, env={}): :param env: Environment to pass when running the commands :return returncode: - Return 0 if everything succeeded, 4 if any command in the list - failed, or 20 if there was a formatting error + Return 0 if everything succeeded, or exit code from failed command """ - exitcode = 0 for cmd in cmds: # Settings from the device yaml configfile like device_ip can be # formatted in test commands like "foo {device_ip}" @@ -489,9 +487,8 @@ def _run_test_cmds_list(cmds, config=None, env={}): logmsg(logging.INFO, "Running: %s", cmd) rc = runcmd(cmd, env) if rc: - exitcode = 4 logmsg(logging.WARNING, "Command failed, rc=%d", rc) - return exitcode + return rc def _run_test_cmds_str(cmds, config=None, env={}): @@ -505,8 +502,7 @@ def _run_test_cmds_str(cmds, config=None, env={}): :param env: Environment to pass when running the commands :return returncode: - Return the value of the return code from the script, or 20 if there - was an error formatting the script + Return the value of the return code from the script """ # If cmds doesn't specify an interpreter, pick a safe default From 43f9ed9a2e2cf9fe3e8a1800faf3b2705988b2a7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 16 Sep 2021 16:10:21 -0500 Subject: [PATCH 339/569] Increase cm3 installation timeout from 15 to 30 minutes --- devices/cm3/cm3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index d0d1a72d..c57edd95 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -86,7 +86,7 @@ def provision(self): self.hardreset() logger.info('Flashing image') out = self._run_control('sudo cm3-installer {}'.format(url), - timeout=900) + timeout=1800) logger.info(out) image_type, image_dev = self.get_image_type() with self.remote_mount(image_dev): From 0e27f8d9711966ce9485a69ba924d78c918360e6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 7 Oct 2021 11:49:27 -0500 Subject: [PATCH 340/569] Increase timeout for running commands in the hardreset() method --- devices/cm3/cm3.py | 2 +- devices/dragonboard/dragonboard.py | 2 +- devices/muxpi/muxpi.py | 2 +- devices/netboot/netboot.py | 2 +- devices/noprovision/noprovision.py | 2 +- devices/oemrecovery/oemrecovery.py | 2 +- devices/rpi3/rpi3.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index c57edd95..ab19bc82 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -235,6 +235,6 @@ def hardreset(self): for cmd in self.config['reboot_script']: logger.info("Running %s", cmd) try: - subprocess.check_call(cmd.split(), timeout=60) + subprocess.check_call(cmd.split(), timeout=120) except Exception: raise RecoveryError("timeout reaching control host!") diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index f34dc097..7bf6c13d 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -99,7 +99,7 @@ def hardreset(self): for cmd in self.config['reboot_script']: logger.info("Running %s", cmd) try: - subprocess.check_call(cmd.split(), timeout=60) + subprocess.check_call(cmd.split(), timeout=120) except: raise RecoveryError("timeout reaching control host!") diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 5cf49f19..a9749ac8 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -210,7 +210,7 @@ def hardreset(self): for cmd in self.config.get('reboot_script', []): logger.info("Running %s", cmd) try: - subprocess.check_call(cmd.split(), timeout=60) + subprocess.check_call(cmd.split(), timeout=120) except Exception: raise RecoveryError("timeout reaching control host!") diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index b9963e3f..c7290880 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -90,7 +90,7 @@ def hardreset(self): for cmd in self.config['reboot_script']: logger.info("Running %s", cmd) try: - subprocess.check_call(cmd.split(), timeout=60) + subprocess.check_call(cmd.split(), timeout=120) except Exception: raise RecoveryError("timeout reaching control host!") diff --git a/devices/noprovision/noprovision.py b/devices/noprovision/noprovision.py index 426866e0..552ec2a4 100644 --- a/devices/noprovision/noprovision.py +++ b/devices/noprovision/noprovision.py @@ -47,7 +47,7 @@ def hardreset(self): for cmd in self.config['reboot_script']: logger.info("Running %s", cmd) try: - subprocess.check_call(cmd.split(), timeout=60) + subprocess.check_call(cmd.split(), timeout=120) except: raise RecoveryError("timeout reaching control host!") diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 48e1ff07..81c4711c 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -143,6 +143,6 @@ def hardreset(self): for cmd in self.config['reboot_script']: logger.info("Running %s", cmd) try: - subprocess.check_call(cmd.split(), timeout=60) + subprocess.check_call(cmd.split(), timeout=120) except Exception: raise RecoveryError("timeout reaching control host!") diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index f7d46eda..e0c94449 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -140,7 +140,7 @@ def hardreset(self): for cmd in self.config['reboot_script']: logger.info("Running %s", cmd) try: - subprocess.check_call(cmd.split(), timeout=60) + subprocess.check_call(cmd.split(), timeout=120) except Exception: raise RecoveryError("timeout reaching control host!") From 7fa2be29e5c4f5f19de80374c3fffecf6677d161 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 14 Oct 2021 17:29:13 -0500 Subject: [PATCH 341/569] Check to see that we actually find partition labels with lsblk --- devices/muxpi/muxpi.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index a9749ac8..426bd20a 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -170,17 +170,25 @@ def flash_test_image(self, server_ip, server_port): raise ProvisioningError("Unable to run hdparm to rescan " "partitions") - @contextmanager - def remote_mount(self): + def _get_part_labels(self): test_device = self.config['test_device'] lsblk_data = self._run_control( 'lsblk -o NAME,LABEL -J {}'.format(test_device)) lsblk_json = json.loads(lsblk_data.decode()) # List of (name, label) pairs - mount_list = [(x.get('name'), - os.path.join(self.mount_point, x.get('label'))) - for x in lsblk_json['blockdevices'][0]['children'] - if x.get('name') and x.get('label')] + return [(x.get('name'), + os.path.join(self.mount_point, x.get('label'))) + for x in lsblk_json['blockdevices'][0]['children'] + if x.get('name') and x.get('label')] + + @contextmanager + def remote_mount(self): + mount_list = self._get_part_labels() + # Sometimes the labels don't show up to lsblk right away + if not mount_list: + print("No valid partitions found, retrying...") + time.sleep(10) + mount_list = self._get_part_labels() for dev, mount in mount_list: try: self._run_control('sudo mkdir -p {}'.format(mount)) From dad5df9e8b18ad3f8735b37c84fdefff4edc4c12 Mon Sep 17 00:00:00 2001 From: Kevin Yeh Date: Mon, 10 Jan 2022 16:36:55 +0800 Subject: [PATCH 342/569] add new condition for resetting efi boot order. --- devices/maas2/maas2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 61cd0e56..08035b07 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -134,7 +134,7 @@ def reset_efi(self): bootlist = efi_data.get('BootOrder:').split(',') new_boot_order = [] for k, v in efi_data.items(): - if ("NIC" in v or "PXE" in v) and "Boot" in k: + if ("IP4" in v or "IPV4" in v or "NIC" in v or "PXE" in v "") and "Boot" in k: new_boot_order.append(k[4:8]) for entry in bootlist: if entry not in new_boot_order: From fabc37be2672f20159587e2f14e6e29fe63472dc Mon Sep 17 00:00:00 2001 From: Kevin Yeh Date: Tue, 11 Jan 2022 13:43:34 +0800 Subject: [PATCH 343/569] use efibootmgr -v to get more preicse boot entry name. --- devices/maas2/maas2.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 08035b07..24f4de98 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -90,7 +90,7 @@ def _get_efi_data(self): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo efibootmgr'] + 'sudo efibootmgr -v'] p = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) # If it fails the first time, try installing efitools snap @@ -99,7 +99,7 @@ def _get_efi_data(self): cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo efibootmgr'] + 'sudo efibootmgr -v'] p = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if p.returncode: @@ -134,7 +134,7 @@ def reset_efi(self): bootlist = efi_data.get('BootOrder:').split(',') new_boot_order = [] for k, v in efi_data.items(): - if ("IP4" in v or "IPV4" in v or "NIC" in v or "PXE" in v "") and "Boot" in k: + if ("IPv4" in v) and "Boot" in k: new_boot_order.append(k[4:8]) for entry in bootlist: if entry not in new_boot_order: From eaf55c25242a8e5c49008576f676adc3b212d609 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 31 Jan 2022 11:41:36 -0600 Subject: [PATCH 344/569] Skip missing phases even if they are present but empty --- testflinger_agent/job.py | 6 +++--- testflinger_agent/tests/test_agent.py | 14 +++++++------- testflinger_agent/tests/test_job.py | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index b76dfdc6..b717d32e 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -55,13 +55,13 @@ def run_test_phase(self, phase, rundir): if not cmd: logger.info('No %s_command configured, skipping...', phase) return 0 - if phase == 'provision' and 'provision_data' not in self.job_data: + if phase == 'provision' and not self.job_data.get('provision_data'): logger.info('No provision_data defined in job data, skipping...') return 0 - if phase == 'test' and 'test_data' not in self.job_data: + if phase == 'test' and not self.job_data.get('test_data'): logger.info('No test_data defined in job data, skipping...') return 0 - if phase == 'reserve' and 'reserve_data' not in self.job_data: + if phase == 'reserve' and not self.job_data.get('reserve_data'): return 0 output_log = os.path.join(rundir, phase+'.log') serial_log = os.path.join(rundir, phase+'-serial.log') diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 496dbe30..e9ce10c6 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -52,7 +52,7 @@ def test_check_and_run_provision(self, agent, requests_mock): self.config['provision_command'] = 'echo provision1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test', - 'provision_data': ''} + 'provision_data': {'url': 'foo'}} requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, {'text': '{}'}]) requests_mock.post(rmock.ANY, status_code=200) @@ -67,7 +67,7 @@ def test_check_and_run_test(self, agent, requests_mock): self.config['test_command'] = 'echo test1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test', - 'test_data': ''} + 'test_data': {'test_cmds': 'foo'}} requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, {'text': '{}'}]) requests_mock.post(rmock.ANY, status_code=200) @@ -82,7 +82,7 @@ def test_config_vars_in_env(self, agent, requests_mock): self.config['test_command'] = 'echo test_string is $test_string' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test', - 'test_data': ''} + 'test_data': {'test_cmds': 'foo'}} requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, {'text': '{}'}]) requests_mock.post(rmock.ANY, status_code=200) @@ -99,8 +99,8 @@ def test_phase_failed(self, agent, requests_mock): self.config['test_command'] = 'echo test1' fake_job_data = {'job_id': str(uuid.uuid1()), 'job_queue': 'test', - 'provision_data': '', - 'test_data': ''} + 'provision_data': {'url': 'foo'}, + 'test_data': {'test_cmds': 'foo'}} requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, {'text': '{}'}]) requests_mock.post(rmock.ANY, status_code=200) @@ -153,8 +153,8 @@ def test_recovery_failed(self, agent, requests_mock): job_id = str(uuid.uuid1()) fake_job_data = {'job_id': job_id, 'job_queue': 'test', - 'provision_data': '', - 'test_data': ''} + 'provision_data': {'url': 'foo'}, + 'test_data': {'test_cmds': 'foo'}} # In this case we are making sure that the repost job request # gets good status with rmock.Mocker() as m: diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 481e7b47..bb70c227 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -38,6 +38,20 @@ def test_skip_missing_provision_data(self, client): log_output = log.read() assert("No provision_data defined in job data" in log_output) + def test_skip_empty_provision_data(self, client): + """ + Test that provision phase is skipped when provision_data is + present but empty + """ + self.config['provision_command'] = '/bin/true' + fake_job_data = {'global_timeout': 1, 'provision_data': ''} + job = _TestflingerJob(fake_job_data, client) + job.run_test_phase('provision', None) + logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') + with open(logfile) as log: + log_output = log.read() + assert("No provision_data defined in job data" in log_output) + def test_job_global_timeout(self, client, requests_mock): """Test that timeout from job_data is respected""" timeout_str = '\nERROR: Global timeout reached! (1s)\n' From b1abf644f094a91f08be3e98e6a656d206169fe6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 20 Apr 2022 23:34:13 -0500 Subject: [PATCH 345/569] Add some unit tests to get started --- testflinger_cli/tests/__init__.py | 0 testflinger_cli/tests/test_cli.py | 98 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 testflinger_cli/tests/__init__.py create mode 100644 testflinger_cli/tests/test_cli.py diff --git a/testflinger_cli/tests/__init__.py b/testflinger_cli/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testflinger_cli/tests/test_cli.py b/testflinger_cli/tests/test_cli.py new file mode 100644 index 00000000..00206ace --- /dev/null +++ b/testflinger_cli/tests/test_cli.py @@ -0,0 +1,98 @@ +# Copyright (C) 2022 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +""" +Unit tests for testflinger-cli +""" + +import json +import sys +import uuid +import pytest + +import testflinger_cli + + +URL = "https://testflinger.canonical.com" + + +def test_status(capsys, requests_mock): + """ Status should report job_state data """ + jobid = str(uuid.uuid1()) + fake_return = {"job_state": "complete"} + requests_mock.get(URL+"/v1/result/"+jobid, json=fake_return) + sys.argv = ['', 'status', jobid] + tfcli = testflinger_cli.TestflingerCli() + tfcli.status() + std = capsys.readouterr() + assert std.out == "complete\n" + + +def test_cancel(requests_mock): + """ Cancel should fail if job is already complete """ + jobid = str(uuid.uuid1()) + fake_return = {"job_state": "complete"} + requests_mock.get(URL+"/v1/result/"+jobid, json=fake_return) + requests_mock.post(URL+"/v1/result/"+jobid) + sys.argv = ['', 'cancel', jobid] + tfcli = testflinger_cli.TestflingerCli() + with pytest.raises(SystemExit) as err: + tfcli.cancel() + assert "already in complete state and cannot" in err.value.args[0] + + +def test_submit(capsys, tmp_path, requests_mock): + """ Make sure jobid is read back from submitted job """ + jobid = str(uuid.uuid1()) + fake_data = { + "queue": "fake", + "provision_data": { + "distro": "fake" + } + } + testfile = tmp_path / "test.json" + testfile.write_text(json.dumps(fake_data)) + fake_return = {"job_id": jobid} + requests_mock.post(URL+"/v1/job", json=fake_return) + sys.argv = ['', 'submit', str(testfile)] + tfcli = testflinger_cli.TestflingerCli() + tfcli.submit() + std = capsys.readouterr() + assert jobid in std.out + + +def test_show(capsys, requests_mock): + """ Exercise show command """ + jobid = str(uuid.uuid1()) + fake_return = {"job_state": "complete"} + requests_mock.get(URL+"/v1/job/"+jobid, json=fake_return) + sys.argv = ['', 'show', jobid] + tfcli = testflinger_cli.TestflingerCli() + tfcli.show() + std = capsys.readouterr() + assert "complete" in std.out + + +def test_results(capsys, requests_mock): + """ results should report job_state data """ + jobid = str(uuid.uuid1()) + fake_return = {"job_state": "complete"} + requests_mock.get(URL+"/v1/result/"+jobid, json=fake_return) + sys.argv = ['', 'results', jobid] + tfcli = testflinger_cli.TestflingerCli() + tfcli.results() + std = capsys.readouterr() + assert "complete" in std.out From 6fa5b670414ad3a820954ffe6fbb0e5e64b29002 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 21 Apr 2022 11:59:22 -0500 Subject: [PATCH 346/569] Add tox.ini and remove tests_require from setup --- setup.py | 1 - tox.ini | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tox.ini diff --git a/setup.py b/setup.py index ce134433..3b2c834b 100755 --- a/setup.py +++ b/setup.py @@ -27,7 +27,6 @@ zip_safe=False, install_requires=INSTALL_REQUIRES, test_suite='testflinger_cli.tests', - tests_require=TEST_REQUIRES, entry_points=''' [console_scripts] testflinger-cli=testflinger_cli:cli diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..47c1b9aa --- /dev/null +++ b/tox.ini @@ -0,0 +1,19 @@ +[tox] +envlist = py +skipsdist = true + +[testenv] +setenv = + HOME = {envtmpdir} +deps = + flake8 + mock + pytest + pylint + pytest-mock + pytest-cov + requests-mock +commands = + {envbindir}/python setup.py develop + {envbindir}/python -m flake8 setup.py testflinger-cli + {envbindir}/python -m pytest --doctest-modules --cov=. From 47c4c71c3e53ef913b5b7acc013f8ac9429afb67 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 21 Apr 2022 12:19:28 -0500 Subject: [PATCH 347/569] Also remove TEST_REQUIRES from setup.py --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 3b2c834b..49a7d8ff 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,6 @@ from setuptools import setup INSTALL_REQUIRES = ['pyyaml', 'requests', 'xdg<4.0'] -TEST_REQUIRES = [] setup( name='testflinger-cli', From 30dffc9e5fa028ffb781a9b9f252615e451bfafb Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 21 Apr 2022 14:53:39 -0500 Subject: [PATCH 348/569] Make pylint happy --- .pylintrc | 7 + testflinger_cli/__init__.py | 290 +++++++++++++++++++----------------- testflinger_cli/client.py | 10 +- testflinger_cli/config.py | 17 ++- testflinger_cli/history.py | 27 +++- 5 files changed, 197 insertions(+), 154 deletions(-) create mode 100644 .pylintrc diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..691ed1ec --- /dev/null +++ b/.pylintrc @@ -0,0 +1,7 @@ +[MASTER] +good-names = k, v + +[MESSAGES CONTROL] +# We currently have some older systems running this which +# don't support f-strings yet +disable = C0209 diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index e7d43329..411d659f 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2020 Canonical +# Copyright (C) 2017-2022 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,16 +14,20 @@ # along with this program. If not, see . # +""" +TestflingerCli module +""" + import inspect import json import os import sys import time -import yaml - from argparse import ArgumentParser from datetime import datetime +import yaml + from testflinger_cli import (client, config, history) @@ -34,14 +38,59 @@ def cli(): + """Generate the TestflingerCli instance and run it""" try: tfcli = TestflingerCli() tfcli.run() - except KeyboardInterrupt: - raise SystemExit + except KeyboardInterrupt as exc: + raise SystemExit from exc + + +def _get_image(images): + image = "" + flex_url = "" + if images and images[list(images.keys())[0]].startswith('url:'): + # If this device can take URLs, offer to let the user enter one + # instead of just using the known images + flex_url = "or URL for a valid image starting with http(s)://... " + while not image or image == "?": + image = input("\nEnter the name of the image you want to use " + + flex_url + "('?' to list) ") + if image == "?": + if not images: + print("WARNING: There are no images defined for this " + "device. You may also provide the URL to an image " + "that can be booted with this device though.") + continue + for image_id in sorted(images.keys()): + print(" " + image_id) + continue + if image.startswith(('http://', 'https://')): + return image + if image not in images.keys(): + print("ERROR: '{}' is not in the list of known images for " + "that queue, please select another.".format(image)) + image = "" + return image + + +def _get_ssh_keys(): + ssh_keys = "" + while not ssh_keys.strip(): + ssh_keys = input("\nEnter the ssh key(s) you wish to use: " + "(ex: lp:userid, gh:userid) ") + key_list = [ssh_key.strip() for ssh_key in ssh_keys.split(",")] + for ssh_key in key_list: + if (not ssh_key.startswith("lp:") and + not ssh_key.startswith("gh:")): + ssh_keys = "" + print("Please enter keys in the form lp:userid " + "or gh:userid") + return key_list class TestflingerCli: + """Class for handling the Testflinger CLI""" def __init__(self): self.get_args() self.config = config.TestflingerCliConfig(self.args.configfile) @@ -52,7 +101,8 @@ def __init__(self): 'https://testflinger.canonical.com' ) # Allow config subcommand without worrying about server or client - if hasattr(self.args, 'func') and self.args.func == self.configure: + if (hasattr(self.args, 'func') and + self.args.func == self.configure): # pylint: disable=W0143 return if not server.startswith(('http://', 'https://')): raise SystemExit('Server must start with "http://" or "https://" ' @@ -61,11 +111,13 @@ def __init__(self): self.history = history.TestflingerCliHistory() def run(self): + """Run the subcommand specified in command line arguments""" if hasattr(self.args, 'func'): raise SystemExit(self.args.func()) print(self.help) def get_args(self): + """Handle command line arguments""" parser = ArgumentParser() parser.add_argument('-c', '--configfile', default=None, help='Configuration file to use') @@ -141,19 +193,16 @@ def status(self): try: job_state = self.client.get_status(self.args.job_id) self.history.update(self.args.job_id, job_state) - except client.HTTPError as e: - if e.status == 204: + except client.HTTPError as exc: + if exc.status == 204: raise SystemExit('No data found for that job id. Check the ' - 'job id to be sure it is correct') - if e.status == 400: + 'job id to be sure it is correct') from exc + if exc.status == 400: raise SystemExit('Invalid job id specified. Check the job ' - 'id to be sure it is correct') - if e.status == 404: + 'id to be sure it is correct') from exc + if exc.status == 404: raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') + 'sure this is a testflinger server?') from exc print(job_state) def cancel(self): @@ -161,30 +210,29 @@ def cancel(self): try: job_state = self.client.get_status(self.args.job_id) self.history.update(self.args.job_id, job_state) - except client.HTTPError as e: - if e.status == 204: + except client.HTTPError as exc: + if exc.status == 204: raise SystemExit('Job {} not found. Check the job ' 'id to be sure it is ' - 'correct.'.format(self.args.job_id)) - if e.status == 400: + 'correct.'.format(self.args.job_id)) from exc + if exc.status == 400: raise SystemExit('Invalid job id specified. Check the job ' - 'id to be sure it is correct.') - if e.status == 404: + 'id to be sure it is correct.') from exc + if exc.status == 404: raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') + 'sure this is a testflinger server?') from exc if job_state in ('complete', 'cancelled'): raise SystemExit('Job {} is already in {} state and cannot be ' 'cancelled.'.format(self.args.job_id, job_state)) self.do_cancel(self.args.job_id) def do_cancel(self, job_id): + """Send cancellation request for a specified job_id""" self.client.post_job_state(job_id, 'cancelled') self.history.update(job_id, 'cancelled') def configure(self): + """Print or set configuration values""" if self.args.setting: setting = self.args.setting.split('=') if len(setting) == 2: @@ -206,14 +254,12 @@ def submit(self): data = sys.stdin.read() else: try: - with open(self.args.filename) as f: - data = f.read() - except FileNotFoundError: - raise SystemExit( - 'File not found: {}'.format(self.args.filename)) - except Exception: + with open(self.args.filename, encoding='utf-8', + errors='ignore') as job_file: + data = job_file.read() + except FileNotFoundError as exc: raise SystemExit( - 'Unable to read file: {}'.format(self.args.filename)) + 'File not found: {}'.format(self.args.filename)) from exc job_id = self.submit_job_data(data) queue = yaml.safe_load(data).get('job_queue') self.history.new(job_id, queue) @@ -230,56 +276,53 @@ def submit_job_data(self, data): """ try: job_id = self.client.submit_job(data) - except client.HTTPError as e: - if e.status == 400: + except client.HTTPError as exc: + if exc.status == 400: raise SystemExit('The job you submitted contained bad data or ' 'bad formatting, or did not specify a ' - 'job_queue.') - if e.status == 404: + 'job_queue.') from exc + if exc.status == 404: raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') + 'sure this is a testflinger server?') from exc # This shouldn't happen, so let's get more information raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) + 'server: {}'.format(exc.status)) from exc return job_id def show(self): """Show the requested job JSON for a specified JOB_ID""" try: results = self.client.show_job(self.args.job_id) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('No data found for that job id.') - if e.status == 400: + except client.HTTPError as exc: + if exc.status == 204: + raise SystemExit('No data found for that job id.') from exc + if exc.status == 400: raise SystemExit('Invalid job id specified. Check the job id ' - 'to be sure it is correct') - if e.status == 404: + 'to be sure it is correct') from exc + if exc.status == 404: raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') + 'sure this is a testflinger server?') from exc # This shouldn't happen, so let's get more information raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) + 'server: {}'.format(exc.status)) from exc print(json.dumps(results, sort_keys=True, indent=4)) def results(self): """Get results JSON for a completed JOB_ID""" try: results = self.client.get_results(self.args.job_id) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('No results found for that job id.') - if e.status == 400: + except client.HTTPError as exc: + if exc.status == 204: + raise SystemExit('No results found for that job id.') from exc + if exc.status == 400: raise SystemExit('Invalid job id specified. Check the job id ' - 'to be sure it is correct') - if e.status == 404: + 'to be sure it is correct') from exc + if exc.status == 404: raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') + 'sure this is a testflinger server?') from exc # This shouldn't happen, so let's get more information raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') + 'server: {}'.format(exc.status)) from exc print(json.dumps(results, sort_keys=True, indent=4)) @@ -288,36 +331,37 @@ def artifacts(self): print('Downloading artifacts tarball...') try: self.client.get_artifact(self.args.job_id, self.args.filename) - except client.HTTPError as e: - if e.status == 204: - raise SystemExit('No artifacts tarball found for that job id.') - if e.status == 400: + except client.HTTPError as exc: + if exc.status == 204: + raise SystemExit( + 'No artifacts tarball found for that job id.') from exc + if exc.status == 400: raise SystemExit('Invalid job id specified. Check the job id ' - 'to be sure it is correct') - if e.status == 404: + 'to be sure it is correct') from exc + if exc.status == 404: raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') + 'sure this is a testflinger server?') from exc # This shouldn't happen, so let's get more information raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(e.status)) - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') + 'server: {}'.format(exc.status)) from exc print('Artifacts downloaded to {}'.format(self.args.filename)) def poll(self): """Poll for output from a job until it is complete""" if self.args.oneshot: - try: - output = self.get_latest_output(self.args.job_id) - except Exception: - sys.exit(1) + # This could get an IOError for connection errors or timeouts + # Raise it since it's not running continuously in this mode + output = self.get_latest_output(self.args.job_id) if output: print(output, end='', flush=True) sys.exit(0) self.do_poll(self.args.job_id) def do_poll(self, job_id): + """Poll for output from a running job and print it while it runs + + :param str job_id: Job ID + """ job_state = self.get_job_state(job_id) self.history.update(job_id, job_state) prev_queue_pos = None @@ -333,14 +377,17 @@ def do_poll(self, job_id): if int(queue_pos) != prev_queue_pos: prev_queue_pos = int(queue_pos) print('Jobs ahead in queue: {}'.format(queue_pos)) - except Exception: - # Ignore any bad response, this will retry + except IOError: + # Ignore/retry any connection errors or timeouts pass time.sleep(10) output = '' try: output = self.get_latest_output(job_id) - except Exception: + except IOError: + # Any kind of IOError here should be a connection issue or + # a timeout so we should ignore it and retry on the next + # pass through the loop continue if output: print(output, end='', flush=True) @@ -395,13 +442,10 @@ def list_queues(self): """List the advertised queues on the current Testflinger server""" try: queues = self.client.get_queues() - except client.HTTPError as e: - if e.status == 404: + except client.HTTPError as exc: + if exc.status == 404: raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') - except Exception: - raise SystemExit( - 'Error communicating with server, check connection and retry') + 'sure this is a testflinger server?') from exc print('Advertised queues on this server:') for name, description in sorted(queues.items()): print(' {} - {}'.format(name, description)) @@ -410,7 +454,7 @@ def reserve(self): """Install and reserve a system""" try: queues = self.client.get_queues() - except Exception: + except OSError: print("WARNING: unable to get a list of queues from the server!") queues = {} queue = self.args.queue or self._get_queue(queues) @@ -419,10 +463,10 @@ def reserve(self): "queues".format(queue)) try: images = self.client.get_images(queue) - except Exception: + except OSError: print("WARNING: unable to get a list of images from the server!") images = {} - image = self.args.image or self._get_image(images) + image = self.args.image or _get_image(images) if (not image.startswith(("http://", "https://")) and image not in images.keys()): raise SystemExit("ERROR: '{}' is not in the list of known " @@ -432,7 +476,7 @@ def reserve(self): image = "url: " + image else: image = images[image] - ssh_keys = self.args.key or self._get_ssh_keys() + ssh_keys = self.args.key or _get_ssh_keys() for ssh_key in ssh_keys: if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): raise SystemExit("Please enter keys in the form lp:userid or " @@ -473,72 +517,38 @@ def _get_queue(self, queues): queue = "" return queue - def _get_image(self, images): - image = "" - flex_url = "" - if images and images[list(images.keys())[0]].startswith('url:'): - # If this device can take URLs, offer to let the user enter one - # instead of just using the known images - flex_url = "or URL for a valid image starting with http(s)://... " - while not image or image == "?": - image = input("\nEnter the name of the image you want to use " + - flex_url + "('?' to list) ") - if image == "?": - if not images: - print("WARNING: There are no images defined for this " - "device. You may also provide the URL to an image " - "that can be booted with this device though.") - continue - for image_id in sorted(images.keys()): - print(" " + image_id) - continue - if image.startswith(('http://', 'https://')): - return image - if image not in images.keys(): - print("ERROR: '{}' is not in the list of known images for " - "that queue, please select another.".format(image)) - image = "" - return image - - def _get_ssh_keys(self): - ssh_keys = "" - while not ssh_keys.strip(): - ssh_keys = input("\nEnter the ssh key(s) you wish to use: " - "(ex: lp:userid, gh:userid) ") - key_list = [ssh_key.strip() for ssh_key in ssh_keys.split(",")] - for ssh_key in key_list: - if (not ssh_key.startswith("lp:") and - not ssh_key.startswith("gh:")): - ssh_keys = "" - print("Please enter keys in the form lp:userid " - "or gh:userid") - return key_list - def get_latest_output(self, job_id): + """Get the latest output from a running job + + :param str job_id: Job ID + :return str: New output from the running job + """ output = '' try: output = self.client.get_output(job_id) - except client.HTTPError as e: - if e.status == 204: + except client.HTTPError as exc: + if exc.status == 204: # We are still waiting for the job to start pass return output def get_job_state(self, job_id): + """Return the job state for the specified job_id + + :param str job_id: Job ID + :raises SystemExit: Exit with HTTP error code + :return str : Job state + """ try: return self.client.get_status(job_id) - except client.HTTPError as e: - if e.status == 204: + except client.HTTPError as exc: + if exc.status == 204: raise SystemExit('No data found for that job id. Check the ' - 'job id to be sure it is correct') - if e.status == 400: + 'job id to be sure it is correct') from exc + if exc.status == 400: raise SystemExit('Invalid job id specified. Check the job id ' - 'to be sure it is correct') - if e.status == 404: + 'to be sure it is correct') from exc + if exc.status == 404: raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') - except Exception: - # If we fail to get the job_state here, it could be because of - # timeout but we can keep going and retrying - pass + 'sure this is a testflinger server?') from exc return 'unknown' diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index 72ef93d4..3f64a3c6 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2020 Canonical +# Copyright (C) 2017-2022 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,15 +14,21 @@ # along with this program. If not, see . # +""" +Testflinger client module +""" + import json -import requests import sys import urllib.parse +import requests import yaml class HTTPError(Exception): + """Exception class for HTTP error codes""" def __init__(self, status): + super().__init__(status) self.status = status diff --git a/testflinger_cli/config.py b/testflinger_cli/config.py index c45f5b04..96509c35 100644 --- a/testflinger_cli/config.py +++ b/testflinger_cli/config.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020 Canonical +# Copyright (C) 2020-2022 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,13 +14,18 @@ # along with this program. If not, see . # +""" +Testflinger config module +""" + import configparser import os -import xdg from collections import OrderedDict +import xdg class TestflingerCliConfig: + """TestflingerCliConfig class load values from files, env, and params""" def __init__(self, configfile=None): config = configparser.ConfigParser() if not configfile: @@ -35,14 +40,18 @@ def __init__(self, configfile=None): self.configfile = configfile def get(self, key): + """Get config item""" return self.data.get(key) def set(self, key, value): + """Set config item""" self.data[key] = value self._save() def _save(self): + """Save config back to the config file""" config = configparser.ConfigParser() config.read_dict({'testflinger-cli': self.data}) - with open(self.configfile, 'w') as f: - config.write(f) + with open(self.configfile, 'w', encoding='utf-8', + errors='ignore') as config_file: + config.write(config_file) diff --git a/testflinger_cli/history.py b/testflinger_cli/history.py index 1d57d882..3364d30d 100644 --- a/testflinger_cli/history.py +++ b/testflinger_cli/history.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020 Canonical +# Copyright (C) 2020-2022 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,14 +14,19 @@ # along with this program. If not, see . # +""" +Testflinger history module +""" + import json import os -import xdg from collections import OrderedDict from datetime import datetime +import xdg class TestflingerCliHistory: + """History class used for storing job history on a device""" def __init__(self): os.makedirs(xdg.XDG_DATA_HOME, exist_ok=True) self.historyfile = os.path.join( @@ -29,6 +34,7 @@ def __init__(self): self.load() def new(self, job_id, queue): + """Add a new job to the history""" submission_time = datetime.now().timestamp() self.history[job_id] = dict( queue=queue, @@ -41,21 +47,26 @@ def new(self, job_id, queue): self.save() def load(self): + """Load the history file""" if not hasattr(self, 'history'): self.history = OrderedDict() if os.path.exists(self.historyfile): - with open(self.historyfile) as f: + with open(self.historyfile, encoding='utf-8', + errors='ignore') as history_file: try: - self.history.update(json.load(f)) - except Exception: + self.history.update(json.load(history_file)) + except (OSError, ValueError): # If there's any error loading the history, ignore it - return + print("Error loading history file from", self.historyfile) def save(self): - with open(self.historyfile, 'w') as f: - json.dump(self.history, f, indent=2) + """Save the history out to the history file""" + with open(self.historyfile, 'w', encoding='utf-8', + errors='ignore') as history_file: + json.dump(self.history, history_file, indent=2) def update(self, job_id, state): + """Update job state in the history file""" if job_id in self.history: self.history[job_id]['job_state'] = state self.save() From fd4bfaf97ad9293fa6800551b291c2809be2cf29 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 21 Apr 2022 14:54:05 -0500 Subject: [PATCH 349/569] Add pylint to the tox run --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 47c1b9aa..e51593c2 100644 --- a/tox.ini +++ b/tox.ini @@ -15,5 +15,6 @@ deps = requests-mock commands = {envbindir}/python setup.py develop - {envbindir}/python -m flake8 setup.py testflinger-cli + {envbindir}/python -m flake8 setup.py testflinger_cli + {envbindir}/python -m pylint testflinger_cli {envbindir}/python -m pytest --doctest-modules --cov=. From f19240e5f37ac5224c7b7074fb63227313ce7826 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 29 Apr 2022 13:24:11 -0500 Subject: [PATCH 350/569] Remove test mode from setup.py and add tox.ini --- pytest.ini | 2 -- setup.py | 13 +------------ tox.ini | 19 +++++++++++++++++++ 3 files changed, 20 insertions(+), 14 deletions(-) delete mode 100644 pytest.ini create mode 100644 tox.ini diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index fc824acd..00000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts = --doctest-modules --ignore=setup.py --ignore=.eggs --flake8 --cov=testflinger_agent diff --git a/setup.py b/setup.py index 0af4c0ca..17811cdf 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2022 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,16 +24,6 @@ "voluptuous", ] -TEST_REQUIRES = [ - # newer mock requires python3.8 features, use an older one so we - # can run unit tests on older systems - "mock==3.0.5", - "pytest", - "pytest-cov", - "pytest-flake8", - "requests-mock", -] - setup( name='testflinger-agent', version='1.0', @@ -41,7 +31,6 @@ packages=['testflinger_agent'], zip_safe=False, install_requires=INSTALL_REQUIRES, - tests_require=TEST_REQUIRES, setup_requires=['pytest-runner'], scripts=['testflinger-agent'], ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..a458b525 --- /dev/null +++ b/tox.ini @@ -0,0 +1,19 @@ +[tox] +envlist = py +skipsdist = true + +[testenv] +setenv = + HOME = {envtmpdir} +deps = + flake8 + mock + pytest + pylint + pytest-mock + pytest-cov + requests-mock +commands = + {envbindir}/python setup.py develop + {envbindir}/python -m flake8 setup.py testflinger_agent + {envbindir}/python -m pytest --doctest-modules --cov=testflinger_agent From b80e67e7fb93c43a9dcb05f15b89bf293b3b35f0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 29 Apr 2022 14:02:44 -0500 Subject: [PATCH 351/569] Add gitignore --- .gitignore | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..8de9a2f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__ +*.pyc +*.egg* +*.swp +env +venv +build +dist +.tox +.coverage +.vscode From d9a69072020aca9fcd0c51831cfb1382160bfdc9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 May 2022 11:51:51 -0500 Subject: [PATCH 352/569] Handle some pyflakes --- devices/dragonboard/dragonboard.py | 46 ++++++++++++++++-------------- devices/netboot/netboot.py | 2 +- devices/noprovision/noprovision.py | 6 ++-- devices/oemrecovery/oemrecovery.py | 4 +-- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index 7bf6c13d..42b6cfc0 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -82,7 +82,7 @@ def setboot(self, mode): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=60) - except: + except subprocess.TimeoutExpired: raise ProvisioningError("timeout reaching control host!") def hardreset(self): @@ -100,7 +100,7 @@ def hardreset(self): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) - except: + except subprocess.TimeoutExpired: raise RecoveryError("timeout reaching control host!") def ensure_test_image(self, test_username, test_password): @@ -118,7 +118,8 @@ def ensure_test_image(self, test_username, test_password): self.setboot('test') try: self._run_control('sudo /sbin/reboot') - except: + except subprocess.SubprocessError: + # Keep trying even if this command fails pass time.sleep(60) @@ -134,7 +135,8 @@ def ensure_test_image(self, test_username, test_password): '{}@{}'.format(test_username, self.config['device_ip'])] subprocess.check_call(cmd) test_image_booted = self.is_test_image_booted() - except: + except subprocess.SubprocessError: + # Keep trying even if this command fails pass if test_image_booted: break @@ -148,10 +150,6 @@ def is_test_image_booted(self): :returns: True if the test image is currently booted, False otherwise. - :raises TimeoutError: - If the command times out - :raises CalledProcessError: - If the command fails """ logger.info("Checking if test image booted.") cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', @@ -161,7 +159,7 @@ def is_test_image_booted(self): try: subprocess.check_output( cmd, stderr=subprocess.STDOUT, timeout=60) - except: + except subprocess.SubprocessError: return False # If we get here, then the above command proved we are in snappy return True @@ -180,7 +178,7 @@ def is_master_image_booted(self): logger.info("Checking if master image booted.") try: output = self._run_control('cat /etc/issue') - except: + except subprocess.SubprocessError: logger.info("Error checking device state. Forcing reboot...") return False if 'Debian GNU' in str(output): @@ -259,11 +257,11 @@ def flash_test_image(self, server_ip, server_port): try: # XXX: I hope 30 min is enough? but maybe not! self._run_control(cmd, timeout=1800) - except: + except subprocess.TimeoutExpired: raise ProvisioningError("timeout reached while flashing image!") try: self._run_control('sync') - except: + except subprocess.SubprocessError: # Nothing should go wrong here, but let's sleep if it does logger.warn("Something went wrong with the sync, sleeping...") time.sleep(30) @@ -271,19 +269,22 @@ def flash_test_image(self, server_ip, server_port): self._run_control( 'sudo hdparm -z {}'.format(self.config['test_device']), timeout=30) - except: - raise ProvisioningError("Unable to run hdparm to rescan " - "partitions") + except subprocess.CalledProcessError as exc: + raise ProvisioningError( + "Unable to run hdparm to rescan" + "partitions: {}".format(exc.output)) def mount_writable_partition(self): # Mount the writable partition try: self._run_control('sudo mount {} /mnt'.format( self.config['snappy_writable_partition'])) - except: + except subprocess.CalledProcessError as exc: err = ("Error mounting writable partition on test image {}. " - "Check device configuration".format( - self.config['snappy_writable_partition'])) + "Check device configuration\n" + "output: {}".format( + self.config['snappy_writable_partition'], + exc.output)) raise ProvisioningError(err) def create_user(self): @@ -315,8 +316,9 @@ def create_user(self): write_cmd.format(metadata, cloud_path, 'meta-data')) self._run_control( write_cmd.format(userdata, cloud_path, 'user-data')) - except: - raise ProvisioningError("Error creating user files") + except subprocess.CalledProcessError as exc: + raise ProvisioningError( + "Error creating user files: {}".format(exc.output)) def setup_sudo(self): sudo_data = 'ubuntu ALL=(ALL) NOPASSWD:ALL' @@ -338,7 +340,7 @@ def wipe_test_device(self): logger.error("Failed to write image, cleaning up...") self._run_control( 'sudo sgdisk -o {}'.format(test_device)) - except: + except subprocess.SubprocessError: # This is an attempt to salvage a bad run, further tracebacks # would just add to the noise pass @@ -384,7 +386,7 @@ def provision(self): self.setup_sudo() logger.info("Booting Test Image") self.ensure_test_image(test_username, test_password) - except: + except (ValueError, subprocess.SubprocessError): # wipe out whatever we installed if things go badly self.wipe_test_device() raise diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index c7290880..f032fbc3 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -212,7 +212,7 @@ def flash_test_image(self, server_ip, server_port): :raises ProvisioningError: If the command times out or anything else fails. """ - url = 'http://{}:8989/writeimage?server={}:{}\&dev={}'.format( + url = r'http://{}:8989/writeimage?server={}:{}\&dev={}'.format( self.config['device_ip'], server_ip, server_port, self.config['test_device']) logger.info("Triggering: %s", url) diff --git a/devices/noprovision/noprovision.py b/devices/noprovision/noprovision.py index 552ec2a4..7f695068 100644 --- a/devices/noprovision/noprovision.py +++ b/devices/noprovision/noprovision.py @@ -48,7 +48,7 @@ def hardreset(self): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) - except: + except subprocess.TimeoutExpired: raise RecoveryError("timeout reaching control host!") def ensure_test_image(self, test_username): @@ -70,7 +70,7 @@ def ensure_test_image(self, test_username): try: subprocess.check_call(cmd) return - except: + except subprocess.SubprocessError: pass self.hardreset() @@ -87,7 +87,7 @@ def ensure_test_image(self, test_username): '/bin/true'] subprocess.check_call(cmd) break - except: + except subprocess.SubprocessError: # keep going if we aren't booted yet pass # If we got here, then it never booted to the test image diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 81c4711c..019431ab 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -51,7 +51,7 @@ def _run_device(self, cmd, timeout=60): try: test_username = self.job_data.get( 'test_data', {}).get('test_username', 'ubuntu') - except: + except AttributeError: test_username = 'ubuntu' ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', @@ -85,7 +85,7 @@ def copy_ssh_id(self): 'test_data', {}).get('test_username', 'ubuntu') test_password = self.job_data.get( 'test_data', {}).get('test_password', 'ubuntu') - except: + except AttributeError: test_username = 'ubuntu' test_password = 'ubuntu' cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', From f8b0c142e379dfd8e7d6c02d54b6f9bb16a2c766 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 May 2022 13:44:59 -0500 Subject: [PATCH 353/569] Convert SerialLogger factory to a class pyflakes doesn't like it when a function has uppercase chars --- devices/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 6ea2670f..defdb6b6 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -34,15 +34,16 @@ class RecoveryError(Exception): pass -def SerialLogger(host=None, port=None, filename=None): - """Factory to generate real or fake SerialLogger object based on params""" - if host and port and filename: - return RealSerialLogger(host, port, filename) - else: +class SerialLogger: + def __new__(cls, host=None, port=None, filename=None): + """Factory to generate real or fake SerialLogger object based on params + """ + if host and port and filename: + return RealSerialLogger(host, port, filename) return StubSerialLogger(host, port, filename) -class StubSerialLogger(): +class StubSerialLogger: def __init__(self, host, port, filename): pass @@ -53,7 +54,7 @@ def stop(self): pass -class RealSerialLogger(): +class RealSerialLogger: """Set up a subprocess to connect to an ip and collect serial logs""" From 6c08e80e550c75fb9d485c90bece566851c6f5e9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 May 2022 13:52:53 -0500 Subject: [PATCH 354/569] Rename Catch decorator to catch because pyflakes --- devices/__init__.py | 2 +- devices/cm3/__init__.py | 4 ++-- devices/dragonboard/__init__.py | 4 ++-- devices/maas2/__init__.py | 4 ++-- devices/muxpi/__init__.py | 4 ++-- devices/netboot/__init__.py | 4 ++-- devices/noprovision/__init__.py | 4 ++-- devices/oemrecovery/__init__.py | 4 ++-- devices/rpi3/__init__.py | 4 ++-- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index defdb6b6..f60fd65b 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -202,7 +202,7 @@ def reserve(self, args): time.sleep(int(timeout)) -def Catch(exception, returnval=0): +def catch(exception, returnval=0): """ Decorator for catching Exceptions and returning values instead This is useful because for certain things, like RecoveryError, we diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py index 9e4291dc..dc696b28 100644 --- a/devices/cm3/__init__.py +++ b/devices/cm3/__init__.py @@ -20,7 +20,7 @@ import snappy_device_agents from devices.cm3.cm3 import CM3 from snappy_device_agents import logmsg -from devices import (Catch, +from devices import (catch, DefaultDevice, RecoveryError, SerialLogger) @@ -32,7 +32,7 @@ class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" - @Catch(RecoveryError, 46) + @catch(RecoveryError, 46) def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index e32a97eb..69ed6398 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -20,7 +20,7 @@ import snappy_device_agents from devices.dragonboard.dragonboard import Dragonboard from snappy_device_agents import logmsg -from devices import (Catch, +from devices import (catch, DefaultDevice, RecoveryError, SerialLogger) @@ -32,7 +32,7 @@ class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" - @Catch(RecoveryError, 46) + @catch(RecoveryError, 46) def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index 98dc3b84..4c1851b8 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -20,7 +20,7 @@ import snappy_device_agents from devices.maas2.maas2 import Maas2 from snappy_device_agents import logmsg -from devices import (Catch, +from devices import (catch, DefaultDevice, RecoveryError, ProvisioningError, @@ -33,7 +33,7 @@ class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" - @Catch(RecoveryError, 46) + @catch(RecoveryError, 46) def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py index 9cf08492..0a312c10 100644 --- a/devices/muxpi/__init__.py +++ b/devices/muxpi/__init__.py @@ -20,7 +20,7 @@ import snappy_device_agents from devices.muxpi.muxpi import MuxPi from snappy_device_agents import logmsg -from devices import (Catch, +from devices import (catch, RecoveryError, DefaultDevice, SerialLogger) @@ -32,7 +32,7 @@ class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" - @Catch(RecoveryError, 46) + @catch(RecoveryError, 46) def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 388bbc64..101b4ae3 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -22,7 +22,7 @@ from devices.netboot.netboot import Netboot from snappy_device_agents import logmsg -from devices import (Catch, +from devices import (catch, DefaultDevice, ProvisioningError, RecoveryError, @@ -35,7 +35,7 @@ class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" - @Catch(RecoveryError, 46) + @catch(RecoveryError, 46) def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: diff --git a/devices/noprovision/__init__.py b/devices/noprovision/__init__.py index d6d649a2..f5993743 100644 --- a/devices/noprovision/__init__.py +++ b/devices/noprovision/__init__.py @@ -22,7 +22,7 @@ from devices.noprovision.noprovision import Noprovision from snappy_device_agents import logmsg -from devices import (Catch, +from devices import (catch, RecoveryError, DefaultDevice) @@ -30,7 +30,7 @@ class DeviceAgent(DefaultDevice): - @Catch(RecoveryError, 46) + @catch(RecoveryError, 46) def provision(self, args): with open(args.config) as configfile: config = yaml.safe_load(configfile) diff --git a/devices/oemrecovery/__init__.py b/devices/oemrecovery/__init__.py index 478d90be..1dcef943 100644 --- a/devices/oemrecovery/__init__.py +++ b/devices/oemrecovery/__init__.py @@ -20,7 +20,7 @@ import snappy_device_agents from devices.oemrecovery.oemrecovery import OemRecovery from snappy_device_agents import logmsg -from devices import (Catch, +from devices import (catch, RecoveryError, DefaultDevice) @@ -31,7 +31,7 @@ class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" - @Catch(RecoveryError, 46) + @catch(RecoveryError, 46) def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py index a2564c16..54e5770d 100644 --- a/devices/rpi3/__init__.py +++ b/devices/rpi3/__init__.py @@ -20,7 +20,7 @@ import snappy_device_agents from devices.rpi3.rpi3 import Rpi3 from snappy_device_agents import logmsg -from devices import (Catch, +from devices import (catch, DefaultDevice, RecoveryError, SerialLogger) @@ -32,7 +32,7 @@ class DeviceAgent(DefaultDevice): """Tool for provisioning baremetal with a given image.""" - @Catch(RecoveryError, 46) + @catch(RecoveryError, 46) def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: From e812fa9a8b8967f1586818dda53558d9b413f111 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 May 2022 14:31:57 -0500 Subject: [PATCH 355/569] Add tox and github workflow --- .github/workflows/tox.yml | 25 +++++++++++++++++++++++++ tox.ini | 15 +++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 .github/workflows/tox.yml create mode 100644 tox.ini diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 00000000..7dfcf2cd --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,25 @@ +name: Run unit tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.8", "3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python }} + - name: Install tox + run: pip install tox + - name: Run tests + run: | + tox diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..5873ed47 --- /dev/null +++ b/tox.ini @@ -0,0 +1,15 @@ +[tox] +envlist = py +skipsdist = true + +[testenv] +deps = + flake8 + pytest + pylint + pytest-cov +commands = + {envbindir}/python setup.py develop + {envbindir}/python -m flake8 setup.py snappy-device-agent snappy_device_agents devices + #{envbindir}/python -m pylint snappy-device-agent snappy_device_agents devices + {envbindir}/python -m pytest --doctest-modules --cov=snappy_device_agents --cov=devices From 4821d9ee487383680b581476272d28b7e0db802e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 May 2022 14:39:29 -0500 Subject: [PATCH 356/569] Add workflow --- .github/workflows/tox.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/tox.yml diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 00000000..7dfcf2cd --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,25 @@ +name: Run unit tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.8", "3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python }} + - name: Install tox + run: pip install tox + - name: Run tests + run: | + tox From 3a49cf1d5a2afa9c19ef14d861f2dffd56b70de6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 May 2022 14:33:27 -0500 Subject: [PATCH 357/569] Also remove tests from setup.py --- setup.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/setup.py b/setup.py index 864f92ae..77c58e30 100755 --- a/setup.py +++ b/setup.py @@ -28,10 +28,6 @@ datafiles = [(d, [os.path.join(d, f) for f in files]) for d, folders, files in os.walk('data')] -TEST_REQUIRES = [ - "pytest", -] - setup( name='snappy-device-agents', version=VERSION, @@ -46,6 +42,5 @@ setup_requires=['pytest-runner'], install_requires=['PyYAML>=3.11', 'netifaces>=0.10.4'], - tests_require=TEST_REQUIRES, scripts=['snappy-device-agent'], ) From 3e16dbca342d5fd460ad1dc06983984688feefcc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 13 May 2022 13:46:46 -0500 Subject: [PATCH 358/569] Add pyproject.toml with black options --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a8f43fef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 79 From 90b5aa466c3d6c34cba8311113e19cfb5348b986 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 13 May 2022 13:47:00 -0500 Subject: [PATCH 359/569] Reformat with python black --- setup.py | 17 +- testflinger-cli | 2 +- testflinger_cli/__init__.py | 479 ++++++++++++++++++------------ testflinger_cli/client.py | 38 +-- testflinger_cli/config.py | 15 +- testflinger_cli/history.py | 22 +- testflinger_cli/tests/test_cli.py | 39 ++- 7 files changed, 360 insertions(+), 252 deletions(-) diff --git a/setup.py b/setup.py index 49a7d8ff..88d5dd8e 100755 --- a/setup.py +++ b/setup.py @@ -16,20 +16,19 @@ # from setuptools import setup -INSTALL_REQUIRES = ['pyyaml', 'requests', 'xdg<4.0'] +INSTALL_REQUIRES = ["pyyaml", "requests", "xdg<4.0"] setup( - name='testflinger-cli', - version='0.1', - description='CLI tool for working with testflinger', - packages=['testflinger_cli'], + name="testflinger-cli", + version="0.1", + description="CLI tool for working with testflinger", + packages=["testflinger_cli"], zip_safe=False, install_requires=INSTALL_REQUIRES, - test_suite='testflinger_cli.tests', - entry_points=''' + test_suite="testflinger_cli.tests", + entry_points=""" [console_scripts] testflinger-cli=testflinger_cli:cli testflinger=testflinger_cli:cli - ''', - + """, ) diff --git a/testflinger-cli b/testflinger-cli index 4b6c8861..f3a0644c 100755 --- a/testflinger-cli +++ b/testflinger-cli @@ -19,5 +19,5 @@ from testflinger_cli import cli -if __name__ == '__main__': +if __name__ == "__main__": cli() diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 411d659f..79a03812 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -28,12 +28,12 @@ from datetime import datetime import yaml -from testflinger_cli import (client, config, history) +from testflinger_cli import client, config, history # Make it easier to run from a checkout -basedir = os.path.abspath(os.path.join(__file__, '..')) -if os.path.exists(os.path.join(basedir, 'setup.py')): +basedir = os.path.abspath(os.path.join(__file__, "..")) +if os.path.exists(os.path.join(basedir, "setup.py")): sys.path.insert(0, basedir) @@ -49,27 +49,34 @@ def cli(): def _get_image(images): image = "" flex_url = "" - if images and images[list(images.keys())[0]].startswith('url:'): + if images and images[list(images.keys())[0]].startswith("url:"): # If this device can take URLs, offer to let the user enter one # instead of just using the known images flex_url = "or URL for a valid image starting with http(s)://... " while not image or image == "?": - image = input("\nEnter the name of the image you want to use " + - flex_url + "('?' to list) ") + image = input( + "\nEnter the name of the image you want to use " + + flex_url + + "('?' to list) " + ) if image == "?": if not images: - print("WARNING: There are no images defined for this " - "device. You may also provide the URL to an image " - "that can be booted with this device though.") + print( + "WARNING: There are no images defined for this " + "device. You may also provide the URL to an image " + "that can be booted with this device though." + ) continue for image_id in sorted(images.keys()): print(" " + image_id) continue - if image.startswith(('http://', 'https://')): + if image.startswith(("http://", "https://")): return image if image not in images.keys(): - print("ERROR: '{}' is not in the list of known images for " - "that queue, please select another.".format(image)) + print( + "ERROR: '{}' is not in the list of known images for " + "that queue, please select another.".format(image) + ) image = "" return image @@ -77,113 +84,149 @@ def _get_image(images): def _get_ssh_keys(): ssh_keys = "" while not ssh_keys.strip(): - ssh_keys = input("\nEnter the ssh key(s) you wish to use: " - "(ex: lp:userid, gh:userid) ") + ssh_keys = input( + "\nEnter the ssh key(s) you wish to use: " + "(ex: lp:userid, gh:userid) " + ) key_list = [ssh_key.strip() for ssh_key in ssh_keys.split(",")] for ssh_key in key_list: - if (not ssh_key.startswith("lp:") and - not ssh_key.startswith("gh:")): + if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): ssh_keys = "" - print("Please enter keys in the form lp:userid " - "or gh:userid") + print( + "Please enter keys in the form lp:userid " "or gh:userid" + ) return key_list class TestflingerCli: """Class for handling the Testflinger CLI""" + def __init__(self): self.get_args() self.config = config.TestflingerCliConfig(self.args.configfile) server = ( - self.args.server or - self.config.get('server') or - os.environ.get('TESTFLINGER_SERVER') or - 'https://testflinger.canonical.com' + self.args.server + or self.config.get("server") + or os.environ.get("TESTFLINGER_SERVER") + or "https://testflinger.canonical.com" ) # Allow config subcommand without worrying about server or client - if (hasattr(self.args, 'func') and - self.args.func == self.configure): # pylint: disable=W0143 + if ( + hasattr(self.args, "func") + and self.args.func == self.configure # pylint: disable=W0143 + ): return - if not server.startswith(('http://', 'https://')): - raise SystemExit('Server must start with "http://" or "https://" ' - '- currently set to: "{}"'.format(server)) + if not server.startswith(("http://", "https://")): + raise SystemExit( + 'Server must start with "http://" or "https://" ' + '- currently set to: "{}"'.format(server) + ) self.client = client.Client(server) self.history = history.TestflingerCliHistory() def run(self): """Run the subcommand specified in command line arguments""" - if hasattr(self.args, 'func'): + if hasattr(self.args, "func"): raise SystemExit(self.args.func()) print(self.help) def get_args(self): """Handle command line arguments""" parser = ArgumentParser() - parser.add_argument('-c', '--configfile', default=None, - help='Configuration file to use') - parser.add_argument('--server', default=None, - help='Testflinger server to use') + parser.add_argument( + "-c", + "--configfile", + default=None, + help="Configuration file to use", + ) + parser.add_argument( + "--server", default=None, help="Testflinger server to use" + ) sub = parser.add_subparsers() arg_artifacts = sub.add_parser( - 'artifacts', - help='Download a tarball of artifacts saved for a specified job') + "artifacts", + help="Download a tarball of artifacts saved for a specified job", + ) arg_artifacts.set_defaults(func=self.artifacts) - arg_artifacts.add_argument('--filename', default='artifacts.tgz') - arg_artifacts.add_argument('job_id') + arg_artifacts.add_argument("--filename", default="artifacts.tgz") + arg_artifacts.add_argument("job_id") arg_cancel = sub.add_parser( - 'cancel', help='Tell the server to cancel a specified JOB_ID') + "cancel", help="Tell the server to cancel a specified JOB_ID" + ) arg_cancel.set_defaults(func=self.cancel) - arg_cancel.add_argument('job_id') + arg_cancel.add_argument("job_id") arg_config = sub.add_parser( - 'config', help='Get or set configuration options') + "config", help="Get or set configuration options" + ) arg_config.set_defaults(func=self.configure) - arg_config.add_argument('setting', nargs='?', help='setting=value') + arg_config.add_argument("setting", nargs="?", help="setting=value") arg_jobs = sub.add_parser( - 'jobs', - help='List the previously started test jobs' + "jobs", help="List the previously started test jobs" ) arg_jobs.set_defaults(func=self.jobs) - arg_jobs.add_argument('--status', '-s', action='store_true', - help='Include job status (may add delay)') + arg_jobs.add_argument( + "--status", + "-s", + action="store_true", + help="Include job status (may add delay)", + ) arg_list_queues = sub.add_parser( - 'list-queues', - help='List the advertised queues on the Testflinger server') + "list-queues", + help="List the advertised queues on the Testflinger server", + ) arg_list_queues.set_defaults(func=self.list_queues) arg_poll = sub.add_parser( - 'poll', help='Poll for output from a job until it is complete') + "poll", help="Poll for output from a job until it is complete" + ) arg_poll.set_defaults(func=self.poll) - arg_poll.add_argument('--oneshot', '-o', action='store_true', - help='Get latest output and exit immediately') - arg_poll.add_argument('job_id') + arg_poll.add_argument( + "--oneshot", + "-o", + action="store_true", + help="Get latest output and exit immediately", + ) + arg_poll.add_argument("job_id") arg_reserve = sub.add_parser( - 'reserve', help='Install and reserve a system') + "reserve", help="Install and reserve a system" + ) arg_reserve.set_defaults(func=self.reserve) - arg_reserve.add_argument('--queue', '-q', - help='Name of the queue to use') arg_reserve.add_argument( - '--image', '-i', help='Name of the image to use for provisioning') + "--queue", "-q", help="Name of the queue to use" + ) + arg_reserve.add_argument( + "--image", "-i", help="Name of the image to use for provisioning" + ) arg_reserve.add_argument( - '--key', '-k', nargs='*', - help=('Ssh key(s) to use for reservation ' - '(ex: -k lp:userid -k gh:userid)')) + "--key", + "-k", + nargs="*", + help=( + "Ssh key(s) to use for reservation " + "(ex: -k lp:userid -k gh:userid)" + ), + ) arg_results = sub.add_parser( - 'results', help='Get results JSON for a completed JOB_ID') + "results", help="Get results JSON for a completed JOB_ID" + ) arg_results.set_defaults(func=self.results) - arg_results.add_argument('job_id') + arg_results.add_argument("job_id") arg_show = sub.add_parser( - 'show', help='Show the requested job JSON for a specified JOB_ID') + "show", help="Show the requested job JSON for a specified JOB_ID" + ) arg_show.set_defaults(func=self.show) - arg_show.add_argument('job_id') + arg_show.add_argument("job_id") arg_status = sub.add_parser( - 'status', help='Show the status of a specified JOB_ID') + "status", help="Show the status of a specified JOB_ID" + ) arg_status.set_defaults(func=self.status) - arg_status.add_argument('job_id') + arg_status.add_argument("job_id") arg_submit = sub.add_parser( - 'submit', help='Submit a new test job to the server') + "submit", help="Submit a new test job to the server" + ) arg_submit.set_defaults(func=self.submit) - arg_submit.add_argument('--poll', '-p', action='store_true') - arg_submit.add_argument('--quiet', '-q', action='store_true') - arg_submit.add_argument('filename') + arg_submit.add_argument("--poll", "-p", action="store_true") + arg_submit.add_argument("--quiet", "-q", action="store_true") + arg_submit.add_argument("filename") self.args = parser.parse_args() self.help = parser.format_help() @@ -195,14 +238,20 @@ def status(self): self.history.update(self.args.job_id, job_state) except client.HTTPError as exc: if exc.status == 204: - raise SystemExit('No data found for that job id. Check the ' - 'job id to be sure it is correct') from exc + raise SystemExit( + "No data found for that job id. Check the " + "job id to be sure it is correct" + ) from exc if exc.status == 400: - raise SystemExit('Invalid job id specified. Check the job ' - 'id to be sure it is correct') from exc + raise SystemExit( + "Invalid job id specified. Check the job " + "id to be sure it is correct" + ) from exc if exc.status == 404: - raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') from exc + raise SystemExit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) from exc print(job_state) def cancel(self): @@ -212,35 +261,44 @@ def cancel(self): self.history.update(self.args.job_id, job_state) except client.HTTPError as exc: if exc.status == 204: - raise SystemExit('Job {} not found. Check the job ' - 'id to be sure it is ' - 'correct.'.format(self.args.job_id)) from exc + raise SystemExit( + "Job {} not found. Check the job " + "id to be sure it is " + "correct.".format(self.args.job_id) + ) from exc if exc.status == 400: - raise SystemExit('Invalid job id specified. Check the job ' - 'id to be sure it is correct.') from exc + raise SystemExit( + "Invalid job id specified. Check the job " + "id to be sure it is correct." + ) from exc if exc.status == 404: - raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') from exc - if job_state in ('complete', 'cancelled'): - raise SystemExit('Job {} is already in {} state and cannot be ' - 'cancelled.'.format(self.args.job_id, job_state)) + raise SystemExit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) from exc + if job_state in ("complete", "cancelled"): + raise SystemExit( + "Job {} is already in {} state and cannot be " + "cancelled.".format(self.args.job_id, job_state) + ) self.do_cancel(self.args.job_id) def do_cancel(self, job_id): """Send cancellation request for a specified job_id""" - self.client.post_job_state(job_id, 'cancelled') - self.history.update(job_id, 'cancelled') + self.client.post_job_state(job_id, "cancelled") + self.history.update(job_id, "cancelled") def configure(self): """Print or set configuration values""" if self.args.setting: - setting = self.args.setting.split('=') + setting = self.args.setting.split("=") if len(setting) == 2: self.config.set(*setting) return if len(setting) == 1: - print("{} = {}".format( - setting[0], self.config.get(setting[0]))) + print( + "{} = {}".format(setting[0], self.config.get(setting[0])) + ) return print("Current Configuration") print("---------------------") @@ -250,43 +308,50 @@ def configure(self): def submit(self): """Submit a new test job to the server""" - if self.args.filename == '-': + if self.args.filename == "-": data = sys.stdin.read() else: try: - with open(self.args.filename, encoding='utf-8', - errors='ignore') as job_file: + with open( + self.args.filename, encoding="utf-8", errors="ignore" + ) as job_file: data = job_file.read() except FileNotFoundError as exc: raise SystemExit( - 'File not found: {}'.format(self.args.filename)) from exc + "File not found: {}".format(self.args.filename) + ) from exc job_id = self.submit_job_data(data) - queue = yaml.safe_load(data).get('job_queue') + queue = yaml.safe_load(data).get("job_queue") self.history.new(job_id, queue) if self.args.quiet: print(job_id) else: - print('Job submitted successfully!') - print('job_id: {}'.format(job_id)) + print("Job submitted successfully!") + print("job_id: {}".format(job_id)) if self.args.poll: self.do_poll(job_id) def submit_job_data(self, data): - """ Submit data that was generated or read from a file as a test job - """ + """Submit data that was generated or read from a file as a test job""" try: job_id = self.client.submit_job(data) except client.HTTPError as exc: if exc.status == 400: - raise SystemExit('The job you submitted contained bad data or ' - 'bad formatting, or did not specify a ' - 'job_queue.') from exc + raise SystemExit( + "The job you submitted contained bad data or " + "bad formatting, or did not specify a " + "job_queue." + ) from exc if exc.status == 404: - raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') from exc + raise SystemExit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) from exc # This shouldn't happen, so let's get more information - raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(exc.status)) from exc + raise SystemExit( + "Unexpected error status from testflinger " + "server: {}".format(exc.status) + ) from exc return job_id def show(self): @@ -295,16 +360,22 @@ def show(self): results = self.client.show_job(self.args.job_id) except client.HTTPError as exc: if exc.status == 204: - raise SystemExit('No data found for that job id.') from exc + raise SystemExit("No data found for that job id.") from exc if exc.status == 400: - raise SystemExit('Invalid job id specified. Check the job id ' - 'to be sure it is correct') from exc + raise SystemExit( + "Invalid job id specified. Check the job id " + "to be sure it is correct" + ) from exc if exc.status == 404: - raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') from exc + raise SystemExit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) from exc # This shouldn't happen, so let's get more information - raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(exc.status)) from exc + raise SystemExit( + "Unexpected error status from testflinger " + "server: {}".format(exc.status) + ) from exc print(json.dumps(results, sort_keys=True, indent=4)) def results(self): @@ -313,38 +384,51 @@ def results(self): results = self.client.get_results(self.args.job_id) except client.HTTPError as exc: if exc.status == 204: - raise SystemExit('No results found for that job id.') from exc + raise SystemExit("No results found for that job id.") from exc if exc.status == 400: - raise SystemExit('Invalid job id specified. Check the job id ' - 'to be sure it is correct') from exc + raise SystemExit( + "Invalid job id specified. Check the job id " + "to be sure it is correct" + ) from exc if exc.status == 404: - raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') from exc + raise SystemExit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) from exc # This shouldn't happen, so let's get more information - raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(exc.status)) from exc + raise SystemExit( + "Unexpected error status from testflinger " + "server: {}".format(exc.status) + ) from exc print(json.dumps(results, sort_keys=True, indent=4)) def artifacts(self): """Download a tarball of artifacts saved for a specified job""" - print('Downloading artifacts tarball...') + print("Downloading artifacts tarball...") try: self.client.get_artifact(self.args.job_id, self.args.filename) except client.HTTPError as exc: if exc.status == 204: raise SystemExit( - 'No artifacts tarball found for that job id.') from exc + "No artifacts tarball found for that job id." + ) from exc if exc.status == 400: - raise SystemExit('Invalid job id specified. Check the job id ' - 'to be sure it is correct') from exc + raise SystemExit( + "Invalid job id specified. Check the job id " + "to be sure it is correct" + ) from exc if exc.status == 404: - raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') from exc + raise SystemExit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) from exc # This shouldn't happen, so let's get more information - raise SystemExit('Unexpected error status from testflinger ' - 'server: {}'.format(exc.status)) from exc - print('Artifacts downloaded to {}'.format(self.args.filename)) + raise SystemExit( + "Unexpected error status from testflinger " + "server: {}".format(exc.status) + ) from exc + print("Artifacts downloaded to {}".format(self.args.filename)) def poll(self): """Poll for output from a job until it is complete""" @@ -353,7 +437,7 @@ def poll(self): # Raise it since it's not running continuously in this mode output = self.get_latest_output(self.args.job_id) if output: - print(output, end='', flush=True) + print(output, end="", flush=True) sys.exit(0) self.do_poll(self.args.job_id) @@ -365,23 +449,23 @@ def do_poll(self, job_id): job_state = self.get_job_state(job_id) self.history.update(job_id, job_state) prev_queue_pos = None - if job_state == 'waiting': - print('This job is waiting on a node to become available.') - while job_state != 'complete': - if job_state == 'cancelled': + if job_state == "waiting": + print("This job is waiting on a node to become available.") + while job_state != "complete": + if job_state == "cancelled": break try: - if job_state == 'waiting': + if job_state == "waiting": try: queue_pos = self.client.get_job_position(job_id) if int(queue_pos) != prev_queue_pos: prev_queue_pos = int(queue_pos) - print('Jobs ahead in queue: {}'.format(queue_pos)) + print("Jobs ahead in queue: {}".format(queue_pos)) except IOError: # Ignore/retry any connection errors or timeouts pass time.sleep(10) - output = '' + output = "" try: output = self.get_latest_output(job_id) except IOError: @@ -390,17 +474,19 @@ def do_poll(self, job_id): # pass through the loop continue if output: - print(output, end='', flush=True) + print(output, end="", flush=True) job_state = self.get_job_state(job_id) self.history.update(job_id, job_state) except KeyboardInterrupt: - choice = input('\nCancel job {} before exiting ' - '(y)es/(N)o/(c)ontinue? '.format(job_id)) + choice = input( + "\nCancel job {} before exiting " + "(y)es/(N)o/(c)ontinue? ".format(job_id) + ) if choice: choice = choice[0].lower() - if choice == 'c': + if choice == "c": continue - if choice == 'y': + if choice == "y": self.do_cancel(job_id) # Both y and n will allow the external handler deal with it raise @@ -411,31 +497,33 @@ def jobs(self): """List the previously started test jobs""" if self.args.status: # Getting job state may be slow, only include if requested - status_text = 'Status' + status_text = "Status" else: - status_text = '' - print('{:36} {:9} {} {}'.format( - 'Job ID', - status_text, - 'Submission Time', - 'Queue' - )) - print('-'*79) + status_text = "" + print( + "{:36} {:9} {} {}".format( + "Job ID", status_text, "Submission Time", "Queue" + ) + ) + print("-" * 79) for job_id, jobdata in self.history.history.items(): if self.args.status: - job_state = jobdata.get('job_state') - if job_state not in ('cancelled', 'complete'): + job_state = jobdata.get("job_state") + if job_state not in ("cancelled", "complete"): job_state = self.get_job_state(job_id) self.history.update(job_id, job_state) else: - job_state = '' - print('{} {:9} {} {}'.format( - job_id, - job_state, - datetime.fromtimestamp( - jobdata.get('submission_time')).strftime('%a %b %d %H:%M'), - jobdata.get('queue') - )) + job_state = "" + print( + "{} {:9} {} {}".format( + job_id, + job_state, + datetime.fromtimestamp( + jobdata.get("submission_time") + ).strftime("%a %b %d %H:%M"), + jobdata.get("queue"), + ) + ) print() def list_queues(self): @@ -444,11 +532,13 @@ def list_queues(self): queues = self.client.get_queues() except client.HTTPError as exc: if exc.status == 404: - raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') from exc - print('Advertised queues on this server:') + raise SystemExit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) from exc + print("Advertised queues on this server:") for name, description in sorted(queues.items()): - print(' {} - {}'.format(name, description)) + print(" {} - {}".format(name, description)) def reserve(self): """Install and reserve a system""" @@ -459,19 +549,25 @@ def reserve(self): queues = {} queue = self.args.queue or self._get_queue(queues) if queue not in queues.keys(): - print("WARNING: '{}' is not in the list of known " - "queues".format(queue)) + print( + "WARNING: '{}' is not in the list of known " + "queues".format(queue) + ) try: images = self.client.get_images(queue) except OSError: print("WARNING: unable to get a list of images from the server!") images = {} image = self.args.image or _get_image(images) - if (not image.startswith(("http://", "https://")) and - image not in images.keys()): - raise SystemExit("ERROR: '{}' is not in the list of known " - "images for that queue, please select " - "another.".format(image)) + if ( + not image.startswith(("http://", "https://")) + and image not in images.keys() + ): + raise SystemExit( + "ERROR: '{}' is not in the list of known " + "images for that queue, please select " + "another.".format(image) + ) if image.startswith(("http://", "https://")): image = "url: " + image else: @@ -479,13 +575,16 @@ def reserve(self): ssh_keys = self.args.key or _get_ssh_keys() for ssh_key in ssh_keys: if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): - raise SystemExit("Please enter keys in the form lp:userid or " - "gh:userid") - template = inspect.cleandoc("""job_queue: {queue} + raise SystemExit( + "Please enter keys in the form lp:userid or " "gh:userid" + ) + template = inspect.cleandoc( + """job_queue: {queue} provision_data: {image} reserve_data: - ssh_keys:""") + ssh_keys:""" + ) for ssh_key in ssh_keys: template += "\n - {}".format(ssh_key) job_data = template.format(queue=queue, image=image) @@ -494,8 +593,8 @@ def reserve(self): answer = input("Proceed? (Y/n) ") if answer in ("Y", "y", ""): job_id = self.submit_job_data(job_data) - print('Job submitted successfully!') - print('job_id: {}'.format(job_id)) + print("Job submitted successfully!") + print("job_id: {}".format(job_id)) self.do_poll(job_id) def _get_queue(self, queues): @@ -510,8 +609,10 @@ def _get_queue(self, queues): print(" {} - {}".format(name, description)) queue = self._get_queue(queues) if queue not in queues.keys(): - print("WARNING: '{}' is not in the list of known " - "queues".format(queue)) + print( + "WARNING: '{}' is not in the list of known " + "queues".format(queue) + ) answer = input("Do you still want to use it? (y/N) ") if answer.lower() != "y": queue = "" @@ -523,7 +624,7 @@ def get_latest_output(self, job_id): :param str job_id: Job ID :return str: New output from the running job """ - output = '' + output = "" try: output = self.client.get_output(job_id) except client.HTTPError as exc: @@ -543,12 +644,18 @@ def get_job_state(self, job_id): return self.client.get_status(job_id) except client.HTTPError as exc: if exc.status == 204: - raise SystemExit('No data found for that job id. Check the ' - 'job id to be sure it is correct') from exc + raise SystemExit( + "No data found for that job id. Check the " + "job id to be sure it is correct" + ) from exc if exc.status == 400: - raise SystemExit('Invalid job id specified. Check the job id ' - 'to be sure it is correct') from exc + raise SystemExit( + "Invalid job id specified. Check the job id " + "to be sure it is correct" + ) from exc if exc.status == 404: - raise SystemExit('Received 404 error from server. Are you ' - 'sure this is a testflinger server?') from exc - return 'unknown' + raise SystemExit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) from exc + return "unknown" diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index 3f64a3c6..6ff0df10 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -27,13 +27,15 @@ class HTTPError(Exception): """Exception class for HTTP error codes""" + def __init__(self, status): super().__init__(status) self.status = status -class Client(): +class Client: """Testflinger connection client""" + def __init__(self, server): self.server = server @@ -48,10 +50,10 @@ def get(self, uri_frag, timeout=15): try: req = requests.get(uri, timeout=timeout) except requests.exceptions.ConnectTimeout: - print('Timeout while trying to communicate with the server.') + print("Timeout while trying to communicate with the server.") raise except requests.exceptions.ConnectionError: - print('Unable to communicate with specified server.') + print("Unable to communicate with specified server.") raise if req.status_code != 200: raise HTTPError(req.status_code) @@ -68,10 +70,10 @@ def put(self, uri_frag, data, timeout=15): try: req = requests.post(uri, json=data, timeout=timeout) except requests.exceptions.ConnectTimeout: - print('Timout while trying to communicate with the server.') + print("Timout while trying to communicate with the server.") sys.exit(1) except requests.exceptions.ConnectionError: - print('Unable to communicate with specified server.') + print("Unable to communicate with specified server.") sys.exit(1) if req.status_code != 200: raise HTTPError(req.status_code) @@ -87,9 +89,9 @@ def get_status(self, job_id): (waiting, setup, provision, test, reserved, released, cancelled, complete) """ - endpoint = '/v1/result/{}'.format(job_id) + endpoint = "/v1/result/{}".format(job_id) data = json.loads(self.get(endpoint)) - return data.get('job_state') + return data.get("job_state") def post_job_state(self, job_id, state): """Post the status of a test job @@ -99,7 +101,7 @@ def post_job_state(self, job_id, state): :param state: Job state to set for the specified job """ - endpoint = '/v1/result/{}'.format(job_id) + endpoint = "/v1/result/{}".format(job_id) data = dict(job_state=state) self.put(endpoint, data) @@ -111,10 +113,10 @@ def submit_job(self, job_data): :return: ID for the test job """ - endpoint = '/v1/job' + endpoint = "/v1/job" data = yaml.safe_load(job_data) response = self.put(endpoint, data) - return json.loads(response).get('job_id') + return json.loads(response).get("job_id") def show_job(self, job_id): """Show the JSON job definition for the specified ID @@ -124,7 +126,7 @@ def show_job(self, job_id): :return: JSON job definition for the specified ID """ - endpoint = '/v1/job/{}'.format(job_id) + endpoint = "/v1/job/{}".format(job_id) return json.loads(self.get(endpoint)) def get_results(self, job_id): @@ -135,7 +137,7 @@ def get_results(self, job_id): :return: Dict containing the results returned from the server """ - endpoint = '/v1/result/{}'.format(job_id) + endpoint = "/v1/result/{}".format(job_id) return json.loads(self.get(endpoint)) def get_artifact(self, job_id, path): @@ -146,12 +148,12 @@ def get_artifact(self, job_id, path): :param path: Path and filename for the artifact file """ - endpoint = '/v1/result/{}/artifact'.format(job_id) + endpoint = "/v1/result/{}/artifact".format(job_id) uri = urllib.parse.urljoin(self.server, endpoint) req = requests.get(uri, timeout=15) if req.status_code != 200: raise HTTPError(req.status_code) - with open(path, 'wb') as artifact: + with open(path, "wb") as artifact: for chunk in req.iter_content(chunk_size=4096): artifact.write(chunk) @@ -163,7 +165,7 @@ def get_output(self, job_id): :return: String containing the latest output from the job """ - endpoint = '/v1/result/{}/output'.format(job_id) + endpoint = "/v1/result/{}/output".format(job_id) return self.get(endpoint) def get_job_position(self, job_id): @@ -175,12 +177,12 @@ def get_job_position(self, job_id): String containing the queue position for the specified ID i.e. how many jobs are ahead of it in the queue """ - endpoint = '/v1/job/{}/position'.format(job_id) + endpoint = "/v1/job/{}/position".format(job_id) return self.get(endpoint) def get_queues(self): """Get the advertised queues from the testflinger server""" - endpoint = '/v1/agents/queues' + endpoint = "/v1/agents/queues" data = self.get(endpoint) try: return json.loads(data) @@ -189,7 +191,7 @@ def get_queues(self): def get_images(self, queue): """Get the advertised images from the testflinger server""" - endpoint = '/v1/agents/images/' + queue + endpoint = "/v1/agents/images/" + queue data = self.get(endpoint) try: return json.loads(data) diff --git a/testflinger_cli/config.py b/testflinger_cli/config.py index 96509c35..3e12140e 100644 --- a/testflinger_cli/config.py +++ b/testflinger_cli/config.py @@ -26,17 +26,19 @@ class TestflingerCliConfig: """TestflingerCliConfig class load values from files, env, and params""" + def __init__(self, configfile=None): config = configparser.ConfigParser() if not configfile: os.makedirs(xdg.XDG_CONFIG_HOME, exist_ok=True) configfile = os.path.join( - xdg.XDG_CONFIG_HOME, "testflinger-cli.conf") + xdg.XDG_CONFIG_HOME, "testflinger-cli.conf" + ) config.read(configfile) # Default empty config in case there's no config file self.data = OrderedDict() - if 'testflinger-cli' in config.sections(): - self.data = OrderedDict(config['testflinger-cli']) + if "testflinger-cli" in config.sections(): + self.data = OrderedDict(config["testflinger-cli"]) self.configfile = configfile def get(self, key): @@ -51,7 +53,8 @@ def set(self, key, value): def _save(self): """Save config back to the config file""" config = configparser.ConfigParser() - config.read_dict({'testflinger-cli': self.data}) - with open(self.configfile, 'w', encoding='utf-8', - errors='ignore') as config_file: + config.read_dict({"testflinger-cli": self.data}) + with open( + self.configfile, "w", encoding="utf-8", errors="ignore" + ) as config_file: config.write(config_file) diff --git a/testflinger_cli/history.py b/testflinger_cli/history.py index 3364d30d..f6c57957 100644 --- a/testflinger_cli/history.py +++ b/testflinger_cli/history.py @@ -27,19 +27,19 @@ class TestflingerCliHistory: """History class used for storing job history on a device""" + def __init__(self): os.makedirs(xdg.XDG_DATA_HOME, exist_ok=True) self.historyfile = os.path.join( - xdg.XDG_DATA_HOME, "testflinger-cli-history.json") + xdg.XDG_DATA_HOME, "testflinger-cli-history.json" + ) self.load() def new(self, job_id, queue): """Add a new job to the history""" submission_time = datetime.now().timestamp() self.history[job_id] = dict( - queue=queue, - submission_time=submission_time, - job_state='unknown' + queue=queue, submission_time=submission_time, job_state="unknown" ) # limit job history to last 10 jobs if len(self.history) > 10: @@ -48,11 +48,12 @@ def new(self, job_id, queue): def load(self): """Load the history file""" - if not hasattr(self, 'history'): + if not hasattr(self, "history"): self.history = OrderedDict() if os.path.exists(self.historyfile): - with open(self.historyfile, encoding='utf-8', - errors='ignore') as history_file: + with open( + self.historyfile, encoding="utf-8", errors="ignore" + ) as history_file: try: self.history.update(json.load(history_file)) except (OSError, ValueError): @@ -61,12 +62,13 @@ def load(self): def save(self): """Save the history out to the history file""" - with open(self.historyfile, 'w', encoding='utf-8', - errors='ignore') as history_file: + with open( + self.historyfile, "w", encoding="utf-8", errors="ignore" + ) as history_file: json.dump(self.history, history_file, indent=2) def update(self, job_id, state): """Update job state in the history file""" if job_id in self.history: - self.history[job_id]['job_state'] = state + self.history[job_id]["job_state"] = state self.save() diff --git a/testflinger_cli/tests/test_cli.py b/testflinger_cli/tests/test_cli.py index 00206ace..0d374518 100644 --- a/testflinger_cli/tests/test_cli.py +++ b/testflinger_cli/tests/test_cli.py @@ -30,11 +30,11 @@ def test_status(capsys, requests_mock): - """ Status should report job_state data """ + """Status should report job_state data""" jobid = str(uuid.uuid1()) fake_return = {"job_state": "complete"} - requests_mock.get(URL+"/v1/result/"+jobid, json=fake_return) - sys.argv = ['', 'status', jobid] + requests_mock.get(URL + "/v1/result/" + jobid, json=fake_return) + sys.argv = ["", "status", jobid] tfcli = testflinger_cli.TestflingerCli() tfcli.status() std = capsys.readouterr() @@ -42,12 +42,12 @@ def test_status(capsys, requests_mock): def test_cancel(requests_mock): - """ Cancel should fail if job is already complete """ + """Cancel should fail if job is already complete""" jobid = str(uuid.uuid1()) fake_return = {"job_state": "complete"} - requests_mock.get(URL+"/v1/result/"+jobid, json=fake_return) - requests_mock.post(URL+"/v1/result/"+jobid) - sys.argv = ['', 'cancel', jobid] + requests_mock.get(URL + "/v1/result/" + jobid, json=fake_return) + requests_mock.post(URL + "/v1/result/" + jobid) + sys.argv = ["", "cancel", jobid] tfcli = testflinger_cli.TestflingerCli() with pytest.raises(SystemExit) as err: tfcli.cancel() @@ -55,19 +55,14 @@ def test_cancel(requests_mock): def test_submit(capsys, tmp_path, requests_mock): - """ Make sure jobid is read back from submitted job """ + """Make sure jobid is read back from submitted job""" jobid = str(uuid.uuid1()) - fake_data = { - "queue": "fake", - "provision_data": { - "distro": "fake" - } - } + fake_data = {"queue": "fake", "provision_data": {"distro": "fake"}} testfile = tmp_path / "test.json" testfile.write_text(json.dumps(fake_data)) fake_return = {"job_id": jobid} - requests_mock.post(URL+"/v1/job", json=fake_return) - sys.argv = ['', 'submit', str(testfile)] + requests_mock.post(URL + "/v1/job", json=fake_return) + sys.argv = ["", "submit", str(testfile)] tfcli = testflinger_cli.TestflingerCli() tfcli.submit() std = capsys.readouterr() @@ -75,11 +70,11 @@ def test_submit(capsys, tmp_path, requests_mock): def test_show(capsys, requests_mock): - """ Exercise show command """ + """Exercise show command""" jobid = str(uuid.uuid1()) fake_return = {"job_state": "complete"} - requests_mock.get(URL+"/v1/job/"+jobid, json=fake_return) - sys.argv = ['', 'show', jobid] + requests_mock.get(URL + "/v1/job/" + jobid, json=fake_return) + sys.argv = ["", "show", jobid] tfcli = testflinger_cli.TestflingerCli() tfcli.show() std = capsys.readouterr() @@ -87,11 +82,11 @@ def test_show(capsys, requests_mock): def test_results(capsys, requests_mock): - """ results should report job_state data """ + """results should report job_state data""" jobid = str(uuid.uuid1()) fake_return = {"job_state": "complete"} - requests_mock.get(URL+"/v1/result/"+jobid, json=fake_return) - sys.argv = ['', 'results', jobid] + requests_mock.get(URL + "/v1/result/" + jobid, json=fake_return) + sys.argv = ["", "results", jobid] tfcli = testflinger_cli.TestflingerCli() tfcli.results() std = capsys.readouterr() From 6311e7046ed904f37c99a4c92b94807a5a96f2fb Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 13 May 2022 13:49:40 -0500 Subject: [PATCH 360/569] Add black format checking as part of the test process --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index e51593c2..87ba5bee 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ skipsdist = true setenv = HOME = {envtmpdir} deps = + black flake8 mock pytest @@ -15,6 +16,7 @@ deps = requests-mock commands = {envbindir}/python setup.py develop + {envbindir}/python -m black --check setup.py testflinger-cli testflinger_cli {envbindir}/python -m flake8 setup.py testflinger_cli {envbindir}/python -m pylint testflinger_cli {envbindir}/python -m pytest --doctest-modules --cov=. From 2b10fbcebb4d0e7f722b27f9d4aa54718ab78e88 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 13 May 2022 13:59:55 -0500 Subject: [PATCH 361/569] Add github actions --- .github/workflows/tox.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/tox.yml diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 00000000..7dfcf2cd --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,25 @@ +name: Run unit tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.8", "3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python }} + - name: Install tox + run: pip install tox + - name: Run tests + run: | + tox From ade9dfa627bd8c3d8dcf9b53ff691a38b3b86667 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 16 May 2022 11:03:41 -0500 Subject: [PATCH 362/569] Add automatic format checking with black --- pyproject.toml | 2 ++ tox.ini | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a8f43fef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 79 diff --git a/tox.ini b/tox.ini index a458b525..1305fc8b 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ skipsdist = true setenv = HOME = {envtmpdir} deps = + black flake8 mock pytest @@ -15,5 +16,6 @@ deps = requests-mock commands = {envbindir}/python setup.py develop + {envbindir}/python -m black --check setup.py testflinger-agent testflinger_agent {envbindir}/python -m flake8 setup.py testflinger_agent {envbindir}/python -m pytest --doctest-modules --cov=testflinger_agent From 566333f363951f5b1ffd565313580a9ea679bf34 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 16 May 2022 11:03:56 -0500 Subject: [PATCH 363/569] Fix black formatting recommendations --- setup.py | 10 +- testflinger-agent | 4 +- testflinger_agent/__init__.py | 43 +++-- testflinger_agent/agent.py | 111 ++++++------ testflinger_agent/client.py | 114 +++++++------ testflinger_agent/errors.py | 2 +- testflinger_agent/job.py | 130 ++++++++------ testflinger_agent/schema.py | 47 +++--- testflinger_agent/tests/test_agent.py | 223 ++++++++++++++----------- testflinger_agent/tests/test_client.py | 14 +- testflinger_agent/tests/test_config.py | 13 +- testflinger_agent/tests/test_job.py | 115 ++++++------- 12 files changed, 464 insertions(+), 362 deletions(-) diff --git a/setup.py b/setup.py index 17811cdf..c9928c76 100755 --- a/setup.py +++ b/setup.py @@ -25,12 +25,12 @@ ] setup( - name='testflinger-agent', - version='1.0', + name="testflinger-agent", + version="1.0", long_description=__doc__, - packages=['testflinger_agent'], + packages=["testflinger_agent"], zip_safe=False, install_requires=INSTALL_REQUIRES, - setup_requires=['pytest-runner'], - scripts=['testflinger-agent'], + setup_requires=["pytest-runner"], + scripts=["testflinger-agent"], ) diff --git a/testflinger-agent b/testflinger-agent index 55cba80a..53ba7005 100755 --- a/testflinger-agent +++ b/testflinger-agent @@ -20,11 +20,11 @@ from testflinger_agent import main logger = logging.getLogger(__name__) -if __name__ == '__main__': +if __name__ == "__main__": try: main() except KeyboardInterrupt: - logger.info('Caught interrupt, exiting!') + logger.info("Caught interrupt, exiting!") sys.exit(0) except Exception as e: logger.exception(e) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index b03e70a1..f248f28a 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -30,15 +30,17 @@ def main(): args = parse_args() config = load_config(args.config) configure_logging(config) - check_interval = config.get('polling_interval') + check_interval = config.get("polling_interval") client = TestflingerClient(config) agent = TestflingerAgent(client) while True: offline_file = agent.check_offline() if offline_file: - logger.error("Agent %s is offline, not processing jobs! " - "Remove %s to resume processing" % - (config.get('agent_id'), offline_file)) + logger.error( + "Agent %s is offline, not processing jobs! " + "Remove %s to resume processing" + % (config.get("agent_id"), offline_file) + ) while agent.check_offline(): time.sleep(check_interval) logger.info("Checking jobs") @@ -57,24 +59,25 @@ def load_config(configfile): def configure_logging(config): # Create these at the beginning so we fail early if there are # permission problems - os.makedirs(config.get('logging_basedir'), exist_ok=True) - os.makedirs(config.get('results_basedir'), exist_ok=True) - log_level = logging.getLevelName(config.get('logging_level')) + os.makedirs(config.get("logging_basedir"), exist_ok=True) + os.makedirs(config.get("results_basedir"), exist_ok=True) + log_level = logging.getLevelName(config.get("logging_level")) # This should help if they specify something invalid if not isinstance(log_level, int): log_level = logging.INFO logfmt = logging.Formatter( - fmt='[%(asctime)s] %(levelname)+7.7s: %(message)s', - datefmt='%y-%m-%d %H:%M:%S') + fmt="[%(asctime)s] %(levelname)+7.7s: %(message)s", + datefmt="%y-%m-%d %H:%M:%S", + ) log_path = os.path.join( - config.get('logging_basedir'), 'testflinger-agent.log') - file_log = TimedRotatingFileHandler(log_path, - when="midnight", - interval=1, - backupCount=6) + config.get("logging_basedir"), "testflinger-agent.log" + ) + file_log = TimedRotatingFileHandler( + log_path, when="midnight", interval=1, backupCount=6 + ) file_log.setFormatter(logfmt) logger.addHandler(file_log) - if not config.get('logging_quiet'): + if not config.get("logging_quiet"): console_log = logging.StreamHandler() console_log.setFormatter(logfmt) logger.addHandler(console_log) @@ -82,7 +85,11 @@ def configure_logging(config): def parse_args(): - parser = argparse.ArgumentParser(description='Testflinger Agent') - parser.add_argument('--config', '-c', default='testflinger-agent.conf', - help='Testflinger agent config file') + parser = argparse.ArgumentParser(description="Testflinger Agent") + parser.add_argument( + "--config", + "-c", + default="testflinger-agent.conf", + help="Testflinger agent config file", + ) return parser.parse_args() diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 659df634..dfb259e9 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -28,13 +28,14 @@ class TestflingerAgent: def __init__(self, client): self.client = client - self._state = multiprocessing.Array('c', 16) - self.set_state('waiting') - self.advertised_queues = self.client.config.get('advertised_queues') - self.advertised_images = self.client.config.get('advertised_images') + self._state = multiprocessing.Array("c", 16) + self.set_state("waiting") + self.advertised_queues = self.client.config.get("advertised_queues") + self.advertised_images = self.client.config.get("advertised_images") if self.advertised_queues or self.advertised_images: self.status_proc = multiprocessing.Process( - target=self._status_worker) + target=self._status_worker + ) self.status_proc.daemon = True self.status_proc.start() @@ -42,7 +43,7 @@ def _status_worker(self): # Report advertised queues to testflinger server when we are listening while True: # Post every 2min unless the agent is offline - if self._state.value.decode('utf-8') != 'offline': + if self._state.value.decode("utf-8") != "offline": if self.advertised_queues: self.client.post_queues(self.advertised_queues) if self.advertised_images: @@ -50,17 +51,19 @@ def _status_worker(self): time.sleep(120) def set_state(self, state): - self._state.value = state.encode('utf-8') + self._state.value = state.encode("utf-8") def get_offline_files(self): # Return possible restart filenames with and without dashes # i.e. support both: # TESTFLINGER-DEVICE-OFFLINE-devname-001 # TESTFLINGER-DEVICE-OFFLINE-devname001 - agent = self.client.config.get('agent_id') + agent = self.client.config.get("agent_id") files = [ - '/tmp/TESTFLINGER-DEVICE-OFFLINE-{}'.format(agent), - '/tmp/TESTFLINGER-DEVICE-OFFLINE-{}'.format(agent.replace('-', '')) + "/tmp/TESTFLINGER-DEVICE-OFFLINE-{}".format(agent), + "/tmp/TESTFLINGER-DEVICE-OFFLINE-{}".format( + agent.replace("-", "") + ), ] return files @@ -69,10 +72,12 @@ def get_restart_files(self): # i.e. support both: # TESTFLINGER-DEVICE-RESTART-devname-001 # TESTFLINGER-DEVICE-RESTART-devname001 - agent = self.client.config.get('agent_id') + agent = self.client.config.get("agent_id") files = [ - '/tmp/TESTFLINGER-DEVICE-RESTART-{}'.format(agent), - '/tmp/TESTFLINGER-DEVICE-RESTART-{}'.format(agent.replace('-', '')) + "/tmp/TESTFLINGER-DEVICE-RESTART-{}".format(agent), + "/tmp/TESTFLINGER-DEVICE-RESTART-{}".format( + agent.replace("-", "") + ), ] return files @@ -80,10 +85,10 @@ def check_offline(self): possible_files = self.get_offline_files() for offline_file in possible_files: if os.path.exists(offline_file): - self.set_state('offline') + self.set_state("offline") return offline_file - self.set_state('waiting') - return '' + self.set_state("waiting") + return "" def check_restart(self): possible_files = self.get_restart_files() @@ -92,25 +97,26 @@ def check_restart(self): try: os.unlink(restart_file) logger.info("Restarting agent") - self.set_state('offline') + self.set_state("offline") raise SystemExit("Restart Requested") except OSError: logger.error( - "Restart requested, but unable to remove marker file!") + "Restart requested, but unable to remove marker file!" + ) break def check_job_state(self, job_id): job_data = self.client.get_result(job_id) if job_data: - return job_data.get('job_state') + return job_data.get("job_state") def mark_device_offline(self): # Create the offline file, this should work even if it exists - open(self.get_offline_files()[0], 'w').close() + open(self.get_offline_files()[0], "w").close() def process_jobs(self): """Coordinate checking for new jobs and handling them if they exists""" - TEST_PHASES = ['setup', 'provision', 'test', 'reserve'] + TEST_PHASES = ["setup", "provision", "test", "reserve"] # First, see if we have any old results that we couldn't send last time self.retry_old_results() @@ -123,42 +129,48 @@ def process_jobs(self): job = TestflingerJob(job_data, self.client) logger.info("Starting job %s", job.job_id) rundir = os.path.join( - self.client.config.get('execution_basedir'), - job.job_id) + self.client.config.get("execution_basedir"), job.job_id + ) os.makedirs(rundir) # Dump the job data to testflinger.json in our execution dir - with open(os.path.join(rundir, 'testflinger.json'), 'w') as f: + with open(os.path.join(rundir, "testflinger.json"), "w") as f: json.dump(job_data, f) # Create json outcome file where phases will store their output with open( - os.path.join(rundir, 'testflinger-outcome.json'), - 'w') as f: + os.path.join(rundir, "testflinger-outcome.json"), "w" + ) as f: json.dump({}, f) for phase in TEST_PHASES: # First make sure the job hasn't been cancelled - if self.check_job_state(job.job_id) == 'cancelled': + if self.check_job_state(job.job_id) == "cancelled": logger.info("Job cancellation was requested, exiting.") break # Try to update the job_state on the testflinger server try: self.client.post_result( - job.job_id, {'job_state': phase}) + job.job_id, {"job_state": phase} + ) except TFServerError: pass self.set_state(phase) - proc = multiprocessing.Process(target=job.run_test_phase, - args=( - phase, - rundir, - )) + proc = multiprocessing.Process( + target=job.run_test_phase, + args=( + phase, + rundir, + ), + ) proc.start() while proc.is_alive(): proc.join(10) - if (self.check_job_state(job.job_id) == 'cancelled' - and phase != 'provision'): + if ( + self.check_job_state(job.job_id) == "cancelled" + and phase != "provision" + ): logger.info( - "Job cancellation was requested, exiting.") + "Job cancellation was requested, exiting." + ) proc.terminate() exitcode = proc.exitcode @@ -170,18 +182,20 @@ def process_jobs(self): shutil.rmtree(rundir) # Return NOW so we don't keep trying to process jobs return - if phase != 'test' and exitcode: - logger.debug('Phase %s failed, aborting job' % phase) + if phase != "test" and exitcode: + logger.debug("Phase %s failed, aborting job" % phase) break except Exception as e: logger.exception(e) finally: # Always run the cleanup, even if the job was cancelled - proc = multiprocessing.Process(target=job.run_test_phase, - args=( - 'cleanup', - rundir, - )) + proc = multiprocessing.Process( + target=job.run_test_phase, + args=( + "cleanup", + rundir, + ), + ) proc.start() proc.join() @@ -192,9 +206,9 @@ def process_jobs(self): # Other errors can happen too for things like connection # problems logger.exception(e) - results_basedir = self.client.config.get('results_basedir') + results_basedir = self.client.config.get("results_basedir") shutil.move(rundir, results_basedir) - self.set_state('waiting') + self.set_state("waiting") self.check_restart() if self.check_offline(): @@ -205,16 +219,17 @@ def process_jobs(self): def retry_old_results(self): """Retry sending results that we previously failed to send""" - results_dir = self.client.config.get('results_basedir') + results_dir = self.client.config.get("results_basedir") # List all the directories in 'results_basedir', where we store the # results that we couldn't transmit before old_results = [ - os.path.join(results_dir, d) for d in os.listdir(results_dir) + os.path.join(results_dir, d) + for d in os.listdir(results_dir) if os.path.isdir(os.path.join(results_dir, d)) ] for result in old_results: try: - logger.info('Attempting to send result: %s' % result) + logger.info("Attempting to send result: %s" % result) self.client.transmit_job_outcome(result) except TFServerError: # Problems still, better luck next time? diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 277d151a..e3d3d15b 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -33,9 +33,10 @@ class TestflingerClient: def __init__(self, config): self.config = config self.server = self.config.get( - 'server_address', 'https://testflinger.canonical.com') - if not self.server.lower().startswith('http'): - self.server = 'http://' + self.server + "server_address", "https://testflinger.canonical.com" + ) + if not self.server.lower().startswith("http"): + self.server = "http://" + self.server def _requests_retry(self, retries=3): session = requests.Session() @@ -43,12 +44,12 @@ def _requests_retry(self, retries=3): total=retries, read=retries, connect=retries, - backoff_factor=.3, - status_forcelist=(500, 502, 503, 504) + backoff_factor=0.3, + status_forcelist=(500, 502, 503, 504), ) adapter = HTTPAdapter(max_retries=retry) - session.mount('http://', adapter) - session.mount('https://', adapter) + session.mount("http://", adapter) + session.mount("https://", adapter) return session def check_jobs(self): @@ -57,11 +58,12 @@ def check_jobs(self): :return: Dict with job data, or None if no job found """ try: - job_uri = urljoin(self.server, '/v1/job') - queue_list = self.config.get('job_queues') + job_uri = urljoin(self.server, "/v1/job") + queue_list = self.config.get("job_queues") logger.debug("Requesting a job") - job_request = requests.get(job_uri, params={'queue': queue_list}, - timeout=10) + job_request = requests.get( + job_uri, params={"queue": queue_list}, timeout=10 + ) if job_request.content: return job_request.json() else: @@ -72,28 +74,32 @@ def check_jobs(self): time.sleep(60) def repost_job(self, job_data): - """"Resubmit the job to the testflinger server with the same id + """ "Resubmit the job to the testflinger server with the same id :param job_id: id for the job on which we want to post results """ - job_uri = urljoin(self.server, '/v1/job') - job_id = job_data.get('job_id') - logger.info('Resubmitting job: %s', job_id) + job_uri = urljoin(self.server, "/v1/job") + job_id = job_data.get("job_id") + logger.info("Resubmitting job: %s", job_id) job_output = """ There was an unrecoverable error while running this stage. Your job will attempt to be automatically resubmitted back to the queue. - Resubmitting job: {}\n""".format(job_id) + Resubmitting job: {}\n""".format( + job_id + ) self.post_live_output(job_id, job_output) try: session = self._requests_retry(retries=5) job_request = session.post(job_uri, json=job_data) except Exception as e: logger.exception(e) - raise TFServerError('other exception') + raise TFServerError("other exception") if not job_request: - logger.error('Unable to re-post job to: %s (error: %s)' % - (job_uri, job_request.status_code)) + logger.error( + "Unable to re-post job to: %s (error: %s)" + % (job_uri, job_request.status_code) + ) raise TFServerError(job_request.status_code) def post_result(self, job_id, data): @@ -104,16 +110,18 @@ def post_result(self, job_id, data): :param data: dict with data to be posted in json """ - result_uri = urljoin(self.server, '/v1/result/') + result_uri = urljoin(self.server, "/v1/result/") result_uri = urljoin(result_uri, job_id) try: job_request = requests.post(result_uri, json=data, timeout=30) except Exception as e: logger.exception(e) - raise TFServerError('other exception') + raise TFServerError("other exception") if not job_request: - logger.error('Unable to post results to: %s (error: %s)' % - (result_uri, job_request.status_code)) + logger.error( + "Unable to post results to: %s (error: %s)" + % (result_uri, job_request.status_code) + ) raise TFServerError(job_request.status_code) def get_result(self, job_id): @@ -125,7 +133,7 @@ def get_result(self, job_id): dict with data to be posted in json or an empty dict if there was an error """ - result_uri = urljoin(self.server, '/v1/result/') + result_uri = urljoin(self.server, "/v1/result/") result_uri = urljoin(result_uri, job_id) try: job_request = requests.get(result_uri, timeout=30) @@ -133,8 +141,10 @@ def get_result(self, job_id): logger.exception(e) return {} if not job_request: - logger.error('Unable to get results from: %s (error: %s)' % - (result_uri, job_request.status_code)) + logger.error( + "Unable to get results from: %s (error: %s)" + % (result_uri, job_request.status_code) + ) return {} if job_request.content: return job_request.json() @@ -147,39 +157,47 @@ def transmit_job_outcome(self, rundir): :param rundir: Execution dir where the results can be found """ - with open(os.path.join(rundir, 'testflinger.json')) as f: + with open(os.path.join(rundir, "testflinger.json")) as f: job_data = json.load(f) - job_id = job_data.get('job_id') + job_id = job_data.get("job_id") # If we find an 'artifacts' dir under rundir, archive it, and transmit # it to the Testflinger server - artifacts_dir = os.path.join(rundir, 'artifacts') + artifacts_dir = os.path.join(rundir, "artifacts") if os.path.isdir(artifacts_dir): with tempfile.TemporaryDirectory() as tmpdir: - artifact_file = os.path.join(tmpdir, 'artifacts') - shutil.make_archive(artifact_file, format='gztar', - root_dir=rundir, base_dir='artifacts') + artifact_file = os.path.join(tmpdir, "artifacts") + shutil.make_archive( + artifact_file, + format="gztar", + root_dir=rundir, + base_dir="artifacts", + ) # Create uri for API: /v1/result/ artifact_uri = urljoin( - self.server, - '/v1/result/{}/artifact'.format(job_id)) - with open(artifact_file+'.tar.gz', 'rb') as tarball: + self.server, "/v1/result/{}/artifact".format(job_id) + ) + with open(artifact_file + ".tar.gz", "rb") as tarball: file_upload = { - 'file': ('file', tarball, 'application/x-gzip')} + "file": ("file", tarball, "application/x-gzip") + } artifact_request = requests.post( - artifact_uri, files=file_upload, timeout=600) + artifact_uri, files=file_upload, timeout=600 + ) if not artifact_request: - logger.error('Unable to post results to: %s (error: %s)' % - (artifact_uri, artifact_request.status_code)) + logger.error( + "Unable to post results to: %s (error: %s)" + % (artifact_uri, artifact_request.status_code) + ) raise TFServerError(artifact_request.status_code) else: shutil.rmtree(artifacts_dir) # Do not retransmit outcome if it's already been done and removed - outcome_file = os.path.join(rundir, 'testflinger-outcome.json') + outcome_file = os.path.join(rundir, "testflinger-outcome.json") if os.path.isfile(outcome_file): - logger.info('Submitting job outcome for job: %s' % job_id) + logger.info("Submitting job outcome for job: %s" % job_id) with open(outcome_file) as f: data = json.load(f) - data['job_state'] = 'complete' + data["job_state"] = "complete" self.post_result(job_id, data) # Remove the outcome file so we don't retransmit os.unlink(outcome_file) @@ -193,11 +211,13 @@ def post_live_output(self, job_id, data): :param data: string with latest output data """ - output_uri = urljoin(self.server, - '/v1/result/{}/output'.format(job_id)) + output_uri = urljoin( + self.server, "/v1/result/{}/output".format(job_id) + ) try: job_request = requests.post( - output_uri, data=data.encode('utf-8'), timeout=60) + output_uri, data=data.encode("utf-8"), timeout=60 + ) except Exception as e: logger.exception(e) return False @@ -209,7 +229,7 @@ def post_queues(self, data): :param data: dict of queue name and descriptions to send to the server """ - queues_uri = urljoin(self.server, '/v1/agents/queues') + queues_uri = urljoin(self.server, "/v1/agents/queues") try: requests.post(queues_uri, json=data, timeout=30) except Exception as e: @@ -221,7 +241,7 @@ def post_images(self, data): :param data: dict of queues containing dicts of imgae names and provision data """ - images_uri = urljoin(self.server, '/v1/agents/images') + images_uri = urljoin(self.server, "/v1/agents/images") try: requests.post(images_uri, json=data, timeout=30) except Exception as e: diff --git a/testflinger_agent/errors.py b/testflinger_agent/errors.py index d4b6db3b..17010491 100644 --- a/testflinger_agent/errors.py +++ b/testflinger_agent/errors.py @@ -16,7 +16,7 @@ class TFServerError(Exception): def __init__(self, m): self.code = m - self.message = 'HTTP Status: {}'.format(m) + self.message = "HTTP Status: {}".format(m) def __str__(self): return self.message diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index b717d32e..11a97175 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -35,8 +35,8 @@ def __init__(self, job_data, client): """ self.client = client self.job_data = job_data - self.job_id = job_data.get('job_id') - self.phase = 'unknown' + self.job_id = job_data.get("job_id") + self.phase = "unknown" def run_test_phase(self, phase, rundir): """Run the specified test phase in rundir @@ -50,50 +50,54 @@ def run_test_phase(self, phase, rundir): if there was no command to run """ self.phase = phase - cmd = self.client.config.get(phase+'_command') - node = self.client.config.get('agent_id') + cmd = self.client.config.get(phase + "_command") + node = self.client.config.get("agent_id") if not cmd: - logger.info('No %s_command configured, skipping...', phase) + logger.info("No %s_command configured, skipping...", phase) return 0 - if phase == 'provision' and not self.job_data.get('provision_data'): - logger.info('No provision_data defined in job data, skipping...') + if phase == "provision" and not self.job_data.get("provision_data"): + logger.info("No provision_data defined in job data, skipping...") return 0 - if phase == 'test' and not self.job_data.get('test_data'): - logger.info('No test_data defined in job data, skipping...') + if phase == "test" and not self.job_data.get("test_data"): + logger.info("No test_data defined in job data, skipping...") return 0 - if phase == 'reserve' and not self.job_data.get('reserve_data'): + if phase == "reserve" and not self.job_data.get("reserve_data"): return 0 - output_log = os.path.join(rundir, phase+'.log') - serial_log = os.path.join(rundir, phase+'-serial.log') - logger.info('Running %s_command: %s', phase, cmd) + output_log = os.path.join(rundir, phase + ".log") + serial_log = os.path.join(rundir, phase + "-serial.log") + logger.info("Running %s_command: %s", phase, cmd) # Set the exitcode to some failed status in case we get interrupted exitcode = 99 for line in self.banner( - 'Starting testflinger {} phase on {}'.format(phase, node)): + "Starting testflinger {} phase on {}".format(phase, node) + ): self.run_with_log("echo '{}'".format(line), output_log, rundir) try: exitcode = self.run_with_log(cmd, output_log, rundir) except Exception as e: logger.exception(e) finally: - with open(os.path.join(rundir, 'testflinger-outcome.json')) as f: + with open(os.path.join(rundir, "testflinger-outcome.json")) as f: outcome_data = json.load(f) if os.path.exists(output_log): - with open(output_log, 'r+', encoding='utf-8') as f: + with open(output_log, "r+", encoding="utf-8") as f: self._set_truncate(f) - outcome_data[phase+'_output'] = f.read() + outcome_data[phase + "_output"] = f.read() if os.path.exists(serial_log): - with open(serial_log, 'r+', encoding='utf-8') as f: + with open(serial_log, "r+", encoding="utf-8") as f: self._set_truncate(f) - outcome_data[phase+'_serial'] = f.read() - outcome_data[phase+'_status'] = exitcode - with open(os.path.join(rundir, 'testflinger-outcome.json'), - 'w', encoding='utf-8') as f: + outcome_data[phase + "_serial"] = f.read() + outcome_data[phase + "_status"] = exitcode + with open( + os.path.join(rundir, "testflinger-outcome.json"), + "w", + encoding="utf-8", + ) as f: json.dump(outcome_data, f) sys.exit(exitcode) - def _set_truncate(self, f, size=1024*1024): + def _set_truncate(self, f, size=1024 * 1024): """Set up an open file so that we don't read more than a specified size. We want to read from the end of the file rather than the beginning. Write a warning at the end of the file if it was too big. @@ -105,8 +109,8 @@ def _set_truncate(self, f, size=1024*1024): """ end = f.seek(0, 2) if end > size: - f.write('\nWARNING: File has been truncated due to length!') - f.seek(end-size, 0) + f.write("\nWARNING: File has been truncated due to length!") + f.seek(end - size, 0) else: f.seek(0, 0) @@ -124,21 +128,28 @@ def run_with_log(self, cmd, logfile, cwd=None): """ env = os.environ.copy() # Make sure there all values we add are strings - env.update({k: v for k, v in self.client.config.items() - if isinstance(v, str)}) + env.update( + {k: v for k, v in self.client.config.items() if isinstance(v, str)} + ) global_timeout = self.get_global_timeout() output_timeout = self.get_output_timeout() start_time = time.time() - with open(logfile, 'a', encoding='utf-8') as f: - live_output_buffer = '' + with open(logfile, "a", encoding="utf-8") as f: + live_output_buffer = "" readpoll = select.poll() buffer_timeout = time.time() - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - shell=True, cwd=cwd, env=env) + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=True, + cwd=cwd, + env=env, + ) def cleanup(signum, frame): process.kill() + signal.signal(signal.SIGTERM, cleanup) set_nonblock(process.stdout.fileno()) readpoll.register(process.stdout, select.POLLIN) @@ -147,25 +158,33 @@ def cleanup(signum, frame): data_ready = readpoll.poll(10000) if data_ready: buf = process.stdout.read().decode( - sys.stdout.encoding, errors='replace') + sys.stdout.encoding, errors="replace" + ) if buf: sys.stdout.write(buf) live_output_buffer += buf f.write(buf) f.flush() else: - if (self.phase == 'test' and - time.time() - buffer_timeout > output_timeout): - buf = ('\nERROR: Output timeout reached! ' - '({}s)\n'.format(output_timeout)) + if ( + self.phase == "test" + and time.time() - buffer_timeout > output_timeout + ): + buf = ( + "\nERROR: Output timeout reached! " + "({}s)\n".format(output_timeout) + ) live_output_buffer += buf f.write(buf) process.kill() break - if (self.phase != 'reserve' and - time.time() - start_time > global_timeout): - buf = '\nERROR: Global timeout reached! ({}s)\n'.format( - global_timeout) + if ( + self.phase != "reserve" + and time.time() - start_time > global_timeout + ): + buf = "\nERROR: Global timeout reached! ({}s)\n".format( + global_timeout + ) live_output_buffer += buf f.write(buf) process.kill() @@ -177,11 +196,12 @@ def cleanup(signum, frame): # Try to stream output, if we can't connect, then # keep buffer for the next pass through this if self.client.post_live_output( - self.job_id, live_output_buffer): - live_output_buffer = '' + self.job_id, live_output_buffer + ): + live_output_buffer = "" buf = process.stdout.read() if buf: - buf = buf.decode(sys.stdout.encoding, errors='replace') + buf = buf.decode(sys.stdout.encoding, errors="replace") sys.stdout.write(buf) live_output_buffer += buf f.write(buf) @@ -194,26 +214,26 @@ def cleanup(signum, frame): return status def get_global_timeout(self): - """Get the global timeout for the test run in seconds - """ + """Get the global timeout for the test run in seconds""" # Default timeout is 4 hours default_timeout = 4 * 60 * 60 # Don't exceed the maximum timeout configured for the device! return min( - self.job_data.get('global_timeout', default_timeout), - self.client.config.get('global_timeout', default_timeout)) + self.job_data.get("global_timeout", default_timeout), + self.client.config.get("global_timeout", default_timeout), + ) def get_output_timeout(self): - """Get the output timeout for the test run in seconds - """ + """Get the output timeout for the test run in seconds""" # Default timeout is 15 minutes default_timeout = 15 * 60 # Don't exceed the maximum timeout configured for the device! return min( - self.job_data.get('output_timeout', default_timeout), - self.client.config.get('output_timeout', default_timeout)) + self.job_data.get("output_timeout", default_timeout), + self.client.config.get("output_timeout", default_timeout), + ) def banner(self, line): """Yield text lines to print a banner around a sting @@ -221,9 +241,9 @@ def banner(self, line): :param line: Line of text to print a banner around """ - yield '*' * (len(line) + 4) - yield '* {} *'.format(line) - yield '*' * (len(line) + 4) + yield "*" * (len(line) + 4) + yield "* {} *".format(line) + yield "*" * (len(line) + 4) def set_nonblock(fd): diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index 830419a5..5edcccbc 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -15,28 +15,31 @@ import voluptuous SCHEMA_V1 = { - voluptuous.Required('agent_id'): str, - voluptuous.Required('polling_interval', default=10): int, - voluptuous.Required('server_address'): str, - voluptuous.Required('execution_basedir', - default='/tmp/testflinger/run'): str, - voluptuous.Required('logging_basedir', - default='/tmp/testflinger/logs'): str, - voluptuous.Required('results_basedir', - default='/tmp/testflinger/results'): str, - voluptuous.Required('logging_level', default='INFO'): str, - voluptuous.Required('logging_quiet', default=False): bool, - voluptuous.Required('job_queues'): list, - voluptuous.Required('setup_command', default=''): str, - voluptuous.Required('provision_command', default=''): str, - voluptuous.Required('test_command', default=''): str, - voluptuous.Required('reserve_command', default=''): str, - voluptuous.Required('cleanup_command', default=''): str, - voluptuous.Optional('provision_type'): str, - voluptuous.Optional('global_timeout'): int, - voluptuous.Optional('output_timeout'): int, - voluptuous.Optional('advertised_queues'): dict, - voluptuous.Optional('advertised_images'): dict, + voluptuous.Required("agent_id"): str, + voluptuous.Required("polling_interval", default=10): int, + voluptuous.Required("server_address"): str, + voluptuous.Required( + "execution_basedir", default="/tmp/testflinger/run" + ): str, + voluptuous.Required( + "logging_basedir", default="/tmp/testflinger/logs" + ): str, + voluptuous.Required( + "results_basedir", default="/tmp/testflinger/results" + ): str, + voluptuous.Required("logging_level", default="INFO"): str, + voluptuous.Required("logging_quiet", default=False): bool, + voluptuous.Required("job_queues"): list, + voluptuous.Required("setup_command", default=""): str, + voluptuous.Required("provision_command", default=""): str, + voluptuous.Required("test_command", default=""): str, + voluptuous.Required("reserve_command", default=""): str, + voluptuous.Required("cleanup_command", default=""): str, + voluptuous.Optional("provision_type"): str, + voluptuous.Optional("global_timeout"): int, + voluptuous.Optional("output_timeout"): int, + voluptuous.Optional("advertised_queues"): dict, + voluptuous.Optional("advertised_images"): dict, } diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index e9ce10c6..4362be63 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -18,15 +18,16 @@ class TestClient: @pytest.fixture def agent(self): self.tmpdir = tempfile.mkdtemp() - self.config = {'agent_id': 'test01', - 'polling_interval': '2', - 'server_address': '127.0.0.1:8000', - 'job_queues': ['test'], - 'execution_basedir': self.tmpdir, - 'logging_basedir': self.tmpdir, - 'results_basedir': os.path.join(self.tmpdir, 'results'), - 'test_string': 'ThisIsATest' - } + self.config = { + "agent_id": "test01", + "polling_interval": "2", + "server_address": "127.0.0.1:8000", + "job_queues": ["test"], + "execution_basedir": self.tmpdir, + "logging_basedir": self.tmpdir, + "results_basedir": os.path.join(self.tmpdir, "results"), + "test_string": "ThisIsATest", + } testflinger_agent.configure_logging(self.config) client = _TestflingerClient(self.config) yield _TestflingerAgent(client) @@ -35,139 +36,167 @@ def agent(self): shutil.rmtree(self.tmpdir) def test_check_and_run_setup(self, agent, requests_mock): - self.config['setup_command'] = 'echo setup1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} - requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, - {'text': '{}'}]) + self.config["setup_command"] = "echo setup1" + fake_job_data = {"job_id": str(uuid.uuid1()), "job_queue": "test"} + requests_mock.get( + rmock.ANY, [{"text": json.dumps(fake_job_data)}, {"text": "{}"}] + ) requests_mock.post(rmock.ANY, status_code=200) - with patch('shutil.rmtree'): + with patch("shutil.rmtree"): agent.process_jobs() - setuplog = open(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'setup.log')).read() - assert('setup1' == setuplog.splitlines()[-1].strip()) + setuplog = open( + os.path.join(self.tmpdir, fake_job_data.get("job_id"), "setup.log") + ).read() + assert "setup1" == setuplog.splitlines()[-1].strip() def test_check_and_run_provision(self, agent, requests_mock): - self.config['provision_command'] = 'echo provision1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test', - 'provision_data': {'url': 'foo'}} - requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, - {'text': '{}'}]) + self.config["provision_command"] = "echo provision1" + fake_job_data = { + "job_id": str(uuid.uuid1()), + "job_queue": "test", + "provision_data": {"url": "foo"}, + } + requests_mock.get( + rmock.ANY, [{"text": json.dumps(fake_job_data)}, {"text": "{}"}] + ) requests_mock.post(rmock.ANY, status_code=200) - with patch('shutil.rmtree'): + with patch("shutil.rmtree"): agent.process_jobs() - provisionlog = open(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'provision.log')).read() - assert('provision1' == provisionlog.splitlines()[-1].strip()) + provisionlog = open( + os.path.join( + self.tmpdir, fake_job_data.get("job_id"), "provision.log" + ) + ).read() + assert "provision1" == provisionlog.splitlines()[-1].strip() def test_check_and_run_test(self, agent, requests_mock): - self.config['test_command'] = 'echo test1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test', - 'test_data': {'test_cmds': 'foo'}} - requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, - {'text': '{}'}]) + self.config["test_command"] = "echo test1" + fake_job_data = { + "job_id": str(uuid.uuid1()), + "job_queue": "test", + "test_data": {"test_cmds": "foo"}, + } + requests_mock.get( + rmock.ANY, [{"text": json.dumps(fake_job_data)}, {"text": "{}"}] + ) requests_mock.post(rmock.ANY, status_code=200) - with patch('shutil.rmtree'): + with patch("shutil.rmtree"): agent.process_jobs() - testlog = open(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'test.log')).read() - assert('test1' == testlog.splitlines()[-1].strip()) + testlog = open( + os.path.join(self.tmpdir, fake_job_data.get("job_id"), "test.log") + ).read() + assert "test1" == testlog.splitlines()[-1].strip() def test_config_vars_in_env(self, agent, requests_mock): - self.config['test_command'] = 'echo test_string is $test_string' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test', - 'test_data': {'test_cmds': 'foo'}} - requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, - {'text': '{}'}]) + self.config["test_command"] = "echo test_string is $test_string" + fake_job_data = { + "job_id": str(uuid.uuid1()), + "job_queue": "test", + "test_data": {"test_cmds": "foo"}, + } + requests_mock.get( + rmock.ANY, [{"text": json.dumps(fake_job_data)}, {"text": "{}"}] + ) requests_mock.post(rmock.ANY, status_code=200) - with patch('shutil.rmtree'): + with patch("shutil.rmtree"): agent.process_jobs() - testlog = open(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'test.log')).read() - assert("ThisIsATest" in testlog) + testlog = open( + os.path.join(self.tmpdir, fake_job_data.get("job_id"), "test.log") + ).read() + assert "ThisIsATest" in testlog def test_phase_failed(self, agent, requests_mock): # Make sure we stop running after a failed phase - self.config['provision_command'] = '/bin/false' - self.config['test_command'] = 'echo test1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test', - 'provision_data': {'url': 'foo'}, - 'test_data': {'test_cmds': 'foo'}} - requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, - {'text': '{}'}]) + self.config["provision_command"] = "/bin/false" + self.config["test_command"] = "echo test1" + fake_job_data = { + "job_id": str(uuid.uuid1()), + "job_queue": "test", + "provision_data": {"url": "foo"}, + "test_data": {"test_cmds": "foo"}, + } + requests_mock.get( + rmock.ANY, [{"text": json.dumps(fake_job_data)}, {"text": "{}"}] + ) requests_mock.post(rmock.ANY, status_code=200) - with patch('shutil.rmtree'), patch('os.unlink'): + with patch("shutil.rmtree"), patch("os.unlink"): agent.process_jobs() - outcome_file = os.path.join(os.path.join(self.tmpdir, - fake_job_data.get('job_id'), - 'testflinger-outcome.json')) + outcome_file = os.path.join( + os.path.join( + self.tmpdir, + fake_job_data.get("job_id"), + "testflinger-outcome.json", + ) + ) with open(outcome_file) as f: outcome_data = json.load(f) - assert(outcome_data.get('provision_status') == 1) - assert(outcome_data.get('test_status') is None) + assert outcome_data.get("provision_status") == 1 + assert outcome_data.get("test_status") is None def test_retry_transmit(self, agent, requests_mock): # Make sure we retry sending test results - self.config['provision_command'] = '/bin/false' - self.config['test_command'] = 'echo test1' - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test'} + self.config["provision_command"] = "/bin/false" + self.config["test_command"] = "echo test1" + fake_job_data = {"job_id": str(uuid.uuid1()), "job_queue": "test"} # Send an extra empty data since we will be calling get 3 times - requests_mock.get(rmock.ANY, [{'text': json.dumps(fake_job_data)}, - {'text': '{}'}, - {'text': '{}'}]) + requests_mock.get( + rmock.ANY, + [ + {"text": json.dumps(fake_job_data)}, + {"text": "{}"}, + {"text": "{}"}, + ], + ) requests_mock.post(rmock.ANY, status_code=200) with patch.object( - testflinger_agent.client.TestflingerClient, - 'transmit_job_outcome') as mock_transmit_job_outcome: + testflinger_agent.client.TestflingerClient, "transmit_job_outcome" + ) as mock_transmit_job_outcome: # Make sure we fail the first time when transmitting the results - mock_transmit_job_outcome.side_effect = [TFServerError(404), ''] + mock_transmit_job_outcome.side_effect = [TFServerError(404), ""] agent.process_jobs() first_dir = os.path.join( - self.config.get('execution_basedir'), - fake_job_data.get('job_id')) + self.config.get("execution_basedir"), + fake_job_data.get("job_id"), + ) mock_transmit_job_outcome.assert_called_with(first_dir) # Try processing jobs again, now it should be in results_basedir agent.process_jobs() retry_dir = os.path.join( - self.config.get('results_basedir'), - fake_job_data.get('job_id')) + self.config.get("results_basedir"), fake_job_data.get("job_id") + ) mock_transmit_job_outcome.assert_called_with(retry_dir) def test_recovery_failed(self, agent, requests_mock): # Make sure we stop processing jobs after a device recovery error - OFFLINE_FILE = '/tmp/TESTFLINGER-DEVICE-OFFLINE-test001' + OFFLINE_FILE = "/tmp/TESTFLINGER-DEVICE-OFFLINE-test001" if os.path.exists(OFFLINE_FILE): os.unlink(OFFLINE_FILE) - self.config['agent_id'] = 'test001' - self.config['provision_command'] = 'exit 46' - self.config['test_command'] = 'echo test1' + self.config["agent_id"] = "test001" + self.config["provision_command"] = "exit 46" + self.config["test_command"] = "echo test1" job_id = str(uuid.uuid1()) - fake_job_data = {'job_id': job_id, - 'job_queue': 'test', - 'provision_data': {'url': 'foo'}, - 'test_data': {'test_cmds': 'foo'}} + fake_job_data = { + "job_id": job_id, + "job_queue": "test", + "provision_data": {"url": "foo"}, + "test_data": {"test_cmds": "foo"}, + } # In this case we are making sure that the repost job request # gets good status with rmock.Mocker() as m: - m.get('http://127.0.0.1:8000/v1/job?queue=test', - json=fake_job_data) - m.get('http://127.0.0.1:8000/v1/result/'+job_id, text='{}') - m.post('http://127.0.0.1:8000/v1/result/'+job_id, text='{}') - m.post('http://127.0.0.1:8000/v1/result/'+job_id+'/output', - text='{}') - m.post('http://127.0.0.1:8000/v1/job', json={'job_id': job_id}) + m.get( + "http://127.0.0.1:8000/v1/job?queue=test", json=fake_job_data + ) + m.get("http://127.0.0.1:8000/v1/result/" + job_id, text="{}") + m.post("http://127.0.0.1:8000/v1/result/" + job_id, text="{}") + m.post( + "http://127.0.0.1:8000/v1/result/" + job_id + "/output", + text="{}", + ) + m.post("http://127.0.0.1:8000/v1/job", json={"job_id": job_id}) agent.process_jobs() - assert(agent.check_offline()) + assert agent.check_offline() # These are the args we would expect when it reposts the job - assert(m.last_request.json() == fake_job_data) + assert m.last_request.json() == fake_job_data if os.path.exists(OFFLINE_FILE): os.unlink(OFFLINE_FILE) diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index a3b6eb3d..e7fd3a67 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -20,19 +20,21 @@ from testflinger_agent.client import TestflingerClient as _TestflingerClient -class TestClient(): +class TestClient: @pytest.fixture def client(self): - yield _TestflingerClient({'server_address': '127.0.0.1:8000'}) + yield _TestflingerClient({"server_address": "127.0.0.1:8000"}) def test_check_jobs_empty(self, client, requests_mock): requests_mock.get(rmock.ANY, status_code=200) job_data = client.check_jobs() - assert(job_data is None) + assert job_data is None def test_check_jobs_with_job(self, client, requests_mock): - fake_job_data = {'job_id': str(uuid.uuid1()), - 'job_queue': 'test_queue'} + fake_job_data = { + "job_id": str(uuid.uuid1()), + "job_queue": "test_queue", + } requests_mock.get(rmock.ANY, json=fake_job_data) job_data = client.check_jobs() - assert(job_data == fake_job_data) + assert job_data == fake_job_data diff --git a/testflinger_agent/tests/test_config.py b/testflinger_agent/tests/test_config.py index b575d9c4..d25cd88d 100644 --- a/testflinger_agent/tests/test_config.py +++ b/testflinger_agent/tests/test_config.py @@ -41,13 +41,16 @@ def tearDown(self): os.unlink(self.configfile) def test_config_good(self): - with open(self.configfile, 'w') as config: + with open(self.configfile, "w") as config: config.write(GOOD_CONFIG) config = testflinger_agent.load_config(self.configfile) - self.assertEqual('test01', config.get('agent_id')) + self.assertEqual("test01", config.get("agent_id")) def test_config_bad(self): - with open(self.configfile, 'w') as config: + with open(self.configfile, "w") as config: config.write(BAD_CONFIG) - self.assertRaises(voluptuous.error.MultipleInvalid, - testflinger_agent.load_config, self.configfile) + self.assertRaises( + voluptuous.error.MultipleInvalid, + testflinger_agent.load_config, + self.configfile, + ) diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index bb70c227..79d8b49e 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -10,120 +10,123 @@ from testflinger_agent.job import TestflingerJob as _TestflingerJob -class TestJob(): +class TestJob: @pytest.fixture def client(self): self.tmpdir = tempfile.mkdtemp() - self.config = {'agent_id': 'test01', - 'polling_interval': '2', - 'server_address': '127.0.0.1:8000', - 'job_queues': ['test'], - 'execution_basedir': self.tmpdir, - 'logging_basedir': self.tmpdir, - 'results_basedir': os.path.join(self.tmpdir, 'results') - } + self.config = { + "agent_id": "test01", + "polling_interval": "2", + "server_address": "127.0.0.1:8000", + "job_queues": ["test"], + "execution_basedir": self.tmpdir, + "logging_basedir": self.tmpdir, + "results_basedir": os.path.join(self.tmpdir, "results"), + } testflinger_agent.configure_logging(self.config) yield _TestflingerClient(self.config) shutil.rmtree(self.tmpdir) def test_skip_missing_provision_data(self, client): - """Test that provision phase is skipped when provision_data is absent """ - self.config['provision_command'] = '/bin/true' - fake_job_data = {'global_timeout': 1} + Test that provision phase is skipped when provision_data is + absent + """ + self.config["provision_command"] = "/bin/true" + fake_job_data = {"global_timeout": 1} job = _TestflingerJob(fake_job_data, client) - job.run_test_phase('provision', None) - logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') + job.run_test_phase("provision", None) + logfile = os.path.join(self.tmpdir, "testflinger-agent.log") with open(logfile) as log: log_output = log.read() - assert("No provision_data defined in job data" in log_output) + assert "No provision_data defined in job data" in log_output def test_skip_empty_provision_data(self, client): """ Test that provision phase is skipped when provision_data is present but empty """ - self.config['provision_command'] = '/bin/true' - fake_job_data = {'global_timeout': 1, 'provision_data': ''} + self.config["provision_command"] = "/bin/true" + fake_job_data = {"global_timeout": 1, "provision_data": ""} job = _TestflingerJob(fake_job_data, client) - job.run_test_phase('provision', None) - logfile = os.path.join(self.tmpdir, 'testflinger-agent.log') + job.run_test_phase("provision", None) + logfile = os.path.join(self.tmpdir, "testflinger-agent.log") with open(logfile) as log: log_output = log.read() - assert("No provision_data defined in job data" in log_output) + assert "No provision_data defined in job data" in log_output def test_job_global_timeout(self, client, requests_mock): """Test that timeout from job_data is respected""" - timeout_str = '\nERROR: Global timeout reached! (1s)\n' - logfile = os.path.join(self.tmpdir, 'testlog') - fake_job_data = {'global_timeout': 1} + timeout_str = "\nERROR: Global timeout reached! (1s)\n" + logfile = os.path.join(self.tmpdir, "testlog") + fake_job_data = {"global_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) - job.phase = 'test' - job.run_with_log('sleep 3', logfile) + job.phase = "test" + job.run_with_log("sleep 3", logfile) with open(logfile) as log: log_data = log.read() - assert(timeout_str == log_data) + assert timeout_str == log_data def test_config_global_timeout(self, client, requests_mock): """Test that timeout from device config is preferred""" - timeout_str = '\nERROR: Global timeout reached! (1s)\n' - logfile = os.path.join(self.tmpdir, 'testlog') - self.config['global_timeout'] = 1 - fake_job_data = {'global_timeout': 3} + timeout_str = "\nERROR: Global timeout reached! (1s)\n" + logfile = os.path.join(self.tmpdir, "testlog") + self.config["global_timeout"] = 1 + fake_job_data = {"global_timeout": 3} requests_mock.post(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) - job.phase = 'test' - job.run_with_log('sleep 3', logfile) + job.phase = "test" + job.run_with_log("sleep 3", logfile) with open(logfile) as log: log_data = log.read() - assert(timeout_str == log_data) + assert timeout_str == log_data def test_job_output_timeout(self, client, requests_mock): """Test that output timeout from job_data is respected""" - timeout_str = '\nERROR: Output timeout reached! (1s)\n' - logfile = os.path.join(self.tmpdir, 'testlog') - fake_job_data = {'output_timeout': 1} + timeout_str = "\nERROR: Output timeout reached! (1s)\n" + logfile = os.path.join(self.tmpdir, "testlog") + fake_job_data = {"output_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) - job.phase = 'test' + job.phase = "test" # unfortunately, we need to sleep for longer that 10 seconds here # or else we fall under the polling time - job.run_with_log('sleep 12', logfile) + job.run_with_log("sleep 12", logfile) with open(logfile) as log: log_data = log.read() - assert(timeout_str == log_data) + assert timeout_str == log_data def test_config_output_timeout(self, client, requests_mock): """Test that output timeout from device config is preferred""" - timeout_str = '\nERROR: Output timeout reached! (1s)\n' - logfile = os.path.join(self.tmpdir, 'testlog') - self.config['output_timeout'] = 1 - fake_job_data = {'output_timeout': 30} + timeout_str = "\nERROR: Output timeout reached! (1s)\n" + logfile = os.path.join(self.tmpdir, "testlog") + self.config["output_timeout"] = 1 + fake_job_data = {"output_timeout": 30} requests_mock.post(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) - job.phase = 'test' + job.phase = "test" # unfortunately, we need to sleep for longer that 10 seconds here # or else we fall under the polling time - job.run_with_log('sleep 12', logfile) + job.run_with_log("sleep 12", logfile) with open(logfile) as log: log_data = log.read() - assert(timeout_str == log_data) + assert timeout_str == log_data def test_no_output_timeout_in_provision(self, client, requests_mock): """Test that output timeout is ignored when not in test phase""" - timeout_str = 'complete\n' - logfile = os.path.join(self.tmpdir, 'testlog') - fake_job_data = {'output_timeout': 1} + timeout_str = "complete\n" + logfile = os.path.join(self.tmpdir, "testlog") + fake_job_data = {"output_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) - job.phase = 'provision' + job.phase = "provision" # unfortunately, we need to sleep for longer that 10 seconds here # or else we fall under the polling time - job.run_with_log('sleep 12 && echo complete', logfile) + job.run_with_log("sleep 12 && echo complete", logfile) with open(logfile) as log: log_data = log.read() - assert(timeout_str == log_data) + assert timeout_str == log_data def test_set_truncate(self, client): """Test the _set_truncate method of TestflingerJob""" @@ -133,13 +136,13 @@ def test_set_truncate(self, client): f.write("x" * 100) job._set_truncate(f, size=100) contents = f.read() - assert(len(contents) == 100) - assert("WARNING" not in contents) + assert len(contents) == 100 + assert "WARNING" not in contents # Now check that a larger file does get truncated f.write("x" * 100) job._set_truncate(f, size=100) contents = f.read() # It won't be exactly 100 bytes, because a warning is added - assert(len(contents) < 150) - assert("WARNING" in contents) + assert len(contents) < 150 + assert "WARNING" in contents From dc69ae3972c4bddb68a80e39b586fd548d04b285 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 16 May 2022 11:10:28 -0500 Subject: [PATCH 364/569] Add black format checking to tox --- pyproject.toml | 2 ++ tox.ini | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a8f43fef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 79 diff --git a/tox.ini b/tox.ini index 5873ed47..54d73caa 100644 --- a/tox.ini +++ b/tox.ini @@ -4,12 +4,14 @@ skipsdist = true [testenv] deps = + black flake8 pytest pylint pytest-cov commands = {envbindir}/python setup.py develop + {envbindir}/python -m black --check setup.py snappy-device-agent snappy_device_agents devices tests {envbindir}/python -m flake8 setup.py snappy-device-agent snappy_device_agents devices #{envbindir}/python -m pylint snappy-device-agent snappy_device_agents devices {envbindir}/python -m pytest --doctest-modules --cov=snappy_device_agents --cov=devices From 2ab9d121f27f250c959c4b0b727861fedd006490 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 16 May 2022 11:10:41 -0500 Subject: [PATCH 365/569] Fix formatting recommended by black --- devices/__init__.py | 135 +++++++----- devices/cm3/__init__.py | 12 +- devices/cm3/cm3.py | 203 ++++++++++-------- devices/dragonboard/__init__.py | 12 +- devices/dragonboard/dragonboard.py | 218 ++++++++++++-------- devices/maas2/__init__.py | 19 +- devices/maas2/maas2.py | 250 ++++++++++++++--------- devices/muxpi/__init__.py | 12 +- devices/muxpi/muxpi.py | 316 +++++++++++++++++------------ devices/netboot/__init__.py | 36 ++-- devices/netboot/netboot.py | 77 ++++--- devices/noprovision/__init__.py | 7 +- devices/noprovision/noprovision.py | 31 ++- devices/oemrecovery/__init__.py | 4 +- devices/oemrecovery/oemrecovery.py | 71 ++++--- devices/rpi3/__init__.py | 12 +- devices/rpi3/rpi3.py | 238 +++++++++++++--------- setup.py | 34 ++-- snappy-device-agent | 2 +- snappy_device_agents/__init__.py | 163 ++++++++------- snappy_device_agents/cmd.py | 21 +- tests/test_snappy_device_agents.py | 26 ++- 22 files changed, 1114 insertions(+), 785 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index f60fd65b..8600251d 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -36,7 +36,8 @@ class RecoveryError(Exception): class SerialLogger: def __new__(cls, host=None, port=None, filename=None): - """Factory to generate real or fake SerialLogger object based on params + """ + Factory to generate real or fake SerialLogger object based on params """ if host and port and filename: return RealSerialLogger(host, port, filename) @@ -75,13 +76,15 @@ def reconnector(): pass # Keep trying if we can't connect, but sleep between attempts snappy_device_agents.logmsg( - logging.ERROR, "Error connecting to serial logging server") + logging.ERROR, "Error connecting to serial logging server" + ) time.sleep(30) + self.proc = multiprocessing.Process(target=reconnector, daemon=True) self.proc.start() def _log_serial(self): - with open(self.filename, 'a+') as f: + with open(self.filename, "a+") as f: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect((self.host, self.port)) while True: @@ -89,12 +92,14 @@ def _log_serial(self): for sock in read_sockets: data = sock.recv(4096) if data: - f.write(data.decode( - encoding='utf-8', errors='ignore')) + f.write( + data.decode(encoding="utf-8", errors="ignore") + ) f.flush() else: snappy_device_agents.logmsg( - logging.ERROR, "Serial Log connection closed") + logging.ERROR, "Serial Log connection closed" + ) return def stop(self): @@ -110,12 +115,12 @@ def runtest(self, args): snappy_device_agents.logmsg(logging.INFO, "BEGIN testrun") test_opportunity = snappy_device_agents.get_test_opportunity( - args.job_data) - test_cmds = test_opportunity.get('test_data').get('test_cmds') - serial_host = config.get('serial_host') - serial_port = config.get('serial_port') - serial_proc = SerialLogger( - serial_host, serial_port, 'test-serial.log') + args.job_data + ) + test_cmds = test_opportunity.get("test_data").get("test_cmds") + serial_host = config.get("serial_host") + serial_port = config.get("serial_port") + serial_proc = SerialLogger(serial_host, serial_port, "test-serial.log") serial_proc.start() try: exitcode = snappy_device_agents.run_test_cmds(test_cmds, config) @@ -132,31 +137,38 @@ def reserve(self, args): config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) snappy_device_agents.logmsg(logging.INFO, "BEGIN reservation") - job_data = snappy_device_agents.get_test_opportunity( - args.job_data) + job_data = snappy_device_agents.get_test_opportunity(args.job_data) try: - test_username = job_data['test_data']['test_username'] + test_username = job_data["test_data"]["test_username"] except KeyError: - test_username = 'ubuntu' - device_ip = config['device_ip'] - reserve_data = job_data['reserve_data'] - ssh_keys = reserve_data.get('ssh_keys', []) + test_username = "ubuntu" + device_ip = config["device_ip"] + reserve_data = job_data["reserve_data"] + ssh_keys = reserve_data.get("ssh_keys", []) for key in ssh_keys: try: - os.unlink('key.pub') + os.unlink("key.pub") except FileNotFoundError: pass - cmd = ['ssh-import-id', '-o', 'key.pub', key] + cmd = ["ssh-import-id", "-o", "key.pub", key] proc = subprocess.run(cmd) if proc.returncode != 0: snappy_device_agents.logmsg( logging.ERROR, - 'Unable to import ssh key from: {}'.format(key)) + "Unable to import ssh key from: {}".format(key), + ) continue - cmd = ['ssh-copy-id', '-f', '-i', 'key.pub', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, device_ip)] + cmd = [ + "ssh-copy-id", + "-f", + "-i", + "key.pub", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, device_ip), + ] for retry in range(10): # Retry ssh key copy just in case it's rebooting try: @@ -168,71 +180,86 @@ def reserve(self, args): pass snappy_device_agents.logmsg( logging.ERROR, - 'Error copying ssh key to device for: {}'.format(key)) + "Error copying ssh key to device for: {}".format(key), + ) if retry != 9: - snappy_device_agents.logmsg(logging.INFO, 'Retrying...') + snappy_device_agents.logmsg(logging.INFO, "Retrying...") time.sleep(60) else: snappy_device_agents.logmsg( - logging.ERROR, - 'Failed to copy ssh key: {}'.format(key)) + logging.ERROR, "Failed to copy ssh key: {}".format(key) + ) # default reservation timeout is 1 hour - timeout = int(reserve_data.get('timeout', '3600')) + timeout = int(reserve_data.get("timeout", "3600")) # If max_reserve_timeout isn't specified, default to 6 hours - max_reserve_timeout = int(config.get('max_reserve_timeout', 6*60*60)) + max_reserve_timeout = int( + config.get("max_reserve_timeout", 6 * 60 * 60) + ) if timeout > max_reserve_timeout: timeout = max_reserve_timeout - serial_host = config.get('serial_host') - serial_port = config.get('serial_port') - print('*** TESTFLINGER SYSTEM RESERVED ***') - print('You can now connect to {}@{}'.format(test_username, device_ip)) + serial_host = config.get("serial_host") + serial_port = config.get("serial_port") + print("*** TESTFLINGER SYSTEM RESERVED ***") + print("You can now connect to {}@{}".format(test_username, device_ip)) if serial_host and serial_port: - print('Serial access is available via: telnet {} {}'.format( - serial_host, serial_port)) + print( + "Serial access is available via: telnet {} {}".format( + serial_host, serial_port + ) + ) now = datetime.utcnow().isoformat() expire_time = ( - datetime.utcnow() + timedelta(seconds=timeout)).isoformat() - print('Current time: [{}]'.format(now)) - print('Reservation expires at: [{}]'.format(expire_time)) - print('Reservation will automatically timeout in {} ' - 'seconds'.format(timeout)) - job_id = job_data.get('job_id', '') - print('To end the reservation sooner use: testflinger-cli ' - 'cancel {}'.format(job_id)) + datetime.utcnow() + timedelta(seconds=timeout) + ).isoformat() + print("Current time: [{}]".format(now)) + print("Reservation expires at: [{}]".format(expire_time)) + print( + "Reservation will automatically timeout in {} " + "seconds".format(timeout) + ) + job_id = job_data.get("job_id", "") + print( + "To end the reservation sooner use: testflinger-cli " + "cancel {}".format(job_id) + ) time.sleep(int(timeout)) def catch(exception, returnval=0): - """ Decorator for catching Exceptions and returning values instead + """Decorator for catching Exceptions and returning values instead This is useful because for certain things, like RecoveryError, we need to give the calling process a hint that we failed for that reason, so it can act accordingly, by disabling the device for example """ + def _wrapper(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except exception: return returnval + return wrapper + return _wrapper def load_devices(): devices = [] device_path = os.path.dirname(os.path.realpath(__file__)) - devs = [os.path.join(device_path, device) - for device in os.listdir(device_path) - if os.path.isdir(os.path.join(device_path, device))] + devs = [ + os.path.join(device_path, device) + for device in os.listdir(device_path) + if os.path.isdir(os.path.join(device_path, device)) + ] for device in devs: - if '__pycache__' in device: + if "__pycache__" in device: continue - module = imp.load_source( - 'module', os.path.join(device, '__init__.py')) + module = imp.load_source("module", os.path.join(device, "__init__.py")) devices.append((module.device_name, module.DeviceAgent)) return tuple(devices) -if __name__ == '__main__': +if __name__ == "__main__": load_devices() diff --git a/devices/cm3/__init__.py b/devices/cm3/__init__.py index dc696b28..91b97511 100644 --- a/devices/cm3/__init__.py +++ b/devices/cm3/__init__.py @@ -20,10 +20,7 @@ import snappy_device_agents from devices.cm3.cm3 import CM3 from snappy_device_agents import logmsg -from devices import (catch, - DefaultDevice, - RecoveryError, - SerialLogger) +from devices import catch, DefaultDevice, RecoveryError, SerialLogger device_name = "cm3" @@ -41,10 +38,11 @@ def provision(self, args): device = CM3(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") - serial_host = config.get('serial_host') - serial_port = config.get('serial_port') + serial_host = config.get("serial_host") + serial_port = config.get("serial_port") serial_proc = SerialLogger( - serial_host, serial_port, 'provision-serial.log') + serial_host, serial_port, "provision-serial.log" + ) serial_proc.start() try: device.provision() diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index ab19bc82..e48417d9 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -23,8 +23,7 @@ from contextlib import contextmanager -from devices import (ProvisioningError, - RecoveryError) +from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -33,9 +32,11 @@ class CM3: """Device Agent for CM3.""" - IMAGE_PATH_IDS = {'etc': 'ubuntu', - 'system-data': 'core', - 'snaps': 'core20'} + IMAGE_PATH_IDS = { + "etc": "ubuntu", + "system-data": "core", + "snaps": "core20", + } def __init__(self, config, job_data): with open(config) as configfile: @@ -54,67 +55,79 @@ def _run_control(self, cmd, timeout=60): :returns: Return output from the command, if any """ - control_host = self.config.get('control_host') - control_user = self.config.get('control_user', 'ubuntu') - ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(control_user, control_host), - cmd] + control_host = self.config.get("control_host") + control_user = self.config.get("control_user", "ubuntu") + ssh_cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(control_user, control_host), + cmd, + ] try: output = subprocess.check_output( - ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout) + ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout + ) except subprocess.CalledProcessError as e: raise ProvisioningError(e.output) return output def provision(self): try: - url = self.job_data['provision_data']['url'] + url = self.job_data["provision_data"]["url"] except KeyError: - raise ProvisioningError('You must specify a "url" value in ' - 'the "provision_data" section of ' - 'your job_data') + raise ProvisioningError( + 'You must specify a "url" value in ' + 'the "provision_data" section of ' + "your job_data" + ) # Remove /dev/sda if somehow it's a normal file try: - self._run_control('test -f /dev/sda') + self._run_control("test -f /dev/sda") # paranoid, but be really certain we're not running locally - self._run_control('sudo rm -f /dev/sda') + self._run_control("sudo rm -f /dev/sda") except Exception: pass - self._run_control('sudo pi3gpio set high 16') + self._run_control("sudo pi3gpio set high 16") time.sleep(5) self.hardreset() - logger.info('Flashing image') - out = self._run_control('sudo cm3-installer {}'.format(url), - timeout=1800) + logger.info("Flashing image") + out = self._run_control( + "sudo cm3-installer {}".format(url), timeout=1800 + ) logger.info(out) image_type, image_dev = self.get_image_type() with self.remote_mount(image_dev): logger.info("Creating Test User") self.create_user(image_type) - self._run_control('sudo sync') + self._run_control("sudo sync") time.sleep(5) - out = self._run_control('sudo udisksctl power-off -b /dev/sda ') + out = self._run_control("sudo udisksctl power-off -b /dev/sda ") logger.info(out) time.sleep(5) - self._run_control('sudo pi3gpio set low 16') + self._run_control("sudo pi3gpio set low 16") time.sleep(5) self.hardreset() if self.check_test_image_booted(): return - agent_name = self.config.get('agent_name') - logger.error('Device %s unreachable after provisioning, deployment ' - 'failed!', agent_name) + agent_name = self.config.get("agent_name") + logger.error( + "Device %s unreachable after provisioning, deployment " "failed!", + agent_name, + ) raise ProvisioningError("Provisioning failed!") @contextmanager - def remote_mount(self, remote_device, mount_point='/mnt'): + def remote_mount(self, remote_device, mount_point="/mnt"): self._run_control( - 'sudo mount /dev/{} {}'.format(remote_device, mount_point)) + "sudo mount /dev/{} {}".format(remote_device, mount_point) + ) try: yield mount_point finally: - self._run_control('sudo umount {}'.format(mount_point)) + self._run_control("sudo umount {}".format(mount_point)) def get_image_type(self): """ @@ -123,16 +136,18 @@ def get_image_type(self): :returns: tuple of image type and device as strings """ - dev = self.config['test_device'] - lsblk_data = self._run_control('lsblk -J {}'.format(dev)) + dev = self.config["test_device"] + lsblk_data = self._run_control("lsblk -J {}".format(dev)) lsblk_json = json.loads(lsblk_data.decode()) - dev_list = [x.get('name') - for x in lsblk_json['blockdevices'][0]['children'] - if x.get('name')] + dev_list = [ + x.get("name") + for x in lsblk_json["blockdevices"][0]["children"] + if x.get("name") + ] for dev in dev_list: try: with self.remote_mount(dev): - dirs = self._run_control('ls /mnt') + dirs = self._run_control("ls /mnt") for path, img_type in self.IMAGE_PATH_IDS.items(): if path in dirs.decode().split(): return img_type, dev @@ -140,25 +155,35 @@ def get_image_type(self): # If unmountable or any other error, go on to the next one continue # We have no idea what kind of image this is - return 'unknown', dev + return "unknown", dev def check_test_image_booted(self): logger.info("Checking if test image booted.") started = time.time() # Retry for a while since we might still be rebooting - test_username = self.job_data.get( - 'test_data', {}).get('test_username', 'ubuntu') - test_password = self.job_data.get( - 'test_data', {}).get('test_password', 'ubuntu') + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + test_password = self.job_data.get("test_data", {}).get( + "test_password", "ubuntu" + ) while time.time() - started < 600: try: time.sleep(10) - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] + cmd = [ + "sshpass", + "-p", + test_password, + "ssh-copy-id", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + ] subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) + cmd, stderr=subprocess.STDOUT, timeout=60 + ) return True except Exception: pass @@ -167,56 +192,64 @@ def check_test_image_booted(self): def create_user(self, image_type): """Create user account for default ubuntu user""" - metadata = 'instance_id: cloud-image' - userdata = ('#cloud-config\n' - 'password: ubuntu\n' - 'chpasswd:\n' - ' list:\n' - ' - ubuntu:ubuntu\n' - ' expire: False\n' - 'ssh_pwauth: True') + metadata = "instance_id: cloud-image" + userdata = ( + "#cloud-config\n" + "password: ubuntu\n" + "chpasswd:\n" + " list:\n" + " - ubuntu:ubuntu\n" + " expire: False\n" + "ssh_pwauth: True" + ) # For core20: - uc20_ci_data = ('#cloud-config\n' - 'datasource_list: [ NoCloud, None ]\n' - 'datasource:\n' - ' NoCloud:\n' - ' user-data: |\n' - ' #cloud-config\n' - ' password: ubuntu\n' - ' chpasswd:\n' - ' list:\n' - ' - ubuntu:ubuntu\n' - ' expire: False\n' - ' ssh_pwauth: True\n' - ' meta-data: |\n' - ' instance_id: cloud-image') - - base = '/mnt' - if image_type == 'core': - base = '/mnt/system-data' + uc20_ci_data = ( + "#cloud-config\n" + "datasource_list: [ NoCloud, None ]\n" + "datasource:\n" + " NoCloud:\n" + " user-data: |\n" + " #cloud-config\n" + " password: ubuntu\n" + " chpasswd:\n" + " list:\n" + " - ubuntu:ubuntu\n" + " expire: False\n" + " ssh_pwauth: True\n" + " meta-data: |\n" + " instance_id: cloud-image" + ) + + base = "/mnt" + if image_type == "core": + base = "/mnt/system-data" try: - if image_type == 'core20': - ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') - self._run_control('sudo mkdir -p {}'.format(ci_path)) + if image_type == "core20": + ci_path = os.path.join(base, "data/etc/cloud/cloud.cfg.d") + self._run_control("sudo mkdir -p {}".format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( - write_cmd.format(uc20_ci_data, ci_path, '99_nocloud.cfg')) + write_cmd.format(uc20_ci_data, ci_path, "99_nocloud.cfg") + ) else: # For core or ubuntu classic images - ci_path = os.path.join( - base, 'var/lib/cloud/seed/nocloud-net') - self._run_control('sudo mkdir -p {}'.format(ci_path)) + ci_path = os.path.join(base, "var/lib/cloud/seed/nocloud-net") + self._run_control("sudo mkdir -p {}".format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( - write_cmd.format(metadata, ci_path, 'meta-data')) + write_cmd.format(metadata, ci_path, "meta-data") + ) self._run_control( - write_cmd.format(userdata, ci_path, 'user-data')) - if image_type == 'ubuntu': + write_cmd.format(userdata, ci_path, "user-data") + ) + if image_type == "ubuntu": # This needs to be removed on classic for rpi, else # cloud-init won't find the user-data we give it rm_cmd = "sudo rm -f {}".format( os.path.join( - base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) + base, "etc/cloud/cloud.cfg.d/99-fake_cloud.cfg" + ) + ) self._run_control(rm_cmd) except Exception: raise ProvisioningError("Error creating user files") @@ -232,7 +265,7 @@ def hardreset(self): This function runs the commands specified in 'reboot_script' in the config yaml. """ - for cmd in self.config['reboot_script']: + for cmd in self.config["reboot_script"]: logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index 69ed6398..dc5a16ae 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -20,10 +20,7 @@ import snappy_device_agents from devices.dragonboard.dragonboard import Dragonboard from snappy_device_agents import logmsg -from devices import (catch, - DefaultDevice, - RecoveryError, - SerialLogger) +from devices import catch, DefaultDevice, RecoveryError, SerialLogger device_name = "dragonboard" @@ -41,10 +38,11 @@ def provision(self, args): device = Dragonboard(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") - serial_host = config.get('serial_host') - serial_port = config.get('serial_port') + serial_host = config.get("serial_host") + serial_port = config.get("serial_port") serial_proc = SerialLogger( - serial_host, serial_port, 'provision-serial.log') + serial_host, serial_port, "provision-serial.log" + ) serial_proc.start() try: device.ensure_master_image() diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index 42b6cfc0..b0aa5dd2 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -23,8 +23,7 @@ import yaml import snappy_device_agents -from devices import (ProvisioningError, - RecoveryError) +from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -50,13 +49,19 @@ def _run_control(self, cmd, timeout=60): :returns: Return output from the command, if any """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'linaro@{}'.format(self.config['device_ip']), - cmd] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "linaro@{}".format(self.config["device_ip"]), + cmd, + ] try: output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=timeout) + cmd, stderr=subprocess.STDOUT, timeout=timeout + ) except subprocess.CalledProcessError as e: raise ProvisioningError(e.output) return output @@ -72,10 +77,10 @@ def setboot(self, mode): This method sets the snappy boot method to the specified value. """ - if mode == 'master': - setboot_script = self.config['select_master_script'] - elif mode == 'test': - setboot_script = self.config['select_test_script'] + if mode == "master": + setboot_script = self.config["select_master_script"] + elif mode == "test": + setboot_script = self.config["select_test_script"] else: raise KeyError for cmd in setboot_script: @@ -96,7 +101,7 @@ def hardreset(self): This function runs the commands specified in 'reboot_script' in the config yaml. """ - for cmd in self.config['reboot_script']: + for cmd in self.config["reboot_script"]: logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) @@ -115,9 +120,9 @@ def ensure_test_image(self, test_username, test_password): If the command times out or anything else fails. """ logger.info("Booting the test image") - self.setboot('test') + self.setboot("test") try: - self._run_control('sudo /sbin/reboot') + self._run_control("sudo /sbin/reboot") except subprocess.SubprocessError: # Keep trying even if this command fails pass @@ -129,10 +134,17 @@ def ensure_test_image(self, test_username, test_password): while time.time() - started < 600: try: time.sleep(10) - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] + cmd = [ + "sshpass", + "-p", + test_password, + "ssh-copy-id", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + ] subprocess.check_call(cmd) test_image_booted = self.is_test_image_booted() except subprocess.SubprocessError: @@ -152,13 +164,17 @@ def is_test_image_booted(self): True if the test image is currently booted, False otherwise. """ logger.info("Checking if test image booted.") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'snap -h'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "snap -h", + ] try: - subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) + subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) except subprocess.SubprocessError: return False # If we get here, then the above command proved we are in snappy @@ -177,11 +193,11 @@ def is_master_image_booted(self): # FIXME: come up with a better way of checking this logger.info("Checking if master image booted.") try: - output = self._run_control('cat /etc/issue') + output = self._run_control("cat /etc/issue") except subprocess.SubprocessError: logger.info("Error checking device state. Forcing reboot...") return False - if 'Debian GNU' in str(output): + if "Debian GNU" in str(output): return True return False @@ -199,7 +215,7 @@ def ensure_master_image(self): if test_booted: # We are not in the master image, so just hard reset - self.setboot('master') + self.setboot("master") self.hardreset() started = time.time() @@ -215,7 +231,8 @@ def ensure_master_image(self): master_booted = self.is_master_image_booted() if not master_booted: logging.warn( - "Device is in an unknown state, attempting to recover") + "Device is in an unknown state, attempting to recover" + ) self.hardreset() started = time.time() while time.time() - started < 300: @@ -228,7 +245,8 @@ def ensure_master_image(self): return self.ensure_master_image() # timeout reached, this could be a dead device raise RecoveryError( - "Device is in an unknown state, may require manual recovery!") + "Device is in an unknown state, may require manual recovery!" + ) # If we get here, the master image was already booted, so just return def flash_test_image(self, server_ip, server_port): @@ -246,13 +264,15 @@ def flash_test_image(self, server_ip, server_port): # First unmount, just in case try: self._run_control( - 'sudo umount {}*'.format(self.config['test_device']), - timeout=30) + "sudo umount {}*".format(self.config["test_device"]), + timeout=30, + ) except ProvisioningError: # We might not be mounted, so expect this to fail sometimes pass - cmd = 'nc {} {}| unxz| sudo dd of={} bs=16M'.format( - server_ip, server_port, self.config['test_device']) + cmd = "nc {} {}| unxz| sudo dd of={} bs=16M".format( + server_ip, server_port, self.config["test_device"] + ) logger.info("Running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! @@ -260,73 +280,86 @@ def flash_test_image(self, server_ip, server_port): except subprocess.TimeoutExpired: raise ProvisioningError("timeout reached while flashing image!") try: - self._run_control('sync') + self._run_control("sync") except subprocess.SubprocessError: # Nothing should go wrong here, but let's sleep if it does logger.warn("Something went wrong with the sync, sleeping...") time.sleep(30) try: self._run_control( - 'sudo hdparm -z {}'.format(self.config['test_device']), - timeout=30) + "sudo hdparm -z {}".format(self.config["test_device"]), + timeout=30, + ) except subprocess.CalledProcessError as exc: raise ProvisioningError( "Unable to run hdparm to rescan" - "partitions: {}".format(exc.output)) + "partitions: {}".format(exc.output) + ) def mount_writable_partition(self): # Mount the writable partition try: - self._run_control('sudo mount {} /mnt'.format( - self.config['snappy_writable_partition'])) + self._run_control( + "sudo mount {} /mnt".format( + self.config["snappy_writable_partition"] + ) + ) except subprocess.CalledProcessError as exc: - err = ("Error mounting writable partition on test image {}. " - "Check device configuration\n" - "output: {}".format( - self.config['snappy_writable_partition'], - exc.output)) + err = ( + "Error mounting writable partition on test image {}. " + "Check device configuration\n" + "output: {}".format( + self.config["snappy_writable_partition"], exc.output + ) + ) raise ProvisioningError(err) def create_user(self): """Create user account for default ubuntu user""" self.mount_writable_partition() - metadata = 'instance_id: cloud-image' - userdata = ('#cloud-config\n' - 'password: ubuntu\n' - 'chpasswd:\n' - ' list:\n' - ' - ubuntu:ubuntu\n' - ' expire: False\n' - 'ssh_pwauth: True') - with open('meta-data', 'w') as mdata: + metadata = "instance_id: cloud-image" + userdata = ( + "#cloud-config\n" + "password: ubuntu\n" + "chpasswd:\n" + " list:\n" + " - ubuntu:ubuntu\n" + " expire: False\n" + "ssh_pwauth: True" + ) + with open("meta-data", "w") as mdata: mdata.write(metadata) - with open('user-data', 'w') as udata: + with open("user-data", "w") as udata: udata.write(userdata) try: - output = self._run_control('ls /mnt') - if 'system-data' in str(output): - base = '/mnt/system-data' + output = self._run_control("ls /mnt") + if "system-data" in str(output): + base = "/mnt/system-data" else: - base = '/mnt' - cloud_path = os.path.join( - base, 'var/lib/cloud/seed/nocloud-net') - self._run_control('sudo mkdir -p {}'.format(cloud_path)) + base = "/mnt" + cloud_path = os.path.join(base, "var/lib/cloud/seed/nocloud-net") + self._run_control("sudo mkdir -p {}".format(cloud_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( - write_cmd.format(metadata, cloud_path, 'meta-data')) + write_cmd.format(metadata, cloud_path, "meta-data") + ) self._run_control( - write_cmd.format(userdata, cloud_path, 'user-data')) + write_cmd.format(userdata, cloud_path, "user-data") + ) except subprocess.CalledProcessError as exc: raise ProvisioningError( - "Error creating user files: {}".format(exc.output)) + "Error creating user files: {}".format(exc.output) + ) def setup_sudo(self): - sudo_data = 'ubuntu ALL=(ALL) NOPASSWD:ALL' - sudo_path = '/mnt/system-data/etc/sudoers.d/ubuntu' + sudo_data = "ubuntu ALL=(ALL) NOPASSWD:ALL" + sudo_path = "/mnt/system-data/etc/sudoers.d/ubuntu" self._run_control( - 'sudo mkdir -p {}'.format(os.path.dirname(sudo_path))) + "sudo mkdir -p {}".format(os.path.dirname(sudo_path)) + ) self._run_control( - 'sudo bash -c "echo \'{}\' > {}"'.format(sudo_data, sudo_path)) + "sudo bash -c \"echo '{}' > {}\"".format(sudo_data, sudo_path) + ) def wipe_test_device(self): """Safety check - wipe the test drive if things go wrong @@ -336,10 +369,9 @@ def wipe_test_device(self): something else. """ try: - test_device = self.config['test_device'] + test_device = self.config["test_device"] logger.error("Failed to write image, cleaning up...") - self._run_control( - 'sudo sgdisk -o {}'.format(test_device)) + self._run_control("sudo sgdisk -o {}".format(test_device)) except subprocess.SubprocessError: # This is an attempt to salvage a bad run, further tracebacks # would just add to the noise @@ -347,34 +379,48 @@ def wipe_test_device(self): def provision(self): """Provision the device""" - url = self.job_data['provision_data'].get('url') + url = self.job_data["provision_data"].get("url") if url: - snappy_device_agents.download(url, 'snappy.img') + snappy_device_agents.download(url, "snappy.img") else: try: - model_assertion = self.config['model_assertion'] - channel = self.job_data['provision_data']['channel'] - extra_snaps = self.job_data.get( - 'provision_data').get('extra-snaps', []) - cmd = ['sudo', 'ubuntu-image', '-c', channel, - model_assertion, '-o', 'snappy.img'] + model_assertion = self.config["model_assertion"] + channel = self.job_data["provision_data"]["channel"] + extra_snaps = self.job_data.get("provision_data").get( + "extra-snaps", [] + ) + cmd = [ + "sudo", + "ubuntu-image", + "-c", + channel, + model_assertion, + "-o", + "snappy.img", + ] for snap in extra_snaps: - cmd.append('--extra-snaps') + cmd.append("--extra-snaps") cmd.append(snap) subprocess.check_output(cmd, stderr=subprocess.STDOUT) except Exception: logger.exception("Bad data passed for provisioning") raise ProvisioningError("Error copying system-user assertion") - image_file = snappy_device_agents.compress_file('snappy.img') - test_username = self.job_data.get( - 'test_data', {}).get('test_username', 'ubuntu') - test_password = self.job_data.get( - 'test_data', {}).get('test_password', 'ubuntu') + image_file = snappy_device_agents.compress_file("snappy.img") + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + test_password = self.job_data.get("test_data", {}).get( + "test_password", "ubuntu" + ) server_ip = snappy_device_agents.get_local_ip_addr() serve_q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, - args=(serve_q, image_file,)) + args=( + serve_q, + image_file, + ), + ) file_server.start() server_port = serve_q.get() logger.info("Flashing Test Image") diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index 4c1851b8..9b5f9e85 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -20,11 +20,13 @@ import snappy_device_agents from devices.maas2.maas2 import Maas2 from snappy_device_agents import logmsg -from devices import (catch, - DefaultDevice, - RecoveryError, - ProvisioningError, - SerialLogger) +from devices import ( + catch, + DefaultDevice, + RecoveryError, + ProvisioningError, + SerialLogger, +) device_name = "maas2" @@ -42,10 +44,11 @@ def provision(self, args): device = Maas2(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") - serial_host = config.get('serial_host') - serial_port = config.get('serial_port') + serial_host = config.get("serial_host") + serial_port = config.get("serial_port") serial_proc = SerialLogger( - serial_host, serial_port, 'provision-serial.log') + serial_host, serial_port, "provision-serial.log" + ) serial_proc.start() try: device.provision() diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 24f4de98..61f6913d 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -22,8 +22,7 @@ import yaml from collections import OrderedDict -from devices import (ProvisioningError, - RecoveryError) +from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -37,10 +36,10 @@ def __init__(self, config, job_data): self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) - self.maas_user = self.config.get('maas_user') - self.node_id = self.config.get('node_id') - self.agent_name = self.config.get('agent_name') - self.timeout_min = int(self.config.get('timeout_min', 60)) + self.maas_user = self.config.get("maas_user") + self.node_id = self.config.get("node_id") + self.agent_name = self.config.get("agent_name") + self.timeout_min = int(self.config.get("timeout_min", 60)) def _logger_debug(self, message): logger.debug("MAAS: {}".format(message)) @@ -62,46 +61,68 @@ def recover(self): self.node_release() def provision(self): - if self.config.get('reset_efi'): + if self.config.get("reset_efi"): self.reset_efi() # Check if this is a device where we need to clear the tpm (dawson) - if self.config.get('clear_tpm'): + if self.config.get("clear_tpm"): self.clear_tpm() - provision_data = self.job_data.get('provision_data') + provision_data = self.job_data.get("provision_data") # Default to a safe LTS if no distro is specified - distro = provision_data.get('distro', 'xenial') - kernel = provision_data.get('kernel') - user_data = provision_data.get('user_data') + distro = provision_data.get("distro", "xenial") + kernel = provision_data.get("kernel") + user_data = provision_data.get("user_data") self.deploy_node(distro, kernel, user_data) def _install_efitools_snap(self): - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo snap install efi-tools-ijohnson --devmode --edge'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "sudo snap install efi-tools-ijohnson --devmode --edge", + ] subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo snap alias efi-tools-ijohnson.efibootmgr efibootmgr'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "sudo snap alias efi-tools-ijohnson.efibootmgr efibootmgr", + ] subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) def _get_efi_data(self): - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo efibootmgr -v'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "sudo efibootmgr -v", + ] p = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) # If it fails the first time, try installing efitools snap if p.returncode: self._install_efitools_snap() - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo efibootmgr -v'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "sudo efibootmgr -v", + ] p = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) if p.returncode: return None # Use OrderedDict because often the NIC entries in EFI are in a good @@ -114,24 +135,32 @@ def _get_efi_data(self): def _set_efi_data(self, boot_order): # Set the boot order to the comma separated string of entries - self._logger_info('Setting boot order to {}'.format(boot_order)) - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'sudo efibootmgr -o {}'.format(boot_order)] + self._logger_info("Setting boot order to {}".format(boot_order)) + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "sudo efibootmgr -o {}".format(boot_order), + ] p = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) if p.returncode: - self._logger_error('Failed to set efi boot order to "{}":\n' - '{}'.format(boot_order, p.stdout.decode())) + self._logger_error( + 'Failed to set efi boot order to "{}":\n' + "{}".format(boot_order, p.stdout.decode()) + ) def reset_efi(self): # Try to reset the boot order so that NICs boot first - self._logger_info('Fixing EFI boot order before provisioning') + self._logger_info("Fixing EFI boot order before provisioning") efi_data = self._get_efi_data() if not efi_data: return - bootlist = efi_data.get('BootOrder:').split(',') + bootlist = efi_data.get("BootOrder:").split(",") new_boot_order = [] for k, v in efi_data.items(): if ("IPv4" in v) and "Boot" in k: @@ -139,7 +168,7 @@ def reset_efi(self): for entry in bootlist: if entry not in new_boot_order: new_boot_order.append(entry) - self._set_efi_data(','.join(new_boot_order)) + self._set_efi_data(",".join(new_boot_order)) def clear_tpm(self): self._logger_info("Clearing the TPM before provisioning") @@ -153,91 +182,126 @@ def clear_tpm(self): def _run_tpm_clear_cmd(self): # Run the command to clear the tpm over ssh - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'echo 5 | sudo tee /sys/class/tpm/tpm0/ppi/request'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "echo 5 | sudo tee /sys/class/tpm/tpm0/ppi/request", + ] try: subprocess.check_call(cmd, timeout=30) - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'cat /sys/class/tpm/tpm0/ppi/request'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "cat /sys/class/tpm/tpm0/ppi/request", + ] output = subprocess.check_output(cmd, timeout=30) # If we now see "5" in that file, then clearing tpm succeeded - if output.decode('utf-8').strip() == "5": + if output.decode("utf-8").strip() == "5": return True except Exception: # Fall through if we fail for any reason pass return False - def deploy_node(self, distro='bionic', kernel=None, user_data=None): + def deploy_node(self, distro="bionic", kernel=None, user_data=None): # Deploy the node in maas, default to bionic if nothing is specified self.recover() - self._logger_info('Acquiring node') - cmd = ['maas', self.maas_user, 'machines', 'allocate', - 'system_id={}'.format(self.node_id)] + self._logger_info("Acquiring node") + cmd = [ + "maas", + self.maas_user, + "machines", + "allocate", + "system_id={}".format(self.node_id), + ] # Do not use runcmd for this - we need the output, not the end user subprocess.check_call(cmd) - self._logger_info('Starting node {} ' - 'with distro {}'.format(self.agent_name, distro)) - cmd = ['maas', self.maas_user, 'machine', 'deploy', self.node_id, - 'distro_series={}'.format(distro)] + self._logger_info( + "Starting node {} " + "with distro {}".format(self.agent_name, distro) + ) + cmd = [ + "maas", + self.maas_user, + "machine", + "deploy", + self.node_id, + "distro_series={}".format(distro), + ] if kernel: - cmd.append('hwe_kernel={}'.format(kernel)) + cmd.append("hwe_kernel={}".format(kernel)) if user_data: data = base64.b64encode(user_data.encode()).decode() - cmd.append('user_data={}'.format(data)) - process = subprocess.run(cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) + cmd.append("user_data={}".format(data)) + process = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) try: process.check_returncode() except subprocess.CalledProcessError: - self._logger_error('maas-cli call failure happens.') + self._logger_error("maas-cli call failure happens.") raise ProvisioningError(process.stdout.decode()) # Make sure the device is available before returning minutes_spent = 0 - self._logger_info("Timeout value: {} minutes.".format( - self.timeout_min)) + self._logger_info( + "Timeout value: {} minutes.".format(self.timeout_min) + ) while minutes_spent < self.timeout_min: time.sleep(60) minutes_spent += 1 - self._logger_info('{} minutes passed ' - 'since deployment.'.format(minutes_spent)) + self._logger_info( + "{} minutes passed " "since deployment.".format(minutes_spent) + ) status = self.node_status() - if status == 'Failed deployment': - self._logger_error('MaaS reports Failed Deployment') - exception_msg = "Provisioning failed because " + \ - "MaaS got unexpected or " + \ - "deployment failure status signal." + if status == "Failed deployment": + self._logger_error("MaaS reports Failed Deployment") + exception_msg = ( + "Provisioning failed because " + + "MaaS got unexpected or " + + "deployment failure status signal." + ) raise ProvisioningError(exception_msg) - if status == 'Deployed': + if status == "Deployed": if self.check_test_image_booted(): - self._logger_info('Deployed and booted.') + self._logger_info("Deployed and booted.") return - self._logger_error('Device {} still in "{}" state, deployment ' - 'failed!'.format(self.agent_name, status)) + self._logger_error( + 'Device {} still in "{}" state, deployment ' + "failed!".format(self.agent_name, status) + ) self._logger_error(process.stdout.decode()) - exception_msg = "Provisioning failed because deployment timeout. " + \ - "Deploying for more than " + \ - "{} minutes.".format(self.timeout_min) + exception_msg = ( + "Provisioning failed because deployment timeout. " + + "Deploying for more than " + + "{} minutes.".format(self.timeout_min) + ) raise ProvisioningError(exception_msg) def check_test_image_booted(self): self._logger_info("Checking if test image booted.") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - '/bin/true'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "/bin/true", + ] try: - subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) + subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) except Exception: return False # If we get here, then the above command proved we are booted @@ -251,22 +315,24 @@ def node_status(self): Deploying: Deployment in progress Deployed: Node is provisioned and ready for use """ - cmd = ['maas', self.maas_user, 'machine', 'read', self.node_id] + cmd = ["maas", self.maas_user, "machine", "read", self.node_id] # Do not use runcmd for this - we need the output, not the end user output = subprocess.check_output(cmd) data = json.loads(output.decode()) - return data.get('status_name') + return data.get("status_name") def node_release(self): """Release the node to make it available again""" - cmd = ['maas', self.maas_user, 'machine', 'release', self.node_id] + cmd = ["maas", self.maas_user, "machine", "release", self.node_id] subprocess.run(cmd) # Make sure the device is available before returning for timeout in range(0, 10): time.sleep(5) status = self.node_status() - if status == 'Ready': + if status == "Ready": return - self._logger_error('Device {} still in "{}" state, could not ' - 'recover!'.format(self.agent_name, status)) + self._logger_error( + 'Device {} still in "{}" state, could not ' + "recover!".format(self.agent_name, status) + ) raise RecoveryError("Device recovery failed!") diff --git a/devices/muxpi/__init__.py b/devices/muxpi/__init__.py index 0a312c10..892acb5c 100644 --- a/devices/muxpi/__init__.py +++ b/devices/muxpi/__init__.py @@ -20,10 +20,7 @@ import snappy_device_agents from devices.muxpi.muxpi import MuxPi from snappy_device_agents import logmsg -from devices import (catch, - RecoveryError, - DefaultDevice, - SerialLogger) +from devices import catch, RecoveryError, DefaultDevice, SerialLogger device_name = "muxpi" @@ -41,10 +38,11 @@ def provision(self, args): device = MuxPi(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") - serial_host = config.get('serial_host') - serial_port = config.get('serial_port') + serial_host = config.get("serial_host") + serial_port = config.get("serial_port") serial_proc = SerialLogger( - serial_host, serial_port, 'provision-serial.log') + serial_host, serial_port, "provision-serial.log" + ) serial_proc.start() try: device.provision() diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 426bd20a..412e7042 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -26,8 +26,7 @@ from contextlib import contextmanager -from devices import (ProvisioningError, - RecoveryError) +from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -36,19 +35,21 @@ class MuxPi: """Device Agent for MuxPi.""" - IMAGE_PATH_IDS = {'writable/usr/bin/firefox': 'pi-desktop', - 'writable/etc': 'ubuntu', - 'writable/system-data': 'core', - 'ubuntu-seed/snaps': 'core20', - 'cloudimg-rootfs/etc/cloud/cloud.cfg': 'ubuntu-cpc'} + IMAGE_PATH_IDS = { + "writable/usr/bin/firefox": "pi-desktop", + "writable/etc": "ubuntu", + "writable/system-data": "core", + "ubuntu-seed/snaps": "core20", + "cloudimg-rootfs/etc/cloud/cloud.cfg": "ubuntu-cpc", + } def __init__(self, config, job_data): with open(config) as configfile: self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) - self.agent_name = self.config.get('agent_name') - self.mount_point = os.path.join('/mnt', self.agent_name) + self.agent_name = self.config.get("agent_name") + self.mount_point = os.path.join("/mnt", self.agent_name) def _run_control(self, cmd, timeout=60): """ @@ -61,15 +62,21 @@ def _run_control(self, cmd, timeout=60): :returns: Return output from the command, if any """ - control_host = self.config.get('control_host') - control_user = self.config.get('control_user', 'ubuntu') - ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(control_user, control_host), - cmd] + control_host = self.config.get("control_host") + control_user = self.config.get("control_user", "ubuntu") + ssh_cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(control_user, control_host), + cmd, + ] try: output = subprocess.check_output( - ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout) + ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout + ) except subprocess.CalledProcessError as e: raise ProvisioningError(e.output) return output @@ -83,38 +90,47 @@ def _copy_to_control(self, local_file, remote_file): :param remote_file: Remote filename """ - control_host = self.config.get('control_host') - control_user = self.config.get('control_user', 'ubuntu') - ssh_cmd = ['scp', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - local_file, - '{}@{}:{}'.format(control_user, control_host, remote_file)] + control_host = self.config.get("control_host") + control_user = self.config.get("control_user", "ubuntu") + ssh_cmd = [ + "scp", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + local_file, + "{}@{}:{}".format(control_user, control_host, remote_file), + ] try: - output = subprocess.check_output( - ssh_cmd, stderr=subprocess.STDOUT) + output = subprocess.check_output(ssh_cmd, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: raise ProvisioningError(e.output) return output def provision(self): try: - url = self.job_data['provision_data']['url'] - snappy_device_agents.download(url, 'snappy.img') + url = self.job_data["provision_data"]["url"] + snappy_device_agents.download(url, "snappy.img") except KeyError: - raise ProvisioningError('You must specify a "url" value in ' - 'the "provision_data" section of ' - 'your job_data') - cmd = self.config.get('control_switch_local_cmd', - 'stm -ts') + raise ProvisioningError( + 'You must specify a "url" value in ' + 'the "provision_data" section of ' + "your job_data" + ) + cmd = self.config.get("control_switch_local_cmd", "stm -ts") self._run_control(cmd) time.sleep(5) - logger.info('Flashing Test image') - image_file = snappy_device_agents.compress_file('snappy.img') + logger.info("Flashing Test image") + image_file = snappy_device_agents.compress_file("snappy.img") server_ip = snappy_device_agents.get_local_ip_addr() serve_q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, - args=(serve_q, image_file,)) + args=( + serve_q, + image_file, + ), + ) file_server.start() server_port = serve_q.get() try: @@ -126,8 +142,7 @@ def provision(self): self.create_user(image_type) self.run_post_provision_script() logger.info("Booting Test Image") - cmd = self.config.get('control_switch_device_cmd', - 'stm -dut') + cmd = self.config.get("control_switch_device_cmd", "stm -dut") self._run_control(cmd) self.hardreset() self.check_test_image_booted() @@ -148,8 +163,9 @@ def flash_test_image(self, server_ip, server_port): """ # First unmount, just in case self.unmount_writable_partition() - cmd = 'nc.traditional {} {}| xzcat| sudo dd of={} bs=16M'.format( - server_ip, server_port, self.config['test_device']) + cmd = "nc.traditional {} {}| xzcat| sudo dd of={} bs=16M".format( + server_ip, server_port, self.config["test_device"] + ) logger.info("Running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! @@ -157,29 +173,33 @@ def flash_test_image(self, server_ip, server_port): except Exception: raise ProvisioningError("timeout reached while flashing image!") try: - self._run_control('sync') + self._run_control("sync") except Exception: # Nothing should go wrong here, but let's sleep if it does logger.warn("Something went wrong with the sync, sleeping...") time.sleep(30) try: self._run_control( - 'sudo hdparm -z {}'.format(self.config['test_device']), - timeout=30) + "sudo hdparm -z {}".format(self.config["test_device"]), + timeout=30, + ) except Exception: - raise ProvisioningError("Unable to run hdparm to rescan " - "partitions") + raise ProvisioningError( + "Unable to run hdparm to rescan " "partitions" + ) def _get_part_labels(self): - test_device = self.config['test_device'] + test_device = self.config["test_device"] lsblk_data = self._run_control( - 'lsblk -o NAME,LABEL -J {}'.format(test_device)) + "lsblk -o NAME,LABEL -J {}".format(test_device) + ) lsblk_json = json.loads(lsblk_data.decode()) # List of (name, label) pairs - return [(x.get('name'), - os.path.join(self.mount_point, x.get('label'))) - for x in lsblk_json['blockdevices'][0]['children'] - if x.get('name') and x.get('label')] + return [ + (x.get("name"), os.path.join(self.mount_point, x.get("label"))) + for x in lsblk_json["blockdevices"][0]["children"] + if x.get("name") and x.get("label") + ] @contextmanager def remote_mount(self): @@ -191,9 +211,8 @@ def remote_mount(self): mount_list = self._get_part_labels() for dev, mount in mount_list: try: - self._run_control('sudo mkdir -p {}'.format(mount)) - self._run_control( - 'sudo mount /dev/{} {}'.format(dev, mount)) + self._run_control("sudo mkdir -p {}".format(mount)) + self._run_control("sudo mount /dev/{} {}".format(dev, mount)) except Exception: # If unmountable or any other error, go on to the next one mount_list.remove((dev, mount)) @@ -202,7 +221,7 @@ def remote_mount(self): yield self.mount_point finally: for _, mount in mount_list: - self._run_control('sudo umount {}'.format(mount)) + self._run_control("sudo umount {}".format(mount)) def hardreset(self): """ @@ -215,7 +234,7 @@ def hardreset(self): This function runs the commands specified in 'reboot_script' in the config yaml. """ - for cmd in self.config.get('reboot_script', []): + for cmd in self.config.get("reboot_script", []): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) @@ -229,27 +248,28 @@ def get_image_type(self): :returns: image type as a string """ + def check_path(dir): - self._run_control('test -e {}'.format(dir)) + self._run_control("test -e {}".format(dir)) for path, img_type in self.IMAGE_PATH_IDS.items(): try: path = os.path.join(self.mount_point, path) check_path(path) - logger.info( - "Image type detected: {}".format(img_type)) + logger.info("Image type detected: {}".format(img_type)) return img_type except Exception: # Path was not found, continue trying others continue # We have no idea what kind of image this is - return 'unknown' + return "unknown" def unmount_writable_partition(self): try: self._run_control( - 'sudo umount {}*'.format(self.config['test_device']), - timeout=30) + "sudo umount {}*".format(self.config["test_device"]), + timeout=30, + ) except KeyError: raise RecoveryError("Device config missing test_device") except Exception: @@ -258,100 +278,120 @@ def unmount_writable_partition(self): def create_user(self, image_type): """Create user account for default ubuntu user""" - metadata = 'instance_id: cloud-image' - userdata = ('#cloud-config\n' - 'password: ubuntu\n' - 'chpasswd:\n' - ' list:\n' - ' - ubuntu:ubuntu\n' - ' expire: False\n' - 'ssh_pwauth: True') + metadata = "instance_id: cloud-image" + userdata = ( + "#cloud-config\n" + "password: ubuntu\n" + "chpasswd:\n" + " list:\n" + " - ubuntu:ubuntu\n" + " expire: False\n" + "ssh_pwauth: True" + ) # For core20: - uc20_ci_data = ('#cloud-config\n' - 'datasource_list: [ NoCloud, None ]\n' - 'datasource:\n' - ' NoCloud:\n' - ' user-data: |\n' - ' #cloud-config\n' - ' password: ubuntu\n' - ' chpasswd:\n' - ' list:\n' - ' - ubuntu:ubuntu\n' - ' expire: False\n' - ' ssh_pwauth: True\n' - ' meta-data: |\n' - ' instance_id: cloud-image') + uc20_ci_data = ( + "#cloud-config\n" + "datasource_list: [ NoCloud, None ]\n" + "datasource:\n" + " NoCloud:\n" + " user-data: |\n" + " #cloud-config\n" + " password: ubuntu\n" + " chpasswd:\n" + " list:\n" + " - ubuntu:ubuntu\n" + " expire: False\n" + " ssh_pwauth: True\n" + " meta-data: |\n" + " instance_id: cloud-image" + ) base = self.mount_point try: - if image_type == 'pi-desktop': + if image_type == "pi-desktop": # make a spot to scp files to - remote_tmp = os.path.join('/tmp', self.agent_name) - self._run_control('mkdir -p {}'.format(remote_tmp)) + remote_tmp = os.path.join("/tmp", self.agent_name) + self._run_control("mkdir -p {}".format(remote_tmp)) - data_path = os.path.join(os.path.dirname(__file__), - '../../data/pi-desktop') + data_path = os.path.join( + os.path.dirname(__file__), "../../data/pi-desktop" + ) # Override oem-config so that it uses the preseed self._copy_to_control( - os.path.join(data_path, 'oem-config.service'), remote_tmp) + os.path.join(data_path, "oem-config.service"), remote_tmp + ) cmd = ( - 'sudo cp {}/oem-config.service ' - '{}/writable/lib/systemd/system/' - 'oem-config.service'.format(remote_tmp, self.mount_point) - ) + "sudo cp {}/oem-config.service " + "{}/writable/lib/systemd/system/" + "oem-config.service".format(remote_tmp, self.mount_point) + ) self._run_control(cmd) # Copy the preseed - self._copy_to_control(os.path.join(data_path, 'preseed.cfg'), - remote_tmp) - cmd = 'sudo cp {}/preseed.cfg {}/writable/preseed.cfg'.format( - remote_tmp, self.mount_point) + self._copy_to_control( + os.path.join(data_path, "preseed.cfg"), remote_tmp + ) + cmd = "sudo cp {}/preseed.cfg {}/writable/preseed.cfg".format( + remote_tmp, self.mount_point + ) self._run_control(cmd) # Make sure NetworkManager is started - cmd = ('sudo cp -a ' - '{}/writable/etc/systemd/system/multi-user.target.wants' - '/NetworkManager.service ' - '{}/writable/etc/systemd/system/' - 'oem-config.target.wants'.format(self.mount_point, - self.mount_point)) + cmd = ( + "sudo cp -a " + "{}/writable/etc/systemd/system/multi-user.target.wants" + "/NetworkManager.service " + "{}/writable/etc/systemd/system/" + "oem-config.target.wants".format( + self.mount_point, self.mount_point + ) + ) self._run_control(cmd) # Setup sudoers data - sudo_data = 'ubuntu ALL=(ALL) NOPASSWD:ALL' - sudo_path = '{}/writable/etc/sudoers.d/ubuntu'.format( - self.mount_point) - self._run_control('sudo bash -c "echo \'{}\' > {}"'.format( - sudo_data, sudo_path)) + sudo_data = "ubuntu ALL=(ALL) NOPASSWD:ALL" + sudo_path = "{}/writable/etc/sudoers.d/ubuntu".format( + self.mount_point + ) + self._run_control( + "sudo bash -c \"echo '{}' > {}\"".format( + sudo_data, sudo_path + ) + ) return - if image_type == 'core20': - base = os.path.join(self.mount_point, 'ubuntu-seed') - ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') - self._run_control('sudo mkdir -p {}'.format(ci_path)) + if image_type == "core20": + base = os.path.join(self.mount_point, "ubuntu-seed") + ci_path = os.path.join(base, "data/etc/cloud/cloud.cfg.d") + self._run_control("sudo mkdir -p {}".format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( - write_cmd.format(uc20_ci_data, ci_path, '99_nocloud.cfg')) + write_cmd.format(uc20_ci_data, ci_path, "99_nocloud.cfg") + ) else: # For core or ubuntu classic images - base = os.path.join(self.mount_point, 'writable') - if image_type == 'core': - base = os.path.join(base, 'system-data') - if image_type == 'ubuntu-cpc': - base = os.path.join(self.mount_point, 'cloudimg-rootfs') - ci_path = os.path.join(base, 'var/lib/cloud/seed/nocloud-net') - self._run_control('sudo mkdir -p {}'.format(ci_path)) + base = os.path.join(self.mount_point, "writable") + if image_type == "core": + base = os.path.join(base, "system-data") + if image_type == "ubuntu-cpc": + base = os.path.join(self.mount_point, "cloudimg-rootfs") + ci_path = os.path.join(base, "var/lib/cloud/seed/nocloud-net") + self._run_control("sudo mkdir -p {}".format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( - write_cmd.format(metadata, ci_path, 'meta-data')) + write_cmd.format(metadata, ci_path, "meta-data") + ) self._run_control( - write_cmd.format(userdata, ci_path, 'user-data')) - if image_type == 'ubuntu': + write_cmd.format(userdata, ci_path, "user-data") + ) + if image_type == "ubuntu": # This needs to be removed on classic for rpi, else # cloud-init won't find the user-data we give it rm_cmd = "sudo rm -f {}".format( os.path.join( - base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) + base, "etc/cloud/cloud.cfg.d/99-fake_cloud.cfg" + ) + ) self._run_control(rm_cmd) except Exception: raise ProvisioningError("Error creating user files") @@ -360,19 +400,29 @@ def check_test_image_booted(self): logger.info("Checking if test image booted.") started = time.time() # Retry for a while since we might still be rebooting - test_username = self.job_data.get( - 'test_data', {}).get('test_username', 'ubuntu') - test_password = self.job_data.get( - 'test_data', {}).get('test_password', 'ubuntu') + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + test_password = self.job_data.get("test_data", {}).get( + "test_password", "ubuntu" + ) while time.time() - started < 1200: try: time.sleep(10) - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] + cmd = [ + "sshpass", + "-p", + test_password, + "ssh-copy-id", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + ] subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) + cmd, stderr=subprocess.STDOUT, timeout=60 + ) return True except Exception: pass @@ -382,7 +432,7 @@ def check_test_image_booted(self): def run_post_provision_script(self): # Run post provision commands on control host if there are any, but # don't fail the provisioning step if any of them don't work - for cmd in self.config.get('post_provision_script', []): + for cmd in self.config.get("post_provision_script", []): logger.info("Running %s", cmd) try: self._run_control(cmd) diff --git a/devices/netboot/__init__.py b/devices/netboot/__init__.py index 101b4ae3..f16a752d 100644 --- a/devices/netboot/__init__.py +++ b/devices/netboot/__init__.py @@ -22,11 +22,13 @@ from devices.netboot.netboot import Netboot from snappy_device_agents import logmsg -from devices import (catch, - DefaultDevice, - ProvisioningError, - RecoveryError, - SerialLogger) +from devices import ( + catch, + DefaultDevice, + ProvisioningError, + RecoveryError, + SerialLogger, +) device_name = "netboot" @@ -44,17 +46,17 @@ def provision(self, args): device = Netboot(args.config) image = snappy_device_agents.get_image(args.job_data) if not image: - raise ProvisioningError('Error downloading image') + raise ProvisioningError("Error downloading image") server_ip = snappy_device_agents.get_local_ip_addr() # Ideally the default user/pass should be metadata about an image, # but we don't currently have any concept of that stored. For now, # we can give a reasonable guess based on the provisioning method. test_username = snappy_device_agents.get_test_username( - job_data=args.job_data, - default='admin') + job_data=args.job_data, default="admin" + ) test_password = snappy_device_agents.get_test_password( - job_data=args.job_data, - default='admin') + job_data=args.job_data, default="admin" + ) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") """Initial recovery process @@ -71,14 +73,20 @@ def provision(self, args): raise RecoveryError("Unable to put system in a usable state!") q = multiprocessing.Queue() file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, args=(q, image,)) + target=snappy_device_agents.serve_file, + args=( + q, + image, + ), + ) file_server.start() server_port = q.get() logmsg(logging.INFO, "Flashing Test Image") - serial_host = config.get('serial_host') - serial_port = config.get('serial_port') + serial_host = config.get("serial_host") + serial_port = config.get("serial_port") serial_proc = SerialLogger( - serial_host, serial_port, 'provision-serial.log') + serial_host, serial_port, "provision-serial.log" + ) serial_proc.start() try: device.flash_test_image(server_ip, server_port) diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index f032fbc3..6c29e9df 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -20,10 +20,8 @@ import time import yaml -from snappy_device_agents import (runcmd, - TimeoutError) -from devices import (ProvisioningError, - RecoveryError) +from snappy_device_agents import runcmd, TimeoutError +from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -47,14 +45,16 @@ def setboot(self, mode): This method sets the snappy boot method to the specified value. """ - if mode == 'master': - setboot_script = self.config.get('select_master_script') - elif mode == 'test': - setboot_script = self.config.get('select_test_script') + if mode == "master": + setboot_script = self.config.get("select_master_script") + elif mode == "test": + setboot_script = self.config.get("select_test_script") else: - raise ProvisioningError("Attempted to set boot mode to '{}' - " - "only 'master' or 'test' are supported " - "modes!".format(mode)) + raise ProvisioningError( + "Attempted to set boot mode to '{}' - " + "only 'master' or 'test' are supported " + "modes!".format(mode) + ) self._run_cmd_list(setboot_script) def _run_cmd_list(self, cmdlist): @@ -74,7 +74,8 @@ def _run_cmd_list(self, cmdlist): raise ProvisioningError("timeout reaching control host!") if rc: raise ProvisioningError( - "Error running {} (rc={})".format(cmd, rc)) + "Error running {} (rc={})".format(cmd, rc) + ) def hardreset(self): """ @@ -87,7 +88,7 @@ def hardreset(self): This function runs the commands specified in 'reboot_script' in the config yaml. """ - for cmd in self.config['reboot_script']: + for cmd in self.config["reboot_script"]: logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) @@ -108,11 +109,16 @@ def ensure_test_image(self, test_username, test_password): logger.info("Booting the test image") if self.is_test_image_booted(test_username, test_password): return - self.setboot('test') - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip']), - 'sudo /sbin/reboot'] + self.setboot("test") + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + "sudo /sbin/reboot", + ] try: subprocess.check_call(cmd, timeout=60) except Exception: @@ -141,10 +147,18 @@ def is_test_image_booted(self, test_username, test_password): :returns: True if the test image is currently booted, False otherwise. """ - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', '-f', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] + cmd = [ + "sshpass", + "-p", + test_password, + "ssh-copy-id", + "-f", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + ] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) except Exception: @@ -162,7 +176,7 @@ def is_master_image_booted(self): .. note:: The master image is used for writing a new image to local media """ - check_url = 'http://{}:8989/check'.format(self.config['device_ip']) + check_url = "http://{}:8989/check".format(self.config["device_ip"]) data = "" try: logger.info("Checking if master image booted: %s", check_url) @@ -171,7 +185,7 @@ def is_master_image_booted(self): except Exception: # Any connection error will fail through the normal path pass - if 'Snappy Test Device Imager' in str(data): + if "Snappy Test Device Imager" in str(data): return True else: return False @@ -187,7 +201,7 @@ def ensure_master_image(self): if self.is_master_image_booted(): return - self.setboot('master') + self.setboot("master") self.hardreset() started = time.time() @@ -212,9 +226,12 @@ def flash_test_image(self, server_ip, server_port): :raises ProvisioningError: If the command times out or anything else fails. """ - url = r'http://{}:8989/writeimage?server={}:{}\&dev={}'.format( - self.config['device_ip'], server_ip, server_port, - self.config['test_device']) + url = r"http://{}:8989/writeimage?server={}:{}\&dev={}".format( + self.config["device_ip"], + server_ip, + server_port, + self.config["test_device"], + ) logger.info("Triggering: %s", url) try: # XXX: I hope 30 min is enough? but maybe not! @@ -225,11 +242,11 @@ def flash_test_image(self, server_ip, server_port): raise ProvisioningError("Error while flashing image!") # Run post-flash hooks - post_flash_cmds = self.config.get('post_flash_cmds') + post_flash_cmds = self.config.get("post_flash_cmds") self._run_cmd_list(post_flash_cmds) # Now reboot the target system - url = 'http://{}:8989/reboot'.format(self.config['device_ip']) + url = "http://{}:8989/reboot".format(self.config["device_ip"]) try: logger.info("Rebooting target device: %s", url) urllib.request.urlopen(url, timeout=10) diff --git a/devices/noprovision/__init__.py b/devices/noprovision/__init__.py index f5993743..acb8ef6e 100644 --- a/devices/noprovision/__init__.py +++ b/devices/noprovision/__init__.py @@ -22,9 +22,7 @@ from devices.noprovision.noprovision import Noprovision from snappy_device_agents import logmsg -from devices import (catch, - RecoveryError, - DefaultDevice) +from devices import catch, RecoveryError, DefaultDevice device_name = "noprovision" @@ -36,8 +34,7 @@ def provision(self, args): config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) device = Noprovision(args.config) - test_username = snappy_device_agents.get_test_username( - args.job_data) + test_username = snappy_device_agents.get_test_username(args.job_data) logmsg(logging.INFO, "BEGIN provision") device.ensure_test_image(test_username) logmsg(logging.INFO, "END provision") diff --git a/devices/noprovision/noprovision.py b/devices/noprovision/noprovision.py index 7f695068..4f67aea8 100644 --- a/devices/noprovision/noprovision.py +++ b/devices/noprovision/noprovision.py @@ -19,8 +19,7 @@ import time import yaml -from devices import (ProvisioningError, - RecoveryError) +from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -44,7 +43,7 @@ def hardreset(self): This function runs the commands specified in 'reboot_script' in the config yaml. """ - for cmd in self.config['reboot_script']: + for cmd in self.config["reboot_script"]: logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) @@ -63,10 +62,15 @@ def ensure_test_image(self, test_username): If the command times out or anything else fails. """ logger.info("Booting the test image") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip']), - '/bin/true'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + "/bin/true", + ] try: subprocess.check_call(cmd) return @@ -81,10 +85,15 @@ def ensure_test_image(self, test_username): while time.time() - started < 300: try: time.sleep(10) - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', - 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip']), - '/bin/true'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + "/bin/true", + ] subprocess.check_call(cmd) break except subprocess.SubprocessError: diff --git a/devices/oemrecovery/__init__.py b/devices/oemrecovery/__init__.py index 1dcef943..5d08762e 100644 --- a/devices/oemrecovery/__init__.py +++ b/devices/oemrecovery/__init__.py @@ -20,9 +20,7 @@ import snappy_device_agents from devices.oemrecovery.oemrecovery import OemRecovery from snappy_device_agents import logmsg -from devices import (catch, - RecoveryError, - DefaultDevice) +from devices import catch, RecoveryError, DefaultDevice device_name = "oemrecovery" diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 019431ab..388e97d5 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -20,8 +20,7 @@ import time import yaml -from devices import (ProvisioningError, - RecoveryError) +from devices import ProvisioningError, RecoveryError from snappy_device_agents import TimeoutError logger = logging.getLogger() @@ -49,17 +48,24 @@ def _run_device(self, cmd, timeout=60): Return output from the command, if any """ try: - test_username = self.job_data.get( - 'test_data', {}).get('test_username', 'ubuntu') + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) except AttributeError: - test_username = 'ubuntu' - ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip']), - cmd] + test_username = "ubuntu" + ssh_cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + cmd, + ] try: output = subprocess.check_output( - ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout) + ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout + ) except subprocess.CalledProcessError as e: raise ProvisioningError(e.output) return output @@ -74,26 +80,34 @@ def provision(self): self.hardreset() self.check_device_booted() - logger.info('Recovering OEM image') - recovery_cmds = self.config.get('recovery_cmds') + logger.info("Recovering OEM image") + recovery_cmds = self.config.get("recovery_cmds") self._run_cmd_list(recovery_cmds) self.check_device_booted() def copy_ssh_id(self): try: - test_username = self.job_data.get( - 'test_data', {}).get('test_username', 'ubuntu') - test_password = self.job_data.get( - 'test_data', {}).get('test_password', 'ubuntu') + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + test_password = self.job_data.get("test_data", {}).get( + "test_password", "ubuntu" + ) except AttributeError: - test_username = 'ubuntu' - test_password = 'ubuntu' - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] - subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) + test_username = "ubuntu" + test_password = "ubuntu" + cmd = [ + "sshpass", + "-p", + test_password, + "ssh-copy-id", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + ] + subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) def check_device_booted(self): logger.info("Checking to see if the device is available.") @@ -107,9 +121,10 @@ def check_device_booted(self): except Exception: pass # If we get here, then we didn't boot in time - agent_name = self.config.get('agent_name') - logger.error('Device %s unreachable, provisioning' - 'failed!', agent_name) + agent_name = self.config.get("agent_name") + logger.error( + "Device %s unreachable, provisioning" "failed!", agent_name + ) raise ProvisioningError("Failed to boot test image!") def _run_cmd_list(self, cmdlist): @@ -140,7 +155,7 @@ def hardreset(self): This function runs the commands specified in 'reboot_script' in the config yaml. """ - for cmd in self.config['reboot_script']: + for cmd in self.config["reboot_script"]: logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py index 54e5770d..361d8b6a 100644 --- a/devices/rpi3/__init__.py +++ b/devices/rpi3/__init__.py @@ -20,10 +20,7 @@ import snappy_device_agents from devices.rpi3.rpi3 import Rpi3 from snappy_device_agents import logmsg -from devices import (catch, - DefaultDevice, - RecoveryError, - SerialLogger) +from devices import catch, DefaultDevice, RecoveryError, SerialLogger device_name = "rpi3" @@ -41,10 +38,11 @@ def provision(self, args): device = Rpi3(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") - serial_host = config.get('serial_host') - serial_port = config.get('serial_port') + serial_host = config.get("serial_host") + serial_port = config.get("serial_port") serial_proc = SerialLogger( - serial_host, serial_port, 'provision-serial.log') + serial_host, serial_port, "provision-serial.log" + ) serial_proc.start() try: device.ensure_master_image() diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index e0c94449..63a2c59f 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -25,8 +25,7 @@ from contextlib import contextmanager import snappy_device_agents -from devices import (ProvisioningError, - RecoveryError) +from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -35,9 +34,11 @@ class Rpi3: """Snappy Device Agent for Rpi3.""" - IMAGE_PATH_IDS = {'etc': 'ubuntu', - 'system-data': 'core', - 'snaps': 'core20'} + IMAGE_PATH_IDS = { + "etc": "ubuntu", + "system-data": "core", + "snaps": "core20", + } def __init__(self, config, job_data): with open(config) as configfile: @@ -56,25 +57,32 @@ def _run_control(self, cmd, timeout=60): :returns: Return output from the command, if any """ - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'pi@{}'.format(self.config['device_ip']), - cmd] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "pi@{}".format(self.config["device_ip"]), + cmd, + ] try: output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=timeout) + cmd, stderr=subprocess.STDOUT, timeout=timeout + ) except subprocess.CalledProcessError as e: raise ProvisioningError(e.output) return output @contextmanager - def remote_mount(self, remote_device, mount_point='/mnt'): + def remote_mount(self, remote_device, mount_point="/mnt"): self._run_control( - 'sudo mount /dev/{} {}'.format(remote_device, mount_point)) + "sudo mount /dev/{} {}".format(remote_device, mount_point) + ) try: yield mount_point finally: - self._run_control('sudo umount {}'.format(mount_point)) + self._run_control("sudo umount {}".format(mount_point)) def get_image_type(self): """ @@ -83,16 +91,18 @@ def get_image_type(self): :returns: tuple of image type and device as strings """ - dev = self.config['test_device'] - lsblk_data = self._run_control('lsblk -J {}'.format(dev)) + dev = self.config["test_device"] + lsblk_data = self._run_control("lsblk -J {}".format(dev)) lsblk_json = json.loads(lsblk_data.decode()) - dev_list = [x.get('name') - for x in lsblk_json['blockdevices'][0]['children'] - if x.get('name')] + dev_list = [ + x.get("name") + for x in lsblk_json["blockdevices"][0]["children"] + if x.get("name") + ] for dev in dev_list: try: with self.remote_mount(dev): - dirs = self._run_control('ls /mnt') + dirs = self._run_control("ls /mnt") for path, img_type in self.IMAGE_PATH_IDS.items(): if path in dirs.decode().split(): return img_type, dev @@ -100,7 +110,7 @@ def get_image_type(self): # If unmountable or any other error, go on to the next one continue # We have no idea what kind of image this is - return 'unknown', dev + return "unknown", dev def setboot(self, mode): """ @@ -113,10 +123,10 @@ def setboot(self, mode): This method sets the snappy boot method to the specified value. """ - if mode == 'master': - setboot_script = self.config['select_master_script'] - elif mode == 'test': - setboot_script = self.config['select_test_script'] + if mode == "master": + setboot_script = self.config["select_master_script"] + elif mode == "test": + setboot_script = self.config["select_test_script"] else: raise KeyError for cmd in setboot_script: @@ -137,7 +147,7 @@ def hardreset(self): This function runs the commands specified in 'reboot_script' in the config yaml. """ - for cmd in self.config['reboot_script']: + for cmd in self.config["reboot_script"]: logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) @@ -156,9 +166,9 @@ def ensure_test_image(self, test_username, test_password): If the command times out or anything else fails. """ logger.info("Booting the test image") - self.setboot('test') + self.setboot("test") try: - self._run_control('sudo /sbin/reboot') + self._run_control("sudo /sbin/reboot") except Exception: pass time.sleep(60) @@ -169,10 +179,17 @@ def ensure_test_image(self, test_username, test_password): while time.time() - started < 600: try: time.sleep(10) - cmd = ['sshpass', '-p', test_password, 'ssh-copy-id', - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '{}@{}'.format(test_username, self.config['device_ip'])] + cmd = [ + "sshpass", + "-p", + test_password, + "ssh-copy-id", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + ] subprocess.check_call(cmd) test_image_booted = self.is_test_image_booted() except Exception: @@ -195,13 +212,17 @@ def is_test_image_booted(self): If the command fails """ logger.info("Checking if test image booted.") - cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - 'ubuntu@{}'.format(self.config['device_ip']), - 'snap -h'] + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "snap -h", + ] try: - subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=60) + subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) except Exception: return False # If we get here, then the above command proved we are in snappy @@ -220,11 +241,11 @@ def is_master_image_booted(self): # FIXME: come up with a better way of checking this logger.info("Checking if master image booted.") try: - output = self._run_control('cat /etc/issue') + output = self._run_control("cat /etc/issue") except Exception: logger.info("Error checking device state. Forcing reboot...") return False - if 'GNU' in str(output): + if "GNU" in str(output): return True return False @@ -242,7 +263,7 @@ def ensure_master_image(self): if test_booted: # We are not in the master image, so just hard reset - self.setboot('master') + self.setboot("master") self.hardreset() started = time.time() @@ -258,7 +279,8 @@ def ensure_master_image(self): master_booted = self.is_master_image_booted() if not master_booted: logging.warn( - "Device is in an unknown state, attempting to recover") + "Device is in an unknown state, attempting to recover" + ) self.hardreset() started = time.time() while time.time() - started < 300: @@ -271,7 +293,8 @@ def ensure_master_image(self): return self.ensure_master_image() # timeout reached, this could be a dead device raise RecoveryError( - "Device is in an unknown state, may require manual recovery!") + "Device is in an unknown state, may require manual recovery!" + ) # If we get here, the master image was already booted, so just return def flash_test_image(self, server_ip, server_port): @@ -289,15 +312,17 @@ def flash_test_image(self, server_ip, server_port): # First unmount, just in case try: self._run_control( - 'sudo umount {}*'.format(self.config['test_device']), - timeout=30) + "sudo umount {}*".format(self.config["test_device"]), + timeout=30, + ) except KeyError: raise RecoveryError("Device config missing test_device") except Exception: # We might not be mounted, so expect this to fail sometimes pass - cmd = 'nc.traditional {} {}| xzcat| sudo dd of={} bs=16M'.format( - server_ip, server_port, self.config['test_device']) + cmd = "nc.traditional {} {}| xzcat| sudo dd of={} bs=16M".format( + server_ip, server_port, self.config["test_device"] + ) logger.info("Running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! @@ -305,70 +330,80 @@ def flash_test_image(self, server_ip, server_port): except Exception: raise ProvisioningError("timeout reached while flashing image!") try: - self._run_control('sync') + self._run_control("sync") except Exception: # Nothing should go wrong here, but let's sleep if it does logger.warn("Something went wrong with the sync, sleeping...") time.sleep(30) try: self._run_control( - 'sudo hdparm -z {}'.format(self.config['test_device']), - timeout=30) + "sudo hdparm -z {}".format(self.config["test_device"]), + timeout=30, + ) except Exception: - raise ProvisioningError("Unable to run hdparm to rescan " - "partitions") + raise ProvisioningError( + "Unable to run hdparm to rescan " "partitions" + ) def create_user(self, image_type): """Create user account for default ubuntu user""" - metadata = 'instance_id: cloud-image' - userdata = ('#cloud-config\n' - 'password: ubuntu\n' - 'chpasswd:\n' - ' list:\n' - ' - ubuntu:ubuntu\n' - ' expire: False\n' - 'ssh_pwauth: True') + metadata = "instance_id: cloud-image" + userdata = ( + "#cloud-config\n" + "password: ubuntu\n" + "chpasswd:\n" + " list:\n" + " - ubuntu:ubuntu\n" + " expire: False\n" + "ssh_pwauth: True" + ) # For core20: - uc20_ci_data = ('#cloud-config\n' - 'datasource_list: [ NoCloud, None ]\n' - 'datasource:\n' - ' NoCloud:\n' - ' user-data: |\n' - ' #cloud-config\n' - ' password: ubuntu\n' - ' chpasswd:\n' - ' list:\n' - ' - ubuntu:ubuntu\n' - ' expire: False\n' - ' ssh_pwauth: True\n' - ' meta-data: |\n' - ' instance_id: cloud-image') - base = '/mnt' - if image_type == 'core': - base = '/mnt/system-data' + uc20_ci_data = ( + "#cloud-config\n" + "datasource_list: [ NoCloud, None ]\n" + "datasource:\n" + " NoCloud:\n" + " user-data: |\n" + " #cloud-config\n" + " password: ubuntu\n" + " chpasswd:\n" + " list:\n" + " - ubuntu:ubuntu\n" + " expire: False\n" + " ssh_pwauth: True\n" + " meta-data: |\n" + " instance_id: cloud-image" + ) + base = "/mnt" + if image_type == "core": + base = "/mnt/system-data" try: - if image_type == 'core20': - ci_path = os.path.join(base, 'data/etc/cloud/cloud.cfg.d') - self._run_control('sudo mkdir -p {}'.format(ci_path)) + if image_type == "core20": + ci_path = os.path.join(base, "data/etc/cloud/cloud.cfg.d") + self._run_control("sudo mkdir -p {}".format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( - write_cmd.format(uc20_ci_data, ci_path, '99_nocloud.cfg')) + write_cmd.format(uc20_ci_data, ci_path, "99_nocloud.cfg") + ) else: # For core or ubuntu classic images - ci_path = os.path.join( - base, 'var/lib/cloud/seed/nocloud-net') - self._run_control('sudo mkdir -p {}'.format(ci_path)) + ci_path = os.path.join(base, "var/lib/cloud/seed/nocloud-net") + self._run_control("sudo mkdir -p {}".format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( - write_cmd.format(metadata, ci_path, 'meta-data')) + write_cmd.format(metadata, ci_path, "meta-data") + ) self._run_control( - write_cmd.format(userdata, ci_path, 'user-data')) - if image_type == 'ubuntu': + write_cmd.format(userdata, ci_path, "user-data") + ) + if image_type == "ubuntu": # This needs to be removed on classic for rpi, else # cloud-init won't find the user-data we give it rm_cmd = "sudo rm -f {}".format( os.path.join( - base, 'etc/cloud/cloud.cfg.d/99-fake_cloud.cfg')) + base, "etc/cloud/cloud.cfg.d/99-fake_cloud.cfg" + ) + ) self._run_control(rm_cmd) except Exception: raise ProvisioningError("Error creating user files") @@ -381,10 +416,9 @@ def wipe_test_device(self): something else. """ try: - test_device = self.config['test_device'] + test_device = self.config["test_device"] logger.error("Failed to write image, cleaning up...") - self._run_control( - 'sudo wipefs -af {}'.format(test_device)) + self._run_control("sudo wipefs -af {}".format(test_device)) except Exception: # This is an attempt to salvage a bad run, further tracebacks # would just add to the noise @@ -393,7 +427,7 @@ def wipe_test_device(self): def run_post_provision_script(self): # Run post provision commands on control host if there are any, but # don't fail the provisioning step if any of them don't work - for cmd in self.config.get('post_provision_script', []): + for cmd in self.config.get("post_provision_script", []): logger.info("Running %s", cmd) try: self._run_control(cmd) @@ -402,22 +436,28 @@ def run_post_provision_script(self): def provision(self): """Provision the device""" - url = self.job_data['provision_data'].get('url') + url = self.job_data["provision_data"].get("url") if url: - snappy_device_agents.download(url, 'snappy.img') + snappy_device_agents.download(url, "snappy.img") else: logger.error("Bad data passed for provisioning") raise ProvisioningError("Error provisioning system") - image_file = snappy_device_agents.compress_file('snappy.img') - test_username = self.job_data.get( - 'test_data', {}).get('test_username', 'ubuntu') - test_password = self.job_data.get( - 'test_data', {}).get('test_password', 'ubuntu') + image_file = snappy_device_agents.compress_file("snappy.img") + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + test_password = self.job_data.get("test_data", {}).get( + "test_password", "ubuntu" + ) server_ip = snappy_device_agents.get_local_ip_addr() serve_q = multiprocessing.Queue() file_server = multiprocessing.Process( target=snappy_device_agents.serve_file, - args=(serve_q, image_file,)) + args=( + serve_q, + image_file, + ), + ) file_server.start() server_port = serve_q.get() logger.info("Flashing Test Image") diff --git a/setup.py b/setup.py index 77c58e30..1472ac9e 100755 --- a/setup.py +++ b/setup.py @@ -20,27 +20,31 @@ find_packages, setup, ) -assert sys.version_info >= (3,), 'Python 3 is required' +assert sys.version_info >= (3,), "Python 3 is required" -VERSION = '0.0.1' -datafiles = [(d, [os.path.join(d, f) for f in files]) - for d, folders, files in os.walk('data')] +VERSION = "0.0.1" + +datafiles = [ + (d, [os.path.join(d, f) for f in files]) + for d, folders, files in os.walk("data") +] setup( - name='snappy-device-agents', + name="snappy-device-agents", version=VERSION, - description=('Device agents scripts for provisioning and running ' - 'tests on Snappy devices'), - author='Snappy Device Agents Developers', - author_email='paul.larson@canonical.com', - url='https://launchpad.net/snappy-device-agents', - license='GPLv3', + description=( + "Device agents scripts for provisioning and running " + "tests on Snappy devices" + ), + author="Snappy Device Agents Developers", + author_email="paul.larson@canonical.com", + url="https://launchpad.net/snappy-device-agents", + license="GPLv3", packages=find_packages(), data_files=datafiles, - setup_requires=['pytest-runner'], - install_requires=['PyYAML>=3.11', - 'netifaces>=0.10.4'], - scripts=['snappy-device-agent'], + setup_requires=["pytest-runner"], + install_requires=["PyYAML>=3.11", "netifaces>=0.10.4"], + scripts=["snappy-device-agent"], ) diff --git a/snappy-device-agent b/snappy-device-agent index 89c24313..e6d2c57a 100755 --- a/snappy-device-agent +++ b/snappy-device-agent @@ -21,5 +21,5 @@ from snappy_device_agents.cmd import main logger = logging.getLogger() logger.setLevel(logging.INFO) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index cd013fe2..bd3f09e8 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -28,7 +28,7 @@ import time import urllib.request -IMAGEFILE = 'snappy.img' +IMAGEFILE = "snappy.img" logger = logging.getLogger() @@ -37,7 +37,7 @@ class TimeoutError(Exception): pass -def get_test_opportunity(job_data='testflinger.json'): +def get_test_opportunity(job_data="testflinger.json"): """ Read the json test opportunity data from testflinger.json. @@ -46,7 +46,7 @@ def get_test_opportunity(job_data='testflinger.json'): :return test_opportunity: Dictionary of values read from the json file """ - with open(job_data, encoding='utf-8') as job_data_json: + with open(job_data, encoding="utf-8") as job_data_json: test_opportunity = json.load(job_data_json) return test_opportunity @@ -58,7 +58,7 @@ def filetype(filename): b"\xfd\x37\x7a\x58\x5a\x00": "xz", b"\x51\x46\x49\xfb": "qcow2", } - with open(filename, 'rb') as f: + with open(filename, "rb") as f: filehead = f.read(1024) filetype = "unknown" for k, v in magic_headers.items(): @@ -79,7 +79,7 @@ def download(url, filename=None): :return filename: Filename of the downloaded snappy core image """ - logger.info('Downloading file from %s', url) + logger.info("Downloading file from %s", url) if filename is None: filename = os.path.basename(url) urllib.request.urlretrieve(url, filename) @@ -104,7 +104,7 @@ def delayretry(func, args, max_retries=3, delay=0): ret = func(*args) except Exception: time.sleep(delay) - if retry_count == max_retries-1: + if retry_count == max_retries - 1: raise continue return ret @@ -121,8 +121,8 @@ def udf_create_image(params): """ imagepath = os.path.join(os.getcwd(), IMAGEFILE) cmd = params.split() - cmd.insert(0, 'ubuntu-device-flash') - cmd.insert(0, 'sudo') + cmd.insert(0, "ubuntu-device-flash") + cmd.insert(0, "sudo") # A shorter tempdir path is needed than the one provided by SPI # because of a bug in kpartx that makes it have trouble deleting @@ -130,26 +130,26 @@ def udf_create_image(params): with tempfile.TemporaryDirectory() as tmpdir: tmp_imagepath = os.path.join(tmpdir, IMAGEFILE) try: - output_opt = cmd.index('-o') + output_opt = cmd.index("-o") cmd[output_opt + 1] = imagepath except Exception: # if we get here, -o was already not in the image - cmd.append('-o') + cmd.append("-o") cmd.append(tmp_imagepath) - logger.info('Creating snappy image with: %s', cmd) + logger.info("Creating snappy image with: %s", cmd) try: output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: - logger.error('Image Creation Output:\n %s', e.output) + logger.error("Image Creation Output:\n %s", e.output) raise - logger.info('Image Creation Output:\n %s', output) + logger.info("Image Creation Output:\n %s", output) shutil.move(tmp_imagepath, imagepath) - return(imagepath) + return imagepath -def get_test_username(job_data='testflinger.json', default='ubuntu'): +def get_test_username(job_data="testflinger.json", default="ubuntu"): """ If the test_data specifies a default username, use it. Otherwise allow the provisioning method pick a default, or use ubuntu as a safe bet @@ -159,13 +159,13 @@ def get_test_username(job_data='testflinger.json', default='ubuntu'): """ testflinger_data = get_test_opportunity(job_data) try: - user = testflinger_data['test_data']['test_username'] + user = testflinger_data["test_data"]["test_username"] except Exception: user = default return user -def get_test_password(job_data='testflinger.json', default='ubuntu'): +def get_test_password(job_data="testflinger.json", default="ubuntu"): """ If the test_data specifies a default password, use it. Otherwise allow the provisioning method pick a default, or use ubuntu as a safe bet @@ -175,13 +175,13 @@ def get_test_password(job_data='testflinger.json', default='ubuntu'): """ testflinger_data = get_test_opportunity(job_data) try: - password = testflinger_data['test_data']['test_password'] + password = testflinger_data["test_data"]["test_password"] except Exception: password = default return password -def get_image(job_data='testflinger.json'): +def get_image(job_data="testflinger.json"): """ Read the json data for a test opportunity from SPI and retrieve or create the requested image. @@ -191,26 +191,30 @@ def get_image(job_data='testflinger.json'): there was an error """ testflinger_data = get_test_opportunity(job_data) - image_keys = testflinger_data.get('provision_data').keys() - if 'download_files' in image_keys: - for url in testflinger_data.get( - 'provision_data').get('download_files'): + image_keys = testflinger_data.get("provision_data").keys() + if "download_files" in image_keys: + for url in testflinger_data.get("provision_data").get( + "download_files" + ): download(url) - if 'url' in image_keys: + if "url" in image_keys: try: - url = testflinger_data.get('provision_data').get('url') + url = testflinger_data.get("provision_data").get("url") image = download(url, IMAGEFILE) except Exception as e: logger.error('Error getting "%s": %s', url, e) - return '' - elif 'udf-params' in image_keys: - udf_params = testflinger_data.get('provision_data').get('udf-params') - image = delayretry(udf_create_image, [udf_params], - max_retries=3, delay=60) + return "" + elif "udf-params" in image_keys: + udf_params = testflinger_data.get("provision_data").get("udf-params") + image = delayretry( + udf_create_image, [udf_params], max_retries=3, delay=60 + ) else: - logger.error('provision_data needs to contain "url" for the image ' - 'or "udf-params"') - return '' + logger.error( + 'provision_data needs to contain "url" for the image ' + 'or "udf-params"' + ) + return "" return compress_file(image) @@ -222,8 +226,8 @@ def get_local_ip_addr(): Returns the ip address of this system """ gateways = netifaces.gateways() - default_interface = gateways['default'][netifaces.AF_INET][1] - ip = netifaces.ifaddresses(default_interface)[netifaces.AF_INET][0]['addr'] + default_interface = gateways["default"][netifaces.AF_INET][1] + ip = netifaces.ifaddresses(default_interface)[netifaces.AF_INET][0]["addr"] return ip @@ -242,7 +246,7 @@ def serve_file(q, filename): q.put(port) server.listen(1) (client, _) = server.accept() - with open(filename, mode='rb') as f: + with open(filename, mode="rb") as f: while True: data = f.read(16 * 1024 * 1024) if not data: @@ -267,38 +271,38 @@ def compress_file(filename): os.unlink(compressed_filename) except FileNotFoundError: pass - if filetype(filename) == 'xz': + if filetype(filename) == "xz": # just hard link it so we can unlink later without special handling os.rename(filename, compressed_filename) - elif filetype(filename) == 'gz': - with lzma.open(compressed_filename, 'wb') as compressed_image: - with gzip.GzipFile(filename, 'rb') as old_compressed: + elif filetype(filename) == "gz": + with lzma.open(compressed_filename, "wb") as compressed_image: + with gzip.GzipFile(filename, "rb") as old_compressed: shutil.copyfileobj(old_compressed, compressed_image) - elif filetype(filename) == 'bz2': - with lzma.open(compressed_filename, 'wb') as compressed_image: - with bz2.BZ2File(filename, 'rb') as old_compressed: + elif filetype(filename) == "bz2": + with lzma.open(compressed_filename, "wb") as compressed_image: + with bz2.BZ2File(filename, "rb") as old_compressed: shutil.copyfileobj(old_compressed, compressed_image) - elif filetype(filename) == 'qcow2': - raw_filename = '{}.raw'.format(filename) + elif filetype(filename) == "qcow2": + raw_filename = "{}.raw".format(filename) try: # Remove the original file, unless we already did os.unlink(raw_filename) except FileNotFoundError: pass - cmd = ['qemu-img', 'convert', '-O', 'raw', filename, raw_filename] + cmd = ["qemu-img", "convert", "-O", "raw", filename, raw_filename] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: - logger.error('Image Conversion Output:\n %s', e.output) + logger.error("Image Conversion Output:\n %s", e.output) raise - with open(raw_filename, 'rb') as uncompressed_image: - with lzma.open(compressed_filename, 'wb') as compressed_image: + with open(raw_filename, "rb") as uncompressed_image: + with lzma.open(compressed_filename, "wb") as compressed_image: shutil.copyfileobj(uncompressed_image, compressed_image) os.unlink(raw_filename) else: # filetype is 'unknown' so assumed to be raw image - with open(filename, 'rb') as uncompressed_image: - with lzma.open(compressed_filename, 'wb') as compressed_image: + with open(filename, "rb") as uncompressed_image: + with lzma.open(compressed_filename, "wb") as compressed_image: shutil.copyfileobj(uncompressed_image, compressed_image) try: # Remove the original file, unless we already did @@ -319,12 +323,13 @@ def filter(self, record): return True logging.basicConfig( - format='%(asctime)s %(agent_name)s %(levelname)s: ' - 'DEVICE AGENT: ' - '%(message)s') - agent_name = config.get('agent_name', "") + format="%(asctime)s %(agent_name)s %(levelname)s: " + "DEVICE AGENT: " + "%(message)s" + ) + agent_name = config.get("agent_name", "") logger.addFilter(AgentFilter(agent_name)) - logstash_host = config.get('logstash_host', None) + logstash_host = config.get("logstash_host", None) if logstash_host is not None: try: @@ -377,19 +382,23 @@ def runcmd(cmd, env={}, timeout=None): deadline = time.time() + timeout else: deadline = None - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - shell=True, env=env) + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=True, + env=env, + ) while process.poll() is None: if deadline and time.time() > deadline: process.terminate() raise TimeoutError line = process.stdout.readline() if line: - sys.stdout.write(line.decode(errors='replace')) + sys.stdout.write(line.decode(errors="replace")) line = process.stdout.read() if line: - sys.stdout.write(line.decode(errors='replace')) + sys.stdout.write(line.decode(errors="replace")) return process.returncode @@ -409,7 +418,7 @@ def run_test_cmds(cmds, config=None, env={}): if not env: env = os.environ.copy() - config_env = config.get('env', {}) + config_env = config.get("env", {}) env.update(config_env) if isinstance(cmds, list): return _run_test_cmds_list(cmds, config, env) @@ -429,35 +438,37 @@ def _process_cmds_template_vars(cmds, config=None): :param config: Config data for the device which can be used for filling templates """ + class IgnoreUnknownFormatter(string.Formatter): def vformat(self, format_string, args, kwargs): tokens = [] for (literal, field_name, spec, conv) in self.parse(format_string): # replace double braces if parse removed them - literal = literal.replace('{', '{{').replace('}', '}}') + literal = literal.replace("{", "{{").replace("}", "}}") # if the field is {}, just add escaped empty braces - if field_name == '': - tokens.extend([literal, '{{}}']) + if field_name == "": + tokens.extend([literal, "{{}}"]) continue # if field name was None, we just add the literal token if field_name is None: tokens.extend([literal]) continue # if conf and spec are not defined, set to '' - conv = '!' + conv if conv else '' - spec = ':' + spec if spec else '' + conv = "!" + conv if conv else "" + spec = ":" + spec if spec else "" # only consider field before index - field = field_name.split('[')[0].split('.')[0] + field = field_name.split("[")[0].split(".")[0] # If this field is one we've defined, fill template value if field in kwargs: - tokens.extend( - [literal, '{', field_name, conv, spec, '}']) + tokens.extend([literal, "{", field_name, conv, spec, "}"]) else: # If not, the use escaped braces to pass it through tokens.extend( - [literal, '{{', field_name, conv, spec, '}}']) - format_string = ''.join(tokens) + [literal, "{{", field_name, conv, spec, "}}"] + ) + format_string = "".join(tokens) return string.Formatter.vformat(self, format_string, args, kwargs) + # Ensure config is a dict if not isinstance(config, dict): config = {} @@ -506,14 +517,14 @@ def _run_test_cmds_str(cmds, config=None, env={}): """ # If cmds doesn't specify an interpreter, pick a safe default - if not cmds.startswith('#!'): + if not cmds.startswith("#!"): cmds = "#!/bin/bash\n" + cmds cmds = _process_cmds_template_vars(cmds, config) - with open('tf_cmd_script', mode='w', encoding='utf-8') as tf_cmd_script: + with open("tf_cmd_script", mode="w", encoding="utf-8") as tf_cmd_script: tf_cmd_script.write(cmds) - os.chmod('tf_cmd_script', 0o775) - rc = runcmd('./tf_cmd_script', env) + os.chmod("tf_cmd_script", 0o775) + rc = runcmd("./tf_cmd_script", env) if rc: logmsg(logging.WARNING, "Tests failed, rc=%d", rc) return rc diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py index 218641e2..1ffd703a 100755 --- a/snappy_device_agents/cmd.py +++ b/snappy_device_agents/cmd.py @@ -33,14 +33,21 @@ def main(): dev_module = dev_class() # Next add the subcommands that can be used and the methods they run cmd_subparser = dev_subparser.add_subparsers() - for (cmd, func) in (('provision', dev_module.provision), - ('runtest', dev_module.runtest), - ('reserve', dev_module.reserve)): + for (cmd, func) in ( + ("provision", dev_module.provision), + ("runtest", dev_module.runtest), + ("reserve", dev_module.reserve), + ): cmd_parser = cmd_subparser.add_parser(cmd) - cmd_parser.add_argument('-c', '--config', required=True, - help='Config file for this device') - cmd_parser.add_argument('job_data', - help='Testflinger json data file') + cmd_parser.add_argument( + "-c", + "--config", + required=True, + help="Config file for this device", + ) + cmd_parser.add_argument( + "job_data", help="Testflinger json data file" + ) cmd_parser.set_defaults(func=func) args = parser.parse_args() raise SystemExit(args.func(args)) diff --git a/tests/test_snappy_device_agents.py b/tests/test_snappy_device_agents.py index b1d5434a..4fdec377 100644 --- a/tests/test_snappy_device_agents.py +++ b/tests/test_snappy_device_agents.py @@ -18,27 +18,33 @@ class TestCommandsTemplate: - """ Tests to ensure test_cmds templating works properly """ + """Tests to ensure test_cmds templating works properly""" def test_known_config_items(self): - """ Known config items should fill in the expected value""" + """Known config items should fill in the expected value""" cmds = "test {item}" config = {"item": "foo"} expected = "test foo" - assert snappy_device_agents._process_cmds_template_vars( - cmds, config) == expected + assert ( + snappy_device_agents._process_cmds_template_vars(cmds, config) + == expected + ) def test_unknown_config_items(self): - """ Unknown config items should not cause an error """ + """Unknown config items should not cause an error""" cmds = "test {unknown_item}" config = {} - assert snappy_device_agents._process_cmds_template_vars( - cmds, config) == cmds + assert ( + snappy_device_agents._process_cmds_template_vars(cmds, config) + == cmds + ) def test_escaped_braces(self): - """ Escaped braces should be unescaped, not interpreted """ + """Escaped braces should be unescaped, not interpreted""" cmds = "test {{item}}" config = {"item": "foo"} expected = "test {item}" - assert snappy_device_agents._process_cmds_template_vars( - cmds, config) == expected + assert ( + snappy_device_agents._process_cmds_template_vars(cmds, config) + == expected + ) From f3c783748e99766281d3411c27b23f8886a51c08 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 11 Jul 2022 16:10:38 -0500 Subject: [PATCH 366/569] Not complete, but many more useful pylint cleanups --- devices/__init__.py | 26 +++++--- devices/netboot/netboot.py | 4 +- devices/oemrecovery/oemrecovery.py | 4 +- snappy_device_agents/__init__.py | 104 ++++++++++++++++------------- snappy_device_agents/cmd.py | 4 ++ 5 files changed, 82 insertions(+), 60 deletions(-) diff --git a/devices/__init__.py b/devices/__init__.py index 8600251d..6ae7c7da 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -34,17 +34,18 @@ class RecoveryError(Exception): pass -class SerialLogger: - def __new__(cls, host=None, port=None, filename=None): - """ - Factory to generate real or fake SerialLogger object based on params - """ - if host and port and filename: - return RealSerialLogger(host, port, filename) - return StubSerialLogger(host, port, filename) +def SerialLogger(host=None, port=None, filename=None): + """ + Factory to generate real or fake SerialLogger object based on params + """ + if host and port and filename: + return RealSerialLogger(host, port, filename) + return StubSerialLogger(host, port, filename) class StubSerialLogger: + """Fake SerialLogger when we don't have Serial Logger data defined""" + def __init__(self, host, port, filename): pass @@ -56,10 +57,10 @@ def stop(self): class RealSerialLogger: - - """Set up a subprocess to connect to an ip and collect serial logs""" + """Real SerialLogger for when we have a serial logging service""" def __init__(self, host, port, filename): + """Set up a subprocess to connect to an ip and collect serial logs""" if not (host and port and filename): self.stub = True self.stub = False @@ -68,7 +69,10 @@ def __init__(self, host, port, filename): self.filename = filename def start(self): + """Start the serial logger connection""" + def reconnector(): + """Reconnect when needed""" while True: try: self._log_serial() @@ -84,6 +88,7 @@ def reconnector(): self.proc.start() def _log_serial(self): + """Log data to the serial data to the output file""" with open(self.filename, "a+") as f: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect((self.host, self.port)) @@ -103,6 +108,7 @@ def _log_serial(self): return def stop(self): + """Stop the serial logger""" self.proc.terminate() diff --git a/devices/netboot/netboot.py b/devices/netboot/netboot.py index 6c29e9df..43fe5f1e 100644 --- a/devices/netboot/netboot.py +++ b/devices/netboot/netboot.py @@ -20,7 +20,7 @@ import time import yaml -from snappy_device_agents import runcmd, TimeoutError +from snappy_device_agents import runcmd, CmdTimeoutError from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -70,7 +70,7 @@ def _run_cmd_list(self, cmdlist): logger.info("Running %s", cmd) try: rc = runcmd(cmd, timeout=60) - except TimeoutError: + except CmdTimeoutError: raise ProvisioningError("timeout reaching control host!") if rc: raise ProvisioningError( diff --git a/devices/oemrecovery/oemrecovery.py b/devices/oemrecovery/oemrecovery.py index 388e97d5..8d01a03a 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/devices/oemrecovery/oemrecovery.py @@ -21,7 +21,7 @@ import yaml from devices import ProvisioningError, RecoveryError -from snappy_device_agents import TimeoutError +from snappy_device_agents import CmdTimeoutError logger = logging.getLogger() @@ -140,7 +140,7 @@ def _run_cmd_list(self, cmdlist): logger.info("Running %s", cmd) try: output = self._run_device(cmd, timeout=600) - except TimeoutError: + except CmdTimeoutError: raise ProvisioningError("timeout reaching control host!") logger.info(output) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index bd3f09e8..6dfe62c0 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -17,7 +17,6 @@ import json import logging import lzma -import netifaces import os import shutil import socket @@ -28,13 +27,15 @@ import time import urllib.request +import netifaces + IMAGEFILE = "snappy.img" logger = logging.getLogger() -class TimeoutError(Exception): - pass +class CmdTimeoutError(Exception): + """Exception for timeout running running commands""" def get_test_opportunity(job_data="testflinger.json"): @@ -52,20 +53,21 @@ def get_test_opportunity(job_data="testflinger.json"): def filetype(filename): + """Attempt to determine the compression type of a specified file""" magic_headers = { b"\x1f\x8b\x08": "gz", b"\x42\x5a\x68": "bz2", b"\xfd\x37\x7a\x58\x5a\x00": "xz", b"\x51\x46\x49\xfb": "qcow2", } - with open(filename, "rb") as f: - filehead = f.read(1024) - filetype = "unknown" - for k, v in magic_headers.items(): + with open(filename, "rb") as checkfile: + filehead = checkfile.read(1024) + ftype = "unknown" + for k, val in magic_headers.items(): if filehead.startswith(k): - filetype = v + ftype = val break - return filetype + return ftype def download(url, filename=None): @@ -102,7 +104,7 @@ def delayretry(func, args, max_retries=3, delay=0): for retry_count in range(max_retries): try: ret = func(*args) - except Exception: + except Exception: # pylint: disable=broad-except time.sleep(delay) if retry_count == max_retries - 1: raise @@ -132,7 +134,7 @@ def udf_create_image(params): try: output_opt = cmd.index("-o") cmd[output_opt + 1] = imagepath - except Exception: + except ValueError: # if we get here, -o was already not in the image cmd.append("-o") cmd.append(tmp_imagepath) @@ -140,8 +142,8 @@ def udf_create_image(params): logger.info("Creating snappy image with: %s", cmd) try: output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError as e: - logger.error("Image Creation Output:\n %s", e.output) + except subprocess.CalledProcessError as exc: + logger.error("Image Creation Output:\n %s", exc.output) raise logger.info("Image Creation Output:\n %s", output) shutil.move(tmp_imagepath, imagepath) @@ -160,7 +162,7 @@ def get_test_username(job_data="testflinger.json", default="ubuntu"): testflinger_data = get_test_opportunity(job_data) try: user = testflinger_data["test_data"]["test_username"] - except Exception: + except KeyError: user = default return user @@ -176,7 +178,7 @@ def get_test_password(job_data="testflinger.json", default="ubuntu"): testflinger_data = get_test_opportunity(job_data) try: password = testflinger_data["test_data"]["test_password"] - except Exception: + except KeyError: password = default return password @@ -199,10 +201,10 @@ def get_image(job_data="testflinger.json"): download(url) if "url" in image_keys: try: - url = testflinger_data.get("provision_data").get("url") + url = testflinger_data["provision_data"]["url"] image = download(url, IMAGEFILE) - except Exception as e: - logger.error('Error getting "%s": %s', url, e) + except KeyError as exc: + logger.error('Error getting "%s": %s', url, exc) return "" elif "udf-params" in image_keys: udf_params = testflinger_data.get("provision_data").get("udf-params") @@ -222,20 +224,22 @@ def get_local_ip_addr(): """ Return our default IP address for another system to connect to - :return ip: + :return ipaddr: Returns the ip address of this system """ gateways = netifaces.gateways() default_interface = gateways["default"][netifaces.AF_INET][1] - ip = netifaces.ifaddresses(default_interface)[netifaces.AF_INET][0]["addr"] - return ip + ipaddr = netifaces.ifaddresses(default_interface)[netifaces.AF_INET][0][ + "addr" + ] + return ipaddr -def serve_file(q, filename): +def serve_file(queue, filename): """ Wait for a connection, then send the specified file one time - :param q: + :param queue: multiprocessing queue used to send the port number back :param filename: The file to transmit @@ -243,12 +247,12 @@ def serve_file(q, filename): server = socket.socket() server.bind(("0.0.0.0", 0)) port = server.getsockname()[1] - q.put(port) + queue.put(port) server.listen(1) (client, _) = server.accept() - with open(filename, mode="rb") as f: + with open(filename, mode="rb") as imagefile: while True: - data = f.read(16 * 1024 * 1024) + data = imagefile.read(16 * 1024 * 1024) if not data: break client.send(data) @@ -265,7 +269,7 @@ def compress_file(filename): :return compressed_filename: The filename of the compressed file """ - compressed_filename = "{}.xz".format(filename) + compressed_filename = f"{filename}.xz" try: # Remove the compressed_filename if it exists, just in case os.unlink(compressed_filename) @@ -283,7 +287,7 @@ def compress_file(filename): with bz2.BZ2File(filename, "rb") as old_compressed: shutil.copyfileobj(old_compressed, compressed_image) elif filetype(filename) == "qcow2": - raw_filename = "{}.raw".format(filename) + raw_filename = f"{filename}.raw" try: # Remove the original file, unless we already did os.unlink(raw_filename) @@ -292,8 +296,8 @@ def compress_file(filename): cmd = ["qemu-img", "convert", "-O", "raw", filename, raw_filename] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError as e: - logger.error("Image Conversion Output:\n %s", e.output) + except subprocess.CalledProcessError as exc: + logger.error("Image Conversion Output:\n %s", exc.output) raise with open(raw_filename, "rb") as uncompressed_image: with lzma.open(compressed_filename, "wb") as compressed_image: @@ -313,6 +317,8 @@ def compress_file(filename): def configure_logging(config): + """Allow logging with optional support for logstash""" + class AgentFilter(logging.Filter): def __init__(self, agent_name): super(AgentFilter, self).__init__() @@ -340,7 +346,7 @@ def filter(self, record): logger.addHandler(logstash.LogstashHandler(logstash_host, 5959, 1)) -def logmsg(level, msg, *args, **kwargs): +def logmsg(level, msg, *args): """ Front end to logging that splits messages into 4096 byte chunks @@ -350,8 +356,6 @@ def logmsg(level, msg, *args, **kwargs): log message :param args: args for filling message variables - :param kwargs: - key/value args, not currently used, but can be used through logging """ if args: @@ -361,7 +365,7 @@ def logmsg(level, msg, *args, **kwargs): logmsg(level, msg[4096:]) -def runcmd(cmd, env={}, timeout=None): +def runcmd(cmd, env=None, timeout=None): """ Run a command and stream the output to stdout @@ -376,6 +380,8 @@ def runcmd(cmd, env={}, timeout=None): """ # Sanitize the environment, eliminate null values or Popen may choke + if not env: + env = {} env = {x: y for x, y in env.items() if y} if timeout: @@ -392,7 +398,7 @@ def runcmd(cmd, env={}, timeout=None): while process.poll() is None: if deadline and time.time() > deadline: process.terminate() - raise TimeoutError + raise CmdTimeoutError line = process.stdout.readline() if line: sys.stdout.write(line.decode(errors="replace")) @@ -402,7 +408,7 @@ def runcmd(cmd, env={}, timeout=None): return process.returncode -def run_test_cmds(cmds, config=None, env={}): +def run_test_cmds(cmds, config=None, env=None): """ Run the test commands provided This is just a frontend to determine the type of cmds we @@ -440,6 +446,8 @@ def _process_cmds_template_vars(cmds, config=None): """ class IgnoreUnknownFormatter(string.Formatter): + """Try to allow both double and single curly braces""" + def vformat(self, format_string, args, kwargs): tokens = [] for (literal, field_name, spec, conv) in self.parse(format_string): @@ -476,7 +484,7 @@ def vformat(self, format_string, args, kwargs): return formatter.format(cmds, **config) -def _run_test_cmds_list(cmds, config=None, env={}): +def _run_test_cmds_list(cmds, config=None, env=None): """ Run the test commands provided @@ -490,19 +498,21 @@ def _run_test_cmds_list(cmds, config=None, env={}): Return 0 if everything succeeded, or exit code from failed command """ + if not env: + env = {} for cmd in cmds: # Settings from the device yaml configfile like device_ip can be # formatted in test commands like "foo {device_ip}" cmd = _process_cmds_template_vars(cmd, config) logmsg(logging.INFO, "Running: %s", cmd) - rc = runcmd(cmd, env) - if rc: - logmsg(logging.WARNING, "Command failed, rc=%d", rc) - return rc + result = runcmd(cmd, env) + if result: + logmsg(logging.WARNING, "Command failed, rc=%d", result) + return result -def _run_test_cmds_str(cmds, config=None, env={}): +def _run_test_cmds_str(cmds, config=None, env=None): """ Run the test commands provided @@ -516,6 +526,8 @@ def _run_test_cmds_str(cmds, config=None, env={}): Return the value of the return code from the script """ + if not env: + env = {} # If cmds doesn't specify an interpreter, pick a safe default if not cmds.startswith("#!"): cmds = "#!/bin/bash\n" + cmds @@ -524,7 +536,7 @@ def _run_test_cmds_str(cmds, config=None, env={}): with open("tf_cmd_script", mode="w", encoding="utf-8") as tf_cmd_script: tf_cmd_script.write(cmds) os.chmod("tf_cmd_script", 0o775) - rc = runcmd("./tf_cmd_script", env) - if rc: - logmsg(logging.WARNING, "Tests failed, rc=%d", rc) - return rc + result = runcmd("./tf_cmd_script", env) + if result: + logmsg(logging.WARNING, "Tests failed, rc=%d", result) + return result diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py index 1ffd703a..6a9301f3 100755 --- a/snappy_device_agents/cmd.py +++ b/snappy_device_agents/cmd.py @@ -12,6 +12,9 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +""" +Main snappy-device-agents command module +""" import argparse @@ -23,6 +26,7 @@ def main(): + """main command function for snappy-device-agents""" devices = load_devices() parser = argparse.ArgumentParser() From c2a834328ea92bbc47b79a1c2e956aae4afe8191 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 26 Jul 2022 15:11:14 -0500 Subject: [PATCH 367/569] snapcraft.yaml updates --- snapcraft.yaml | 14 ++++++++++++-- testflinger_cli/__init__.py | 6 ++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/snapcraft.yaml b/snapcraft.yaml index 2a5d669b..df4c0e2c 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -1,12 +1,17 @@ name: testflinger-cli -version: '0.1' summary: testflinger-cli description: | The testflinger-cli tool is used for interacting with the testflinger server for submitting test jobs, checking status, getting results, and streaming output. confinement: strict -base: core18 +base: core20 +adopt-info: testflinger-cli + +architectures: + - build-on: amd64 + - build-on: arm64 + - build-on: armhf apps: testflinger-cli: @@ -24,3 +29,8 @@ parts: testflinger-cli: plugin: python source: . + override-pull: | + set -e + snapcraftctl pull + snapcraftctl set-version "$(date +%Y%m%d)" + snapcraftctl set-grade "stable" diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 79a03812..8f0aa0af 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -92,9 +92,7 @@ def _get_ssh_keys(): for ssh_key in key_list: if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): ssh_keys = "" - print( - "Please enter keys in the form lp:userid " "or gh:userid" - ) + print("Please enter keys in the form lp:userid or gh:userid") return key_list @@ -576,7 +574,7 @@ def reserve(self): for ssh_key in ssh_keys: if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"): raise SystemExit( - "Please enter keys in the form lp:userid or " "gh:userid" + "Please enter keys in the form lp:userid or gh:userid" ) template = inspect.cleandoc( """job_queue: {queue} From 4eba18b0dce69477ffc1a56703eb014556cac1b5 Mon Sep 17 00:00:00 2001 From: Nadzeya Hutsko <84857215+nadzyah@users.noreply.github.com> Date: Mon, 1 Aug 2022 21:53:38 +0400 Subject: [PATCH 368/569] Add a job cancellation functionality (#6) * Add .gitignore * Add a job cancellation function * Modify tests for a job cancellation function --- .gitignore | 6 ++++++ testflinger_cli/__init__.py | 29 +++++++---------------------- testflinger_cli/tests/test_cli.py | 11 ++++++----- 3 files changed, 19 insertions(+), 27 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ca62b051 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +env/ +dist/ +build/ +testflinger_cli.egg-info/ +**/__pycache__/ +.coverage diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 8f0aa0af..ad0fdaea 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -255,36 +255,21 @@ def status(self): def cancel(self): """Tell the server to cancel a specified JOB_ID""" try: - job_state = self.client.get_status(self.args.job_id) - self.history.update(self.args.job_id, job_state) + self.client.put( + f"/v1/job/{self.args.job_id}/action", {"action": "cancel"} + ) + self.history.update(self.args.job_id, "cancelled") except client.HTTPError as exc: - if exc.status == 204: - raise SystemExit( - "Job {} not found. Check the job " - "id to be sure it is " - "correct.".format(self.args.job_id) - ) from exc if exc.status == 400: raise SystemExit( - "Invalid job id specified. Check the job " - "id to be sure it is correct." + "Invalid job ID specified or the job is already " + "completed/cancelled." ) from exc if exc.status == 404: raise SystemExit( "Received 404 error from server. Are you " "sure this is a testflinger server?" ) from exc - if job_state in ("complete", "cancelled"): - raise SystemExit( - "Job {} is already in {} state and cannot be " - "cancelled.".format(self.args.job_id, job_state) - ) - self.do_cancel(self.args.job_id) - - def do_cancel(self, job_id): - """Send cancellation request for a specified job_id""" - self.client.post_job_state(job_id, "cancelled") - self.history.update(job_id, "cancelled") def configure(self): """Print or set configuration values""" @@ -485,7 +470,7 @@ def do_poll(self, job_id): if choice == "c": continue if choice == "y": - self.do_cancel(job_id) + self.cancel() # Both y and n will allow the external handler deal with it raise diff --git a/testflinger_cli/tests/test_cli.py b/testflinger_cli/tests/test_cli.py index 0d374518..576e7665 100644 --- a/testflinger_cli/tests/test_cli.py +++ b/testflinger_cli/tests/test_cli.py @@ -42,16 +42,17 @@ def test_status(capsys, requests_mock): def test_cancel(requests_mock): - """Cancel should fail if job is already complete""" + """Cancel should fail if /v1/job//action URL returns 400 code""" jobid = str(uuid.uuid1()) - fake_return = {"job_state": "complete"} - requests_mock.get(URL + "/v1/result/" + jobid, json=fake_return) - requests_mock.post(URL + "/v1/result/" + jobid) + requests_mock.post( + URL + "/v1/job/" + jobid + "/action", + status_code=400, + ) sys.argv = ["", "cancel", jobid] tfcli = testflinger_cli.TestflingerCli() with pytest.raises(SystemExit) as err: tfcli.cancel() - assert "already in complete state and cannot" in err.value.args[0] + assert "already completed/cancelled" in err.value.args[0] def test_submit(capsys, tmp_path, requests_mock): From 696b37811a61b45105a9f4c80c1fbfa4d0b5bb77 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 16 Aug 2022 09:45:33 -0500 Subject: [PATCH 369/569] Pass the job_id when calling cancel() from poll --- testflinger_cli/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index ad0fdaea..b62a65a5 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -252,13 +252,16 @@ def status(self): ) from exc print(job_state) - def cancel(self): + def cancel(self, job_id=None): """Tell the server to cancel a specified JOB_ID""" + if not job_id: + try: + job_id = self.args.job_id + except AttributeError as exc: + raise SystemExit("No job id specified to cancel.") from exc try: - self.client.put( - f"/v1/job/{self.args.job_id}/action", {"action": "cancel"} - ) - self.history.update(self.args.job_id, "cancelled") + self.client.put(f"/v1/job/{job_id}/action", {"action": "cancel"}) + self.history.update(job_id, "cancelled") except client.HTTPError as exc: if exc.status == 400: raise SystemExit( @@ -470,7 +473,7 @@ def do_poll(self, job_id): if choice == "c": continue if choice == "y": - self.cancel() + self.cancel(job_id) # Both y and n will allow the external handler deal with it raise From 71855ea47179334a06843467e2d86e9a57ff9ac3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 17 Aug 2022 15:07:34 -0500 Subject: [PATCH 370/569] Ignore HTTPError when checking job position --- testflinger_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index b62a65a5..39cbde6a 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -447,7 +447,7 @@ def do_poll(self, job_id): if int(queue_pos) != prev_queue_pos: prev_queue_pos = int(queue_pos) print("Jobs ahead in queue: {}".format(queue_pos)) - except IOError: + except (IOError, client.HTTPError): # Ignore/retry any connection errors or timeouts pass time.sleep(10) From e1f6b6752c15bb8030fdbb05cf654845374066f1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 23 Aug 2022 12:21:24 -0500 Subject: [PATCH 371/569] Use logging in a few places where it makes sense --- testflinger_cli/__init__.py | 18 ++++++++++++++++-- testflinger_cli/client.py | 14 ++++++++++---- testflinger_cli/history.py | 8 +++++++- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 39cbde6a..c12de052 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -21,6 +21,7 @@ import inspect import json +import logging import os import sys import time @@ -30,6 +31,7 @@ from testflinger_cli import client, config, history +logger = logging.getLogger(__name__) # Make it easier to run from a checkout basedir = os.path.abspath(os.path.join(__file__, "..")) @@ -41,11 +43,23 @@ def cli(): """Generate the TestflingerCli instance and run it""" try: tfcli = TestflingerCli() + configure_logging() tfcli.run() except KeyboardInterrupt as exc: raise SystemExit from exc +def configure_logging(): + """Configure default logging""" + logging.basicConfig( + level=logging.WARNING, + format=( + "%(levelname)s: %(asctime)s %(filename)s:%(lineno)d -- %(message)s" + ), + datefmt="%Y-%m-%d %H:%M:%S", + ) + + def _get_image(images): image = "" flex_url = "" @@ -531,7 +545,7 @@ def reserve(self): try: queues = self.client.get_queues() except OSError: - print("WARNING: unable to get a list of queues from the server!") + logger.warning("Unable to get a list of queues from the server!") queues = {} queue = self.args.queue or self._get_queue(queues) if queue not in queues.keys(): @@ -542,7 +556,7 @@ def reserve(self): try: images = self.client.get_images(queue) except OSError: - print("WARNING: unable to get a list of images from the server!") + logger.warning("Unable to get a list of images from the server!") images = {} image = self.args.image or _get_image(images) if ( diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index 6ff0df10..ac3dc7cb 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -19,12 +19,16 @@ """ import json +import logging import sys import urllib.parse import requests import yaml +logger = logging.getLogger(__name__) + + class HTTPError(Exception): """Exception class for HTTP error codes""" @@ -50,10 +54,12 @@ def get(self, uri_frag, timeout=15): try: req = requests.get(uri, timeout=timeout) except requests.exceptions.ConnectTimeout: - print("Timeout while trying to communicate with the server.") + logger.error( + "Timeout while trying to communicate with the server." + ) raise except requests.exceptions.ConnectionError: - print("Unable to communicate with specified server.") + logger.error("Unable to communicate with specified server.") raise if req.status_code != 200: raise HTTPError(req.status_code) @@ -70,10 +76,10 @@ def put(self, uri_frag, data, timeout=15): try: req = requests.post(uri, json=data, timeout=timeout) except requests.exceptions.ConnectTimeout: - print("Timout while trying to communicate with the server.") + logger.error("Timout while trying to communicate with the server.") sys.exit(1) except requests.exceptions.ConnectionError: - print("Unable to communicate with specified server.") + logger.error("Unable to communicate with specified server.") sys.exit(1) if req.status_code != 200: raise HTTPError(req.status_code) diff --git a/testflinger_cli/history.py b/testflinger_cli/history.py index f6c57957..c957a9e6 100644 --- a/testflinger_cli/history.py +++ b/testflinger_cli/history.py @@ -19,12 +19,16 @@ """ import json +import logging import os from collections import OrderedDict from datetime import datetime import xdg +logger = logging.getLogger(__name__) + + class TestflingerCliHistory: """History class used for storing job history on a device""" @@ -58,7 +62,9 @@ def load(self): self.history.update(json.load(history_file)) except (OSError, ValueError): # If there's any error loading the history, ignore it - print("Error loading history file from", self.historyfile) + logger.error( + "Error loading history file from %s", self.historyfile + ) def save(self): """Save the history out to the history file""" From 7c7086a268631cd7de9920b9edc8fbb88de7d39d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 27 Sep 2022 15:19:58 -0500 Subject: [PATCH 372/569] Retry more persistently if timeouts or connection errors occur --- testflinger_cli/__init__.py | 10 ++++++---- testflinger_cli/client.py | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index c12de052..62997541 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -468,15 +468,15 @@ def do_poll(self, job_id): output = "" try: output = self.get_latest_output(job_id) + if output: + print(output, end="", flush=True) + job_state = self.get_job_state(job_id) + self.history.update(job_id, job_state) except IOError: # Any kind of IOError here should be a connection issue or # a timeout so we should ignore it and retry on the next # pass through the loop continue - if output: - print(output, end="", flush=True) - job_state = self.get_job_state(job_id) - self.history.update(job_id, job_state) except KeyboardInterrupt: choice = input( "\nCancel job {} before exiting " @@ -658,4 +658,6 @@ def get_job_state(self, job_id): "Received 404 error from server. Are you " "sure this is a testflinger server?" ) from exc + except IOError: + logger.warning("Unable to retrieve job state.") return "unknown" diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index ac3dc7cb..f11f8583 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -53,14 +53,15 @@ def get(self, uri_frag, timeout=15): uri = urllib.parse.urljoin(self.server, uri_frag) try: req = requests.get(uri, timeout=timeout) - except requests.exceptions.ConnectTimeout: + except requests.exceptions.ConnectionError: + logger.error("Unable to communicate with specified server.") + raise + except IOError: + # This should catch all other timeout cases logger.error( "Timeout while trying to communicate with the server." ) raise - except requests.exceptions.ConnectionError: - logger.error("Unable to communicate with specified server.") - raise if req.status_code != 200: raise HTTPError(req.status_code) return req.text From cc42683f5f188c536fc0ce6c0fb123c3eefcb7ae Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 28 Sep 2022 14:57:29 -0500 Subject: [PATCH 373/569] Further simplification of do_poll --- testflinger_cli/__init__.py | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 62997541..55818918 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -456,27 +456,20 @@ def do_poll(self, job_id): break try: if job_state == "waiting": - try: - queue_pos = self.client.get_job_position(job_id) - if int(queue_pos) != prev_queue_pos: - prev_queue_pos = int(queue_pos) - print("Jobs ahead in queue: {}".format(queue_pos)) - except (IOError, client.HTTPError): - # Ignore/retry any connection errors or timeouts - pass + queue_pos = self.client.get_job_position(job_id) + if int(queue_pos) != prev_queue_pos: + prev_queue_pos = int(queue_pos) + print("Jobs ahead in queue: {}".format(queue_pos)) time.sleep(10) output = "" - try: - output = self.get_latest_output(job_id) - if output: - print(output, end="", flush=True) - job_state = self.get_job_state(job_id) - self.history.update(job_id, job_state) - except IOError: - # Any kind of IOError here should be a connection issue or - # a timeout so we should ignore it and retry on the next - # pass through the loop - continue + output = self.get_latest_output(job_id) + if output: + print(output, end="", flush=True) + job_state = self.get_job_state(job_id) + self.history.update(job_id, job_state) + except (IOError, client.HTTPError): + # Ignore/retry any connection errors or timeouts + pass except KeyboardInterrupt: choice = input( "\nCancel job {} before exiting " From 65a1797af2e7ef3c7ca5a62d5be18346886e81a2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 04:37:29 +0000 Subject: [PATCH 374/569] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..39a2b6e9 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} From eaa0768878505dd93bf9f842d84376490c3374e8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 04:44:19 +0000 Subject: [PATCH 375/569] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..39a2b6e9 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} From a440893f44766f279d4af83459e01bf4492ca424 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 05:13:48 +0000 Subject: [PATCH 376/569] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..39a2b6e9 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} From 089a8abd2f5db4d9e934cf83be03d88caae48fd0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 17:47:16 +0000 Subject: [PATCH 377/569] Update actions/setup-python action to v4 --- .github/workflows/tox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 7dfcf2cd..c9011a8b 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: Install tox From ba7f889cea53d52345ceab78a8f75f72933b9e6e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 17:48:38 +0000 Subject: [PATCH 378/569] Update dependency python-logstash to v0.4.8 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1538acac..6e41b606 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.11 netifaces==0.10.4 -python-logstash==0.4.5 +python-logstash==0.4.8 # For the spi-agent docopt==0.6.2 requests==2.7.0 From bbcb403bb13a0f207492ed2662fdf15e8718d560 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 17:48:44 +0000 Subject: [PATCH 379/569] Update dependency PyYAML to v3.13 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1538acac..715d9bda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -PyYAML==3.11 +PyYAML==3.13 netifaces==0.10.4 python-logstash==0.4.5 # For the spi-agent From 7b035ccd027723a39dea98bfde2fa7647c9992f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 17:49:34 +0000 Subject: [PATCH 380/569] Update actions/setup-python action to v4 --- .github/workflows/tox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 7dfcf2cd..c9011a8b 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: Install tox From 13d5887a9c931fc91a10f9bba062bf1f9ff8cb21 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 17:49:40 +0000 Subject: [PATCH 381/569] Update dependency xdg to v5 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 88d5dd8e..02351a03 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ # from setuptools import setup -INSTALL_REQUIRES = ["pyyaml", "requests", "xdg<4.0"] +INSTALL_REQUIRES = ["pyyaml", "requests", "xdg<5.2"] setup( name="testflinger-cli", From 356bed2949669de8e3b3cb6a243d43e755cbf70d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 18:04:52 +0000 Subject: [PATCH 382/569] Update dependency flake8 to v2.6.2 --- test_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_requirements.txt b/test_requirements.txt index 3af2c523..1c95b2c8 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1 +1 @@ -flake8==2.4.0 +flake8==2.6.2 From a3baa0e280a6a43a5f13d6a7088d172f084a12c7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 18:05:44 +0000 Subject: [PATCH 383/569] Update dependency netifaces to v0.11.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3540cd8d..8221e9d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.13 -netifaces==0.10.4 +netifaces==0.11.0 python-logstash==0.4.8 # For the spi-agent docopt==0.6.2 From 7ff24ab3f1e991dbc605a1422191f41eb841245c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 4 Oct 2022 16:37:34 -0500 Subject: [PATCH 384/569] Add an argument to turn on debug messages --- testflinger_cli/__init__.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 55818918..70242721 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -151,6 +151,9 @@ def get_args(self): default=None, help="Configuration file to use", ) + parser.add_argument( + "-d", "--debug", action="store_true", help="Enable debug logging" + ) parser.add_argument( "--server", default=None, help="Testflinger server to use" ) @@ -451,10 +454,12 @@ def do_poll(self, job_id): prev_queue_pos = None if job_state == "waiting": print("This job is waiting on a node to become available.") - while job_state != "complete": - if job_state == "cancelled": - break + while True: try: + job_state = self.get_job_state(job_id) + self.history.update(job_id, job_state) + if job_state in ("cancelled", "complete"): + break if job_state == "waiting": queue_pos = self.client.get_job_position(job_id) if int(queue_pos) != prev_queue_pos: @@ -465,11 +470,10 @@ def do_poll(self, job_id): output = self.get_latest_output(job_id) if output: print(output, end="", flush=True) - job_state = self.get_job_state(job_id) - self.history.update(job_id, job_state) except (IOError, client.HTTPError): - # Ignore/retry any connection errors or timeouts - pass + # Ignore/retry or debug any connection errors or timeouts + if self.args.debug: + logging.exception("Error polling for job output") except KeyboardInterrupt: choice = input( "\nCancel job {} before exiting " From 1489167853dceaa2dff3b979f24510c051a2d84a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 5 Oct 2022 02:09:52 +0000 Subject: [PATCH 385/569] Update actions/setup-python action to v4 --- .github/workflows/tox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 7dfcf2cd..c9011a8b 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: Install tox From d782c13b8c41624d5e80060fc3e37e78ff9e41f4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 7 Oct 2022 08:53:24 -0500 Subject: [PATCH 386/569] Match updated filename for removing 99-fake_cloud.cfg --- devices/cm3/cm3.py | 2 +- devices/muxpi/muxpi.py | 2 +- devices/rpi3/rpi3.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/devices/cm3/cm3.py b/devices/cm3/cm3.py index e48417d9..3dd5812f 100644 --- a/devices/cm3/cm3.py +++ b/devices/cm3/cm3.py @@ -247,7 +247,7 @@ def create_user(self, image_type): # cloud-init won't find the user-data we give it rm_cmd = "sudo rm -f {}".format( os.path.join( - base, "etc/cloud/cloud.cfg.d/99-fake_cloud.cfg" + base, "etc/cloud/cloud.cfg.d/99-fake?cloud.cfg" ) ) self._run_control(rm_cmd) diff --git a/devices/muxpi/muxpi.py b/devices/muxpi/muxpi.py index 412e7042..6daf85e5 100644 --- a/devices/muxpi/muxpi.py +++ b/devices/muxpi/muxpi.py @@ -389,7 +389,7 @@ def create_user(self, image_type): # cloud-init won't find the user-data we give it rm_cmd = "sudo rm -f {}".format( os.path.join( - base, "etc/cloud/cloud.cfg.d/99-fake_cloud.cfg" + base, "etc/cloud/cloud.cfg.d/99-fake?cloud.cfg" ) ) self._run_control(rm_cmd) diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py index 63a2c59f..11a27192 100644 --- a/devices/rpi3/rpi3.py +++ b/devices/rpi3/rpi3.py @@ -401,7 +401,7 @@ def create_user(self, image_type): # cloud-init won't find the user-data we give it rm_cmd = "sudo rm -f {}".format( os.path.join( - base, "etc/cloud/cloud.cfg.d/99-fake_cloud.cfg" + base, "etc/cloud/cloud.cfg.d/99-fake?cloud.cfg" ) ) self._run_control(rm_cmd) From 0744e9a4fcc63cdd79e1bdbe77198dac0584486a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 18 Oct 2022 15:45:25 -0500 Subject: [PATCH 387/569] Issue a deprecation warning if double braces are found --- snappy_device_agents/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 6dfe62c0..3ba7ea75 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -445,6 +445,11 @@ def _process_cmds_template_vars(cmds, config=None): Config data for the device which can be used for filling templates """ + logmsg( + logging.WARNING, + "DEPRECATED - Detected use of double-braces in test_cmds", + ) + class IgnoreUnknownFormatter(string.Formatter): """Try to allow both double and single curly braces""" @@ -503,7 +508,8 @@ def _run_test_cmds_list(cmds, config=None, env=None): for cmd in cmds: # Settings from the device yaml configfile like device_ip can be # formatted in test commands like "foo {device_ip}" - cmd = _process_cmds_template_vars(cmd, config) + if "{{" in cmd: + cmd = _process_cmds_template_vars(cmd, config) logmsg(logging.INFO, "Running: %s", cmd) result = runcmd(cmd, env) @@ -532,7 +538,8 @@ def _run_test_cmds_str(cmds, config=None, env=None): if not cmds.startswith("#!"): cmds = "#!/bin/bash\n" + cmds - cmds = _process_cmds_template_vars(cmds, config) + if "{{" in cmds: + cmds = _process_cmds_template_vars(cmds, config) with open("tf_cmd_script", mode="w", encoding="utf-8") as tf_cmd_script: tf_cmd_script.write(cmds) os.chmod("tf_cmd_script", 0o775) From 5a1e79220e839aec44ca5a67e177d8368d4ddf3f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 20 Oct 2022 17:22:07 -0500 Subject: [PATCH 388/569] Minimum patch to fix issues with reboot return code and missing ssh key --- devices/dragonboard/__init__.py | 1 - devices/dragonboard/dragonboard.py | 62 +++++++++++++++--------------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/devices/dragonboard/__init__.py b/devices/dragonboard/__init__.py index dc5a16ae..45d0ccd9 100644 --- a/devices/dragonboard/__init__.py +++ b/devices/dragonboard/__init__.py @@ -45,7 +45,6 @@ def provision(self, args): ) serial_proc.start() try: - device.ensure_master_image() device.provision() except Exception as e: raise e diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index b0aa5dd2..50d7b609 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -58,12 +58,9 @@ def _run_control(self, cmd, timeout=60): "linaro@{}".format(self.config["device_ip"]), cmd, ] - try: - output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=timeout - ) - except subprocess.CalledProcessError as e: - raise ProvisioningError(e.output) + output = subprocess.check_output( + cmd, stderr=subprocess.STDOUT, timeout=timeout + ) return output def setboot(self, mode): @@ -108,6 +105,23 @@ def hardreset(self): except subprocess.TimeoutExpired: raise RecoveryError("timeout reaching control host!") + def copy_ssh_id(self, test_username, test_password): + cmd = [ + "sshpass", + "-p", + test_password, + "ssh-copy-id", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "{}@{}".format(test_username, self.config["device_ip"]), + ] + try: + subprocess.check_call(cmd) + except subprocess.SubprocessError: + pass + def ensure_test_image(self, test_username, test_password): """ Actively switch the device to boot the test image. @@ -132,24 +146,8 @@ def ensure_test_image(self, test_username, test_password): # Retry for a while since we might still be rebooting test_image_booted = False while time.time() - started < 600: - try: - time.sleep(10) - cmd = [ - "sshpass", - "-p", - test_password, - "ssh-copy-id", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "{}@{}".format(test_username, self.config["device_ip"]), - ] - subprocess.check_call(cmd) - test_image_booted = self.is_test_image_booted() - except subprocess.SubprocessError: - # Keep trying even if this command fails - pass + self.copy_ssh_id(test_username, test_password) + test_image_booted = self.is_test_image_booted() if test_image_booted: break # Check again if we are in the master image @@ -267,7 +265,7 @@ def flash_test_image(self, server_ip, server_port): "sudo umount {}*".format(self.config["test_device"]), timeout=30, ) - except ProvisioningError: + except subprocess.SubprocessError: # We might not be mounted, so expect this to fail sometimes pass cmd = "nc {} {}| unxz| sudo dd of={} bs=16M".format( @@ -380,6 +378,14 @@ def wipe_test_device(self): def provision(self): """Provision the device""" url = self.job_data["provision_data"].get("url") + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + test_password = self.job_data.get("test_data", {}).get( + "test_password", "ubuntu" + ) + self.copy_ssh_id(test_username, test_password) + self.ensure_master_image() if url: snappy_device_agents.download(url, "snappy.img") else: @@ -406,12 +412,6 @@ def provision(self): logger.exception("Bad data passed for provisioning") raise ProvisioningError("Error copying system-user assertion") image_file = snappy_device_agents.compress_file("snappy.img") - test_username = self.job_data.get("test_data", {}).get( - "test_username", "ubuntu" - ) - test_password = self.job_data.get("test_data", {}).get( - "test_password", "ubuntu" - ) server_ip = snappy_device_agents.get_local_ip_addr() serve_q = multiprocessing.Queue() file_server = multiprocessing.Process( From 61ad7b45b8a9c3088a87047295c78c89fd2da787 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 20 Oct 2022 17:34:51 -0500 Subject: [PATCH 389/569] set default username/password in instance vars --- devices/dragonboard/dragonboard.py | 35 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index 50d7b609..98b11511 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -37,6 +37,12 @@ def __init__(self, config, job_data): self.config = yaml.safe_load(configfile) with open(job_data) as j: self.job_data = json.load(j) + self.test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + self.test_password = self.job_data.get("test_data", {}).get( + "test_password", "ubuntu" + ) def _run_control(self, cmd, timeout=60): """ @@ -69,6 +75,8 @@ def setboot(self, mode): :param mode: One of 'master' or 'test' + :raises KeyError: + if script keys are missing from the config file :raises ProvisioningError: If the command times out or anything else fails. @@ -105,31 +113,30 @@ def hardreset(self): except subprocess.TimeoutExpired: raise RecoveryError("timeout reaching control host!") - def copy_ssh_id(self, test_username, test_password): + def copy_ssh_id(self): + """ + Copy the ssh key to the device. + """ cmd = [ "sshpass", "-p", - test_password, + self.test_password, "ssh-copy-id", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", - "{}@{}".format(test_username, self.config["device_ip"]), + "{}@{}".format(self.test_username, self.config["device_ip"]), ] try: subprocess.check_call(cmd) except subprocess.SubprocessError: pass - def ensure_test_image(self, test_username, test_password): + def ensure_test_image(self): """ Actively switch the device to boot the test image. - :param test_username: - Username of the default user in the test image - :param test_password: - Password of the default user in the test image :raises ProvisioningError: If the command times out or anything else fails. """ @@ -146,7 +153,7 @@ def ensure_test_image(self, test_username, test_password): # Retry for a while since we might still be rebooting test_image_booted = False while time.time() - started < 600: - self.copy_ssh_id(test_username, test_password) + self.copy_ssh_id() test_image_booted = self.is_test_image_booted() if test_image_booted: break @@ -378,13 +385,7 @@ def wipe_test_device(self): def provision(self): """Provision the device""" url = self.job_data["provision_data"].get("url") - test_username = self.job_data.get("test_data", {}).get( - "test_username", "ubuntu" - ) - test_password = self.job_data.get("test_data", {}).get( - "test_password", "ubuntu" - ) - self.copy_ssh_id(test_username, test_password) + self.copy_ssh_id() self.ensure_master_image() if url: snappy_device_agents.download(url, "snappy.img") @@ -431,7 +432,7 @@ def provision(self): self.create_user() self.setup_sudo() logger.info("Booting Test Image") - self.ensure_test_image(test_username, test_password) + self.ensure_test_image() except (ValueError, subprocess.SubprocessError): # wipe out whatever we installed if things go badly self.wipe_test_device() From 5032c253dfb0e97bea008a9e55fee2f1ff15e1e0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 20 Oct 2022 18:17:04 -0500 Subject: [PATCH 390/569] dragonboard provisioning with model assertions is no longer supported --- devices/dragonboard/dragonboard.py | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/devices/dragonboard/dragonboard.py b/devices/dragonboard/dragonboard.py index 98b11511..da7a5cd7 100644 --- a/devices/dragonboard/dragonboard.py +++ b/devices/dragonboard/dragonboard.py @@ -387,31 +387,7 @@ def provision(self): url = self.job_data["provision_data"].get("url") self.copy_ssh_id() self.ensure_master_image() - if url: - snappy_device_agents.download(url, "snappy.img") - else: - try: - model_assertion = self.config["model_assertion"] - channel = self.job_data["provision_data"]["channel"] - extra_snaps = self.job_data.get("provision_data").get( - "extra-snaps", [] - ) - cmd = [ - "sudo", - "ubuntu-image", - "-c", - channel, - model_assertion, - "-o", - "snappy.img", - ] - for snap in extra_snaps: - cmd.append("--extra-snaps") - cmd.append(snap) - subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except Exception: - logger.exception("Bad data passed for provisioning") - raise ProvisioningError("Error copying system-user assertion") + snappy_device_agents.download(url, "snappy.img") image_file = snappy_device_agents.compress_file("snappy.img") server_ip = snappy_device_agents.get_local_ip_addr() serve_q = multiprocessing.Queue() From dfa79d8805758af4b8cc03f2c89639dbfbbca2dc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 21 Oct 2022 09:28:37 -0500 Subject: [PATCH 391/569] Remove requirements files, those are not used at this point at least --- requirements.txt | 8 -------- test_requirements.txt | 1 - 2 files changed, 9 deletions(-) delete mode 100644 requirements.txt delete mode 100644 test_requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8221e9d5..00000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -PyYAML==3.13 -netifaces==0.11.0 -python-logstash==0.4.8 -# For the spi-agent -docopt==0.6.2 -requests==2.7.0 -requests-oauthlib==0.5.0 -oauthlib==1.0.1 diff --git a/test_requirements.txt b/test_requirements.txt deleted file mode 100644 index 1c95b2c8..00000000 --- a/test_requirements.txt +++ /dev/null @@ -1 +0,0 @@ -flake8==2.6.2 From 8f6ce62ce163da827361f0456f41af2af1ecb2a3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 31 Oct 2022 07:46:19 -0500 Subject: [PATCH 392/569] Handle ValueError from get_status in case we get a JSONDecodeError --- testflinger_cli/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 70242721..71a37121 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -655,6 +655,8 @@ def get_job_state(self, job_id): "Received 404 error from server. Are you " "sure this is a testflinger server?" ) from exc - except IOError: + except (IOError, ValueError): + # For other types of network errors, or JSONDecodeError if we got + # a bad return from get_status() logger.warning("Unable to retrieve job state.") return "unknown" From 9a42444278c2d2db0c67dd80b563a0bea8bcfc6c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 31 Oct 2022 09:33:29 -0500 Subject: [PATCH 393/569] reuse get_job_state for getting a definite string for state --- testflinger_cli/__init__.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 71a37121..464d58a7 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -248,25 +248,9 @@ def get_args(self): def status(self): """Show the status of a specified JOB_ID""" - try: - job_state = self.client.get_status(self.args.job_id) + job_state = self.get_job_state(self.args.job_id) + if job_state != "unknown": self.history.update(self.args.job_id, job_state) - except client.HTTPError as exc: - if exc.status == 204: - raise SystemExit( - "No data found for that job id. Check the " - "job id to be sure it is correct" - ) from exc - if exc.status == 400: - raise SystemExit( - "Invalid job id specified. Check the job " - "id to be sure it is correct" - ) from exc - if exc.status == 404: - raise SystemExit( - "Received 404 error from server. Are you " - "sure this is a testflinger server?" - ) from exc print(job_state) def cancel(self, job_id=None): From d72fdd8e15bdeda0551f20878d27b7f4d77db837 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 30 Nov 2022 15:20:35 -0600 Subject: [PATCH 394/569] Move check_job_state to client.py --- testflinger_agent/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index e3d3d15b..e1c1292e 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016-2020 Canonical +# Copyright (C) 2016-2022 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -73,6 +73,11 @@ def check_jobs(self): # Wait a little extra before trying again time.sleep(60) + def check_job_state(self, job_id): + job_data = self.get_result(job_id) + if job_data: + return job_data.get("job_state") + def repost_job(self, job_data): """ "Resubmit the job to the testflinger server with the same id From fdb016a2c950473a55de314b586733c4819d514d Mon Sep 17 00:00:00 2001 From: Po-Hsu Lin Date: Tue, 6 Dec 2022 12:21:53 +0800 Subject: [PATCH 395/569] Fix post hard-reset connectivity check If we break out of the while loop when the DUT is accessible after the hard-reset, it will run the raise() in the end and the reservation will fail. We should use return instead. --- devices/noprovision/noprovision.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/noprovision/noprovision.py b/devices/noprovision/noprovision.py index 4f67aea8..0a7e5183 100644 --- a/devices/noprovision/noprovision.py +++ b/devices/noprovision/noprovision.py @@ -95,7 +95,7 @@ def ensure_test_image(self, test_username): "/bin/true", ] subprocess.check_call(cmd) - break + return except subprocess.SubprocessError: # keep going if we aren't booted yet pass From 8c4b7b4d136290ad24cfcf27bc5e755be8380a60 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 11 Jan 2023 16:30:08 -0600 Subject: [PATCH 396/569] Convert to using pyproject.toml --- pyproject.toml | 21 +++++++++++++++++++ setup.py | 19 ++--------------- testflinger_agent/__init__.py | 2 +- testflinger-agent => testflinger_agent/cmd.py | 13 +++++++----- tox.ini | 2 +- 5 files changed, 33 insertions(+), 24 deletions(-) rename testflinger-agent => testflinger_agent/cmd.py (74%) mode change 100755 => 100644 diff --git a/pyproject.toml b/pyproject.toml index a8f43fef..337ffd4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,23 @@ +[build-system] +requires = [ + "setuptools", + "setuptools-scm", +] +build-backend = "setuptools.build_meta" + +[project] +name = "testflinger-agent" +description = "Testflinger agent" +readme = "README.rst" +dependencies = [ + "PyYAML", + "requests", + "voluptuous", +] +dynamic = ["version"] + +[project.scripts] +testflinger-agent = "testflinger_agent.cmd:main" + [tool.black] line-length = 79 diff --git a/setup.py b/setup.py index c9928c76..26bd3447 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2016-2022 Canonical +# Copyright (C) 2016-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,19 +18,4 @@ from setuptools import setup -INSTALL_REQUIRES = [ - "PyYAML", - "requests", - "voluptuous", -] - -setup( - name="testflinger-agent", - version="1.0", - long_description=__doc__, - packages=["testflinger_agent"], - zip_safe=False, - install_requires=INSTALL_REQUIRES, - setup_requires=["pytest-runner"], - scripts=["testflinger-agent"], -) +setup() diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index f248f28a..59852085 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) -def main(): +def start_agent(): args = parse_args() config = load_config(args.config) configure_logging(config) diff --git a/testflinger-agent b/testflinger_agent/cmd.py old mode 100755 new mode 100644 similarity index 74% rename from testflinger-agent rename to testflinger_agent/cmd.py index 53ba7005..071f27ff --- a/testflinger-agent +++ b/testflinger_agent/cmd.py @@ -12,19 +12,22 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +"""Entrypoint for the testflinger-agent command""" import logging import sys -from testflinger_agent import main +from testflinger_agent import start_agent logger = logging.getLogger(__name__) -if __name__ == "__main__": + +def main(): + """main() entrypoint for the testflinger-agent command""" try: - main() + start_agent() except KeyboardInterrupt: logger.info("Caught interrupt, exiting!") sys.exit(0) - except Exception as e: - logger.exception(e) + except Exception as exc: # pylint: disable=broad-except + logger.exception(exc) diff --git a/tox.ini b/tox.ini index 1305fc8b..53a3d0f8 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,6 @@ deps = requests-mock commands = {envbindir}/python setup.py develop - {envbindir}/python -m black --check setup.py testflinger-agent testflinger_agent + {envbindir}/python -m black --check setup.py testflinger_agent {envbindir}/python -m flake8 setup.py testflinger_agent {envbindir}/python -m pytest --doctest-modules --cov=testflinger_agent From 7380c6fc6adc768bfc7510b666debfe24a1c5cb9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 12 Jan 2023 11:17:50 -0600 Subject: [PATCH 397/569] update python binary version and copyright header years --- setup.py | 2 +- testflinger_agent/__init__.py | 2 +- testflinger_agent/cmd.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 26bd3447..a3456e80 100755 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # Copyright (C) 2016-2023 Canonical # # This program is free software: you can redistribute it and/or modify diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 59852085..e0b92c30 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016-2017 Canonical +# Copyright (C) 2016-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/testflinger_agent/cmd.py b/testflinger_agent/cmd.py index 071f27ff..f507cb3c 100644 --- a/testflinger_agent/cmd.py +++ b/testflinger_agent/cmd.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python3 -# Copyright (C) 2016 Canonical +# Copyright (C) 2016-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by From 56a5c49a9fc20f64123ffbd379c94aba4f21fcb6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 31 Jan 2023 16:45:59 -0600 Subject: [PATCH 398/569] Remove currently unused logstash support --- README.rst | 12 ------------ snappy_device_agents/__init__.py | 11 +---------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/README.rst b/README.rst index f880aeab..59521bb4 100644 --- a/README.rst +++ b/README.rst @@ -92,18 +92,6 @@ Because we install to the hard drive, and not a mmc with a known location, you s - snmpset -c private -v1 pdu11.cert-maas.taipei .1.3.6.1.4.1.318.1.1.12.3.3.1.1.4.6 i 1 -Logstash Logging -================ - -Log messages can optionally be directed to a logstash server by adding -two additional values in the yaml file:: - - logstash_host: 10.0.3.207 - agent_name: test001 - -Logstash_host is the logstash server the messages will be sent to on port 5959. -Agent_name should be the name of the device this agent represents. It -will be added as extra data in the log message. Exit Status =========== diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3ba7ea75..eb2f5926 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -317,7 +317,7 @@ def compress_file(filename): def configure_logging(config): - """Allow logging with optional support for logstash""" + """Setup logging""" class AgentFilter(logging.Filter): def __init__(self, agent_name): @@ -335,15 +335,6 @@ def filter(self, record): ) agent_name = config.get("agent_name", "") logger.addFilter(AgentFilter(agent_name)) - logstash_host = config.get("logstash_host", None) - - if logstash_host is not None: - try: - import logstash - except ImportError: - pass - else: - logger.addHandler(logstash.LogstashHandler(logstash_host, 5959, 1)) def logmsg(level, msg, *args): From dc470ea9aeea3a1c217979a2e15da2986808b59c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 3 Feb 2023 10:48:15 -0600 Subject: [PATCH 399/569] Fix changes requested by new version of black formatting --- snappy_device_agents/__init__.py | 2 +- snappy_device_agents/cmd.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 3ba7ea75..5e567cc2 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -455,7 +455,7 @@ class IgnoreUnknownFormatter(string.Formatter): def vformat(self, format_string, args, kwargs): tokens = [] - for (literal, field_name, spec, conv) in self.parse(format_string): + for literal, field_name, spec, conv in self.parse(format_string): # replace double braces if parse removed them literal = literal.replace("{", "{{").replace("}", "}}") # if the field is {}, just add escaped empty braces diff --git a/snappy_device_agents/cmd.py b/snappy_device_agents/cmd.py index 6a9301f3..2539c5c8 100755 --- a/snappy_device_agents/cmd.py +++ b/snappy_device_agents/cmd.py @@ -32,12 +32,12 @@ def main(): # First add a subcommand for each supported device type dev_parser = parser.add_subparsers() - for (dev_name, dev_class) in devices: + for dev_name, dev_class in devices: dev_subparser = dev_parser.add_parser(dev_name) dev_module = dev_class() # Next add the subcommands that can be used and the methods they run cmd_subparser = dev_subparser.add_subparsers() - for (cmd, func) in ( + for cmd, func in ( ("provision", dev_module.provision), ("runtest", dev_module.runtest), ("reserve", dev_module.reserve), From 4d035331fe90085dfebb0ab52515490a3d4c0c12 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 3 Jan 2023 14:45:10 -0600 Subject: [PATCH 400/569] Improve handling of maas errors and a few more pylint cleanups --- devices/maas2/__init__.py | 10 ++--- devices/maas2/maas2.py | 93 +++++++++++++++++++++++---------------- 2 files changed, 61 insertions(+), 42 deletions(-) diff --git a/devices/maas2/__init__.py b/devices/maas2/__init__.py index 9b5f9e85..f0f834f7 100644 --- a/devices/maas2/__init__.py +++ b/devices/maas2/__init__.py @@ -15,18 +15,18 @@ """Ubuntu MaaS 2.x CLI support code.""" import logging -import yaml import snappy_device_agents -from devices.maas2.maas2 import Maas2 -from snappy_device_agents import logmsg +import yaml from devices import ( - catch, DefaultDevice, - RecoveryError, ProvisioningError, + RecoveryError, SerialLogger, + catch, ) +from devices.maas2.maas2 import Maas2 +from snappy_device_agents import logmsg device_name = "maas2" diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 61f6913d..295dcea8 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -19,9 +19,9 @@ import logging import subprocess import time -import yaml - from collections import OrderedDict + +import yaml from devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -83,7 +83,9 @@ def _install_efitools_snap(self): "ubuntu@{}".format(self.config["device_ip"]), "sudo snap install efi-tools-ijohnson --devmode --edge", ] - subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False + ) cmd = [ "ssh", "-o", @@ -93,7 +95,9 @@ def _install_efitools_snap(self): "ubuntu@{}".format(self.config["device_ip"]), "sudo snap alias efi-tools-ijohnson.efibootmgr efibootmgr", ] - subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False + ) def _get_efi_data(self): cmd = [ @@ -106,7 +110,7 @@ def _get_efi_data(self): "sudo efibootmgr -v", ] p = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False ) # If it fails the first time, try installing efitools snap if p.returncode: @@ -121,7 +125,10 @@ def _get_efi_data(self): "sudo efibootmgr -v", ] p = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, ) if p.returncode: return None @@ -146,7 +153,7 @@ def _set_efi_data(self, boot_order): "sudo efibootmgr -o {}".format(boot_order), ] p = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False ) if p.returncode: self._logger_error( @@ -191,24 +198,26 @@ def _run_tpm_clear_cmd(self): "ubuntu@{}".format(self.config["device_ip"]), "echo 5 | sudo tee /sys/class/tpm/tpm0/ppi/request", ] - try: - subprocess.check_call(cmd, timeout=30) - cmd = [ - "ssh", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "ubuntu@{}".format(self.config["device_ip"]), - "cat /sys/class/tpm/tpm0/ppi/request", - ] - output = subprocess.check_output(cmd, timeout=30) - # If we now see "5" in that file, then clearing tpm succeeded - if output.decode("utf-8").strip() == "5": - return True - except Exception: - # Fall through if we fail for any reason - pass + proc = subprocess.run(cmd, timeout=30, check=False) + if proc.returncode: + return False + + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "ubuntu@{}".format(self.config["device_ip"]), + "cat /sys/class/tpm/tpm0/ppi/request", + ] + proc = subprocess.run(cmd, timeout=30, check=False) + if proc.returncode: + return False + + # If we now see "5" in that file, then clearing tpm succeeded + if proc.stdout.decode("utf-8").strip() == "5": + return True return False def deploy_node(self, distro="bionic", kernel=None, user_data=None): @@ -223,7 +232,12 @@ def deploy_node(self, distro="bionic", kernel=None, user_data=None): "system_id={}".format(self.node_id), ] # Do not use runcmd for this - we need the output, not the end user - subprocess.check_call(cmd) + proc = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False + ) + if proc.returncode: + self._logger_error(f"maas error running: {' '.join(cmd)}") + raise ProvisioningError(proc.stdout.decode()) self._logger_info( "Starting node {} " "with distro {}".format(self.agent_name, distro) @@ -241,14 +255,14 @@ def deploy_node(self, distro="bionic", kernel=None, user_data=None): if user_data: data = base64.b64encode(user_data.encode()).decode() cmd.append("user_data={}".format(data)) - process = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + proc = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False ) try: - process.check_returncode() + proc.check_returncode() except subprocess.CalledProcessError: self._logger_error("maas-cli call failure happens.") - raise ProvisioningError(process.stdout.decode()) + raise ProvisioningError(proc.stdout.decode()) # Make sure the device is available before returning minutes_spent = 0 @@ -281,7 +295,7 @@ def deploy_node(self, distro="bionic", kernel=None, user_data=None): 'Device {} still in "{}" state, deployment ' "failed!".format(self.agent_name, status) ) - self._logger_error(process.stdout.decode()) + self._logger_error(proc.stdout.decode()) exception_msg = ( "Provisioning failed because deployment timeout. " + "Deploying for more than " @@ -301,8 +315,8 @@ def check_test_image_booted(self): "/bin/true", ] try: - subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) - except Exception: + subprocess.check_call(cmd, stderr=subprocess.STDOUT, timeout=60) + except subprocess.SubprocessError: return False # If we get here, then the above command proved we are booted return True @@ -317,16 +331,21 @@ def node_status(self): """ cmd = ["maas", self.maas_user, "machine", "read", self.node_id] # Do not use runcmd for this - we need the output, not the end user - output = subprocess.check_output(cmd) - data = json.loads(output.decode()) + proc = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False + ) + if proc.returncode: + self._logger_error(f"maas error running: {' '.join(cmd)}") + raise ProvisioningError(proc.stdout.decode()) + data = json.loads(proc.stdout.decode()) return data.get("status_name") def node_release(self): """Release the node to make it available again""" cmd = ["maas", self.maas_user, "machine", "release", self.node_id] - subprocess.run(cmd) + subprocess.run(cmd, check=False) # Make sure the device is available before returning - for timeout in range(0, 10): + for _ in range(0, 10): time.sleep(5) status = self.node_status() if status == "Ready": From 38217634a4d968f80d11e85196e81e25ad61f169 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 24 Jan 2023 19:26:32 -0600 Subject: [PATCH 401/569] minor cleanups --- devices/maas2/maas2.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 295dcea8..6acaa0fe 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -256,7 +256,7 @@ def deploy_node(self, distro="bionic", kernel=None, user_data=None): data = base64.b64encode(user_data.encode()).decode() cmd.append("user_data={}".format(data)) proc = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False ) try: proc.check_returncode() @@ -315,7 +315,9 @@ def check_test_image_booted(self): "/bin/true", ] try: - subprocess.check_call(cmd, stderr=subprocess.STDOUT, timeout=60) + subprocess.run( + cmd, stderr=subprocess.STDOUT, timeout=60, check=True + ) except subprocess.SubprocessError: return False # If we get here, then the above command proved we are booted From 29c53f95602fc1cf9af27b829d006a856245568a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 3 Feb 2023 09:58:31 -0600 Subject: [PATCH 402/569] Improve error handling on failed commands in maas:deploy_node() --- devices/maas2/maas2.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/devices/maas2/maas2.py b/devices/maas2/maas2.py index 6acaa0fe..a13c6c27 100644 --- a/devices/maas2/maas2.py +++ b/devices/maas2/maas2.py @@ -258,10 +258,8 @@ def deploy_node(self, distro="bionic", kernel=None, user_data=None): proc = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False ) - try: - proc.check_returncode() - except subprocess.CalledProcessError: - self._logger_error("maas-cli call failure happens.") + if proc.returncode: + self._logger_error(f"maas-cli error running: {' '.join(cmd)}") raise ProvisioningError(proc.stdout.decode()) # Make sure the device is available before returning From 3f8fcfd0e5f7398d36c1a6ededd581839d0814a9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Feb 2023 15:35:20 -0600 Subject: [PATCH 403/569] Different way of getting IP address --- setup.py | 2 +- snappy_device_agents/__init__.py | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/setup.py b/setup.py index 1472ac9e..d7acc0cb 100755 --- a/setup.py +++ b/setup.py @@ -45,6 +45,6 @@ packages=find_packages(), data_files=datafiles, setup_requires=["pytest-runner"], - install_requires=["PyYAML>=3.11", "netifaces>=0.10.4"], + install_requires=["PyYAML>=3.11"], scripts=["snappy-device-agent"], ) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 708adda9..ba87088b 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -27,8 +27,6 @@ import time import urllib.request -import netifaces - IMAGEFILE = "snappy.img" logger = logging.getLogger() @@ -227,11 +225,10 @@ def get_local_ip_addr(): :return ipaddr: Returns the ip address of this system """ - gateways = netifaces.gateways() - default_interface = gateways["default"][netifaces.AF_INET][1] - ipaddr = netifaces.ifaddresses(default_interface)[netifaces.AF_INET][0][ - "addr" - ] + # Use SOCK_DGRAM since we don't need to send any data and to avoid timeout + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("10.0.0.0", 0)) + ipaddr = sock.getsockname()[0] return ipaddr From 2db9f85ea1c64f955d3105f1f7db2e709a19988e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 6 Feb 2023 15:22:59 -0600 Subject: [PATCH 404/569] Pylint cleanups on __init__.py --- snappy_device_agents/__init__.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index ba87088b..74d80379 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -11,6 +11,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +"""General functions used by snappy device agents""" import bz2 import gzip @@ -316,9 +317,13 @@ def compress_file(filename): def configure_logging(config): """Setup logging""" - class AgentFilter(logging.Filter): + class AgentFilter( + logging.Filter + ): # pylint: disable=too-few-public-methods + """Add agent_name to log records""" + def __init__(self, agent_name): - super(AgentFilter, self).__init__() + super().__init__() self.agent_name = agent_name def filter(self, record): @@ -376,23 +381,23 @@ def runcmd(cmd, env=None, timeout=None): deadline = time.time() + timeout else: deadline = None - process = subprocess.Popen( + with subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, env=env, - ) - while process.poll() is None: - if deadline and time.time() > deadline: - process.terminate() - raise CmdTimeoutError - line = process.stdout.readline() + ) as process: + while process.poll() is None: + if deadline and time.time() > deadline: + process.terminate() + raise CmdTimeoutError + line = process.stdout.readline() + if line: + sys.stdout.write(line.decode(errors="replace")) + line = process.stdout.read() if line: sys.stdout.write(line.decode(errors="replace")) - line = process.stdout.read() - if line: - sys.stdout.write(line.decode(errors="replace")) return process.returncode From 7b9d7c5c770f310c8dea99b99d9e38938ff92a0b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 7 Feb 2023 13:11:52 -0600 Subject: [PATCH 405/569] Remove ubuntu-device-format support --- snappy_device_agents/__init__.py | 72 ++++---------------------------- 1 file changed, 9 insertions(+), 63 deletions(-) diff --git a/snappy_device_agents/__init__.py b/snappy_device_agents/__init__.py index 74d80379..1528d7c3 100644 --- a/snappy_device_agents/__init__.py +++ b/snappy_device_agents/__init__.py @@ -24,7 +24,6 @@ import string import subprocess import sys -import tempfile import time import urllib.request @@ -111,45 +110,6 @@ def delayretry(func, args, max_retries=3, delay=0): return ret -def udf_create_image(params): - """ - Create a new snappy core image with ubuntu-device-flash - - :param params: - Command-line parameters to pass after 'sudo ubuntu-device-flash' - :return filename: - Returns the filename of the image - """ - imagepath = os.path.join(os.getcwd(), IMAGEFILE) - cmd = params.split() - cmd.insert(0, "ubuntu-device-flash") - cmd.insert(0, "sudo") - - # A shorter tempdir path is needed than the one provided by SPI - # because of a bug in kpartx that makes it have trouble deleting - # mappings with long paths - with tempfile.TemporaryDirectory() as tmpdir: - tmp_imagepath = os.path.join(tmpdir, IMAGEFILE) - try: - output_opt = cmd.index("-o") - cmd[output_opt + 1] = imagepath - except ValueError: - # if we get here, -o was already not in the image - cmd.append("-o") - cmd.append(tmp_imagepath) - - logger.info("Creating snappy image with: %s", cmd) - try: - output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError as exc: - logger.error("Image Creation Output:\n %s", exc.output) - raise - logger.info("Image Creation Output:\n %s", output) - shutil.move(tmp_imagepath, imagepath) - - return imagepath - - def get_test_username(job_data="testflinger.json", default="ubuntu"): """ If the test_data specifies a default username, use it. Otherwise @@ -192,29 +152,15 @@ def get_image(job_data="testflinger.json"): there was an error """ testflinger_data = get_test_opportunity(job_data) - image_keys = testflinger_data.get("provision_data").keys() - if "download_files" in image_keys: - for url in testflinger_data.get("provision_data").get( - "download_files" - ): - download(url) - if "url" in image_keys: - try: - url = testflinger_data["provision_data"]["url"] - image = download(url, IMAGEFILE) - except KeyError as exc: - logger.error('Error getting "%s": %s', url, exc) - return "" - elif "udf-params" in image_keys: - udf_params = testflinger_data.get("provision_data").get("udf-params") - image = delayretry( - udf_create_image, [udf_params], max_retries=3, delay=60 - ) - else: - logger.error( - 'provision_data needs to contain "url" for the image ' - 'or "udf-params"' - ) + provision_data = testflinger_data.get("provision_data") + if "url" not in provision_data: + logger.error('provision_data needs to contain "url" for the image') + return "" + url = testflinger_data["provision_data"]["url"] + try: + image = download(url, IMAGEFILE) + except OSError: + logger.exception('Error getting "%s":', url) return "" return compress_file(image) From e260d8d7eefcb24750e08eaac946dca4e29f2b37 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Feb 2023 09:26:00 -0600 Subject: [PATCH 406/569] Fix a few use-dict-literal warnings from pylint --- testflinger_cli/client.py | 2 +- testflinger_cli/history.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index f11f8583..def3d52a 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -109,7 +109,7 @@ def post_job_state(self, job_id, state): Job state to set for the specified job """ endpoint = "/v1/result/{}".format(job_id) - data = dict(job_state=state) + data = {"job_state": state} self.put(endpoint, data) def submit_job(self, job_data): diff --git a/testflinger_cli/history.py b/testflinger_cli/history.py index c957a9e6..27c2d117 100644 --- a/testflinger_cli/history.py +++ b/testflinger_cli/history.py @@ -42,9 +42,12 @@ def __init__(self): def new(self, job_id, queue): """Add a new job to the history""" submission_time = datetime.now().timestamp() - self.history[job_id] = dict( - queue=queue, submission_time=submission_time, job_state="unknown" - ) + self.history[job_id] = { + "queue": queue, + "submission_time": submission_time, + "job_state": "unknown", + } + # limit job history to last 10 jobs if len(self.history) > 10: self.history.popitem(last=False) From f2da85c65ff6ba5cfae698db7d8174f402789986 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Feb 2023 10:16:07 -0600 Subject: [PATCH 407/569] Convert from setup.py to pyproject --- README.rst | 2 +- pyproject.toml | 24 +++++++++++++++++++++++- setup.py | 21 +++------------------ tox.ini | 2 +- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/README.rst b/README.rst index 58ad977d..b207f451 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,7 @@ To install it in a virtual environment: $ virtualenv -p python3 env $ . env/bin/activate - $ ./setup install + $ pip install . Usage diff --git a/pyproject.toml b/pyproject.toml index a8f43fef..1b2ba197 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,24 @@ +[build-system] +requires = [ + "setuptools", + "setuptools-scm", +] +build-backend = "setuptools.build_meta" + +[project] +name = "testflinger-cli" +description = "Testflinger CLI" +readme = "README.rst" +dependencies = [ + "PyYAML", + "requests", + "xdg<5.2", +] +dynamic = ["version"] + +[project.scripts] +testflinger-cli = "testflinger_cli:cli" +testflinger = "testflinger_cli:cli" + [tool.black] -line-length = 79 +line-length = 79 \ No newline at end of file diff --git a/setup.py b/setup.py index 02351a03..77b2fc18 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ -#!/usr/bin/env python -# Copyright (C) 2017-2020 Canonical +#!/usr/bin/env python3 +# Copyright (C) 2017-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,19 +16,4 @@ # from setuptools import setup -INSTALL_REQUIRES = ["pyyaml", "requests", "xdg<5.2"] - -setup( - name="testflinger-cli", - version="0.1", - description="CLI tool for working with testflinger", - packages=["testflinger_cli"], - zip_safe=False, - install_requires=INSTALL_REQUIRES, - test_suite="testflinger_cli.tests", - entry_points=""" - [console_scripts] - testflinger-cli=testflinger_cli:cli - testflinger=testflinger_cli:cli - """, -) +setup() diff --git a/tox.ini b/tox.ini index 87ba5bee..2ba286ad 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ deps = pytest-cov requests-mock commands = - {envbindir}/python setup.py develop + {envbindir}/pip install . {envbindir}/python -m black --check setup.py testflinger-cli testflinger_cli {envbindir}/python -m flake8 setup.py testflinger_cli {envbindir}/python -m pylint testflinger_cli From 346c36354c4a0a40123dd0e9294042ad16112f8b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 13 Feb 2023 13:48:44 -0600 Subject: [PATCH 408/569] Remove rpi3 device agent since we use muxpi instead now --- devices/rpi3/__init__.py | 53 ----- devices/rpi3/rpi3.py | 478 --------------------------------------- 2 files changed, 531 deletions(-) delete mode 100644 devices/rpi3/__init__.py delete mode 100644 devices/rpi3/rpi3.py diff --git a/devices/rpi3/__init__.py b/devices/rpi3/__init__.py deleted file mode 100644 index 361d8b6a..00000000 --- a/devices/rpi3/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (C) 2016-2019 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Rpi3 support code.""" - -import logging -import yaml - -import snappy_device_agents -from devices.rpi3.rpi3 import Rpi3 -from snappy_device_agents import logmsg -from devices import catch, DefaultDevice, RecoveryError, SerialLogger - -device_name = "rpi3" - - -class DeviceAgent(DefaultDevice): - - """Tool for provisioning baremetal with a given image.""" - - @catch(RecoveryError, 46) - def provision(self, args): - """Method called when the command is invoked.""" - with open(args.config) as configfile: - config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) - device = Rpi3(args.config, args.job_data) - logmsg(logging.INFO, "BEGIN provision") - logmsg(logging.INFO, "Booting Master Image") - serial_host = config.get("serial_host") - serial_port = config.get("serial_port") - serial_proc = SerialLogger( - serial_host, serial_port, "provision-serial.log" - ) - serial_proc.start() - try: - device.ensure_master_image() - device.provision() - except Exception as e: - raise e - finally: - serial_proc.stop() diff --git a/devices/rpi3/rpi3.py b/devices/rpi3/rpi3.py deleted file mode 100644 index 11a27192..00000000 --- a/devices/rpi3/rpi3.py +++ /dev/null @@ -1,478 +0,0 @@ -# Copyright (C) 2016-2020 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Rpi3 support code.""" - -import json -import logging -import multiprocessing -import os -import subprocess -import time -import yaml - -from contextlib import contextmanager - -import snappy_device_agents -from devices import ProvisioningError, RecoveryError - -logger = logging.getLogger() - - -class Rpi3: - - """Snappy Device Agent for Rpi3.""" - - IMAGE_PATH_IDS = { - "etc": "ubuntu", - "system-data": "core", - "snaps": "core20", - } - - def __init__(self, config, job_data): - with open(config) as configfile: - self.config = yaml.safe_load(configfile) - with open(job_data) as j: - self.job_data = json.load(j) - - def _run_control(self, cmd, timeout=60): - """ - Run a command on the control host over ssh - - :param cmd: - Command to run - :param timeout: - Timeout (default 60) - :returns: - Return output from the command, if any - """ - cmd = [ - "ssh", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "pi@{}".format(self.config["device_ip"]), - cmd, - ] - try: - output = subprocess.check_output( - cmd, stderr=subprocess.STDOUT, timeout=timeout - ) - except subprocess.CalledProcessError as e: - raise ProvisioningError(e.output) - return output - - @contextmanager - def remote_mount(self, remote_device, mount_point="/mnt"): - self._run_control( - "sudo mount /dev/{} {}".format(remote_device, mount_point) - ) - try: - yield mount_point - finally: - self._run_control("sudo umount {}".format(mount_point)) - - def get_image_type(self): - """ - Figure out which kind of image is on the configured block device - - :returns: - tuple of image type and device as strings - """ - dev = self.config["test_device"] - lsblk_data = self._run_control("lsblk -J {}".format(dev)) - lsblk_json = json.loads(lsblk_data.decode()) - dev_list = [ - x.get("name") - for x in lsblk_json["blockdevices"][0]["children"] - if x.get("name") - ] - for dev in dev_list: - try: - with self.remote_mount(dev): - dirs = self._run_control("ls /mnt") - for path, img_type in self.IMAGE_PATH_IDS.items(): - if path in dirs.decode().split(): - return img_type, dev - except Exception: - # If unmountable or any other error, go on to the next one - continue - # We have no idea what kind of image this is - return "unknown", dev - - def setboot(self, mode): - """ - Set the boot mode of the device. - - :param mode: - One of 'master' or 'test' - :raises ProvisioningError: - If the command times out or anything else fails. - - This method sets the snappy boot method to the specified value. - """ - if mode == "master": - setboot_script = self.config["select_master_script"] - elif mode == "test": - setboot_script = self.config["select_test_script"] - else: - raise KeyError - for cmd in setboot_script: - logger.info("Running %s", cmd) - try: - subprocess.check_call(cmd.split(), timeout=60) - except Exception: - raise ProvisioningError("timeout reaching control host!") - - def hardreset(self): - """ - Reboot the device. - - :raises RecoveryError: - If the command times out or anything else fails. - - .. note:: - This function runs the commands specified in 'reboot_script' - in the config yaml. - """ - for cmd in self.config["reboot_script"]: - logger.info("Running %s", cmd) - try: - subprocess.check_call(cmd.split(), timeout=120) - except Exception: - raise RecoveryError("timeout reaching control host!") - - def ensure_test_image(self, test_username, test_password): - """ - Actively switch the device to boot the test image. - - :param test_username: - Username of the default user in the test image - :param test_password: - Password of the default user in the test image - :raises ProvisioningError: - If the command times out or anything else fails. - """ - logger.info("Booting the test image") - self.setboot("test") - try: - self._run_control("sudo /sbin/reboot") - except Exception: - pass - time.sleep(60) - - started = time.time() - # Retry for a while since we might still be rebooting - test_image_booted = False - while time.time() - started < 600: - try: - time.sleep(10) - cmd = [ - "sshpass", - "-p", - test_password, - "ssh-copy-id", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "{}@{}".format(test_username, self.config["device_ip"]), - ] - subprocess.check_call(cmd) - test_image_booted = self.is_test_image_booted() - except Exception: - pass - if test_image_booted: - break - # Check again if we are in the master image - if not test_image_booted: - raise ProvisioningError("Failed to boot test image!") - - def is_test_image_booted(self): - """ - Check if the master image is booted. - - :returns: - True if the test image is currently booted, False otherwise. - :raises TimeoutError: - If the command times out - :raises CalledProcessError: - If the command fails - """ - logger.info("Checking if test image booted.") - cmd = [ - "ssh", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "ubuntu@{}".format(self.config["device_ip"]), - "snap -h", - ] - try: - subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) - except Exception: - return False - # If we get here, then the above command proved we are in snappy - return True - - def is_master_image_booted(self): - """ - Check if the master image is booted. - - :returns: - True if the master image is currently booted, False otherwise. - - .. note:: - The master image is used for writing a new image to local media - """ - # FIXME: come up with a better way of checking this - logger.info("Checking if master image booted.") - try: - output = self._run_control("cat /etc/issue") - except Exception: - logger.info("Error checking device state. Forcing reboot...") - return False - if "GNU" in str(output): - return True - return False - - def ensure_master_image(self): - """ - Actively switch the device to boot the test image. - - :raises RecoveryError: - If the command times out or anything else fails. - """ - logger.info("Making sure the master image is booted") - - # most likely, we are still in a test image, check that first - test_booted = self.is_test_image_booted() - - if test_booted: - # We are not in the master image, so just hard reset - self.setboot("master") - self.hardreset() - - started = time.time() - while time.time() - started < 300: - time.sleep(10) - master_booted = self.is_master_image_booted() - if master_booted: - return - # Check again if we are in the master image - if not master_booted: - raise RecoveryError("Could not reboot to master!") - - master_booted = self.is_master_image_booted() - if not master_booted: - logging.warn( - "Device is in an unknown state, attempting to recover" - ) - self.hardreset() - started = time.time() - while time.time() - started < 300: - time.sleep(10) - if self.is_master_image_booted(): - return - elif self.is_test_image_booted(): - # device was stuck, but booted to the test image - # So rerun ourselves to get to the master image - return self.ensure_master_image() - # timeout reached, this could be a dead device - raise RecoveryError( - "Device is in an unknown state, may require manual recovery!" - ) - # If we get here, the master image was already booted, so just return - - def flash_test_image(self, server_ip, server_port): - """ - Flash the image at :image_url to the sd card. - - :param server_ip: - IP address of the image server. The image will be downloaded and - uncompressed over the SD card. - :param server_port: - TCP port to connect to on server_ip for downloading the image - :raises ProvisioningError: - If the command times out or anything else fails. - """ - # First unmount, just in case - try: - self._run_control( - "sudo umount {}*".format(self.config["test_device"]), - timeout=30, - ) - except KeyError: - raise RecoveryError("Device config missing test_device") - except Exception: - # We might not be mounted, so expect this to fail sometimes - pass - cmd = "nc.traditional {} {}| xzcat| sudo dd of={} bs=16M".format( - server_ip, server_port, self.config["test_device"] - ) - logger.info("Running: %s", cmd) - try: - # XXX: I hope 30 min is enough? but maybe not! - self._run_control(cmd, timeout=1800) - except Exception: - raise ProvisioningError("timeout reached while flashing image!") - try: - self._run_control("sync") - except Exception: - # Nothing should go wrong here, but let's sleep if it does - logger.warn("Something went wrong with the sync, sleeping...") - time.sleep(30) - try: - self._run_control( - "sudo hdparm -z {}".format(self.config["test_device"]), - timeout=30, - ) - except Exception: - raise ProvisioningError( - "Unable to run hdparm to rescan " "partitions" - ) - - def create_user(self, image_type): - """Create user account for default ubuntu user""" - metadata = "instance_id: cloud-image" - userdata = ( - "#cloud-config\n" - "password: ubuntu\n" - "chpasswd:\n" - " list:\n" - " - ubuntu:ubuntu\n" - " expire: False\n" - "ssh_pwauth: True" - ) - # For core20: - uc20_ci_data = ( - "#cloud-config\n" - "datasource_list: [ NoCloud, None ]\n" - "datasource:\n" - " NoCloud:\n" - " user-data: |\n" - " #cloud-config\n" - " password: ubuntu\n" - " chpasswd:\n" - " list:\n" - " - ubuntu:ubuntu\n" - " expire: False\n" - " ssh_pwauth: True\n" - " meta-data: |\n" - " instance_id: cloud-image" - ) - base = "/mnt" - if image_type == "core": - base = "/mnt/system-data" - try: - if image_type == "core20": - ci_path = os.path.join(base, "data/etc/cloud/cloud.cfg.d") - self._run_control("sudo mkdir -p {}".format(ci_path)) - write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" - self._run_control( - write_cmd.format(uc20_ci_data, ci_path, "99_nocloud.cfg") - ) - else: - # For core or ubuntu classic images - ci_path = os.path.join(base, "var/lib/cloud/seed/nocloud-net") - self._run_control("sudo mkdir -p {}".format(ci_path)) - write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" - self._run_control( - write_cmd.format(metadata, ci_path, "meta-data") - ) - self._run_control( - write_cmd.format(userdata, ci_path, "user-data") - ) - if image_type == "ubuntu": - # This needs to be removed on classic for rpi, else - # cloud-init won't find the user-data we give it - rm_cmd = "sudo rm -f {}".format( - os.path.join( - base, "etc/cloud/cloud.cfg.d/99-fake?cloud.cfg" - ) - ) - self._run_control(rm_cmd) - except Exception: - raise ProvisioningError("Error creating user files") - - def wipe_test_device(self): - """Safety check - wipe the test drive if things go wrong - - This way if we reboot the sytem after a failed provision, it goes - back to the control boot image which we could use to provision - something else. - """ - try: - test_device = self.config["test_device"] - logger.error("Failed to write image, cleaning up...") - self._run_control("sudo wipefs -af {}".format(test_device)) - except Exception: - # This is an attempt to salvage a bad run, further tracebacks - # would just add to the noise - pass - - def run_post_provision_script(self): - # Run post provision commands on control host if there are any, but - # don't fail the provisioning step if any of them don't work - for cmd in self.config.get("post_provision_script", []): - logger.info("Running %s", cmd) - try: - self._run_control(cmd) - except Exception: - logger.warn("Error running %s", cmd) - - def provision(self): - """Provision the device""" - url = self.job_data["provision_data"].get("url") - if url: - snappy_device_agents.download(url, "snappy.img") - else: - logger.error("Bad data passed for provisioning") - raise ProvisioningError("Error provisioning system") - image_file = snappy_device_agents.compress_file("snappy.img") - test_username = self.job_data.get("test_data", {}).get( - "test_username", "ubuntu" - ) - test_password = self.job_data.get("test_data", {}).get( - "test_password", "ubuntu" - ) - server_ip = snappy_device_agents.get_local_ip_addr() - serve_q = multiprocessing.Queue() - file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, - args=( - serve_q, - image_file, - ), - ) - file_server.start() - server_port = serve_q.get() - logger.info("Flashing Test Image") - try: - self.flash_test_image(server_ip, server_port) - file_server.terminate() - image_type, image_dev = self.get_image_type() - with self.remote_mount(image_dev): - logger.info("Creating Test User") - self.create_user(image_type) - self.run_post_provision_script() - logger.info("Booting Test Image") - self.ensure_test_image(test_username, test_password) - except Exception: - # wipe out whatever we installed if things go badly - self.wipe_test_device() - raise - logger.info("END provision") From 03ef5b1df5d97efa9e3dcba95f3b51f9b02f7d8d Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 15 Feb 2023 22:55:46 -0800 Subject: [PATCH 409/569] enable agent data (state, job, etc) to post to api server --- testflinger_agent/agent.py | 7 +++++++ testflinger_agent/client.py | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index dfb259e9..aac19645 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -32,6 +32,11 @@ def __init__(self, client): self.set_state("waiting") self.advertised_queues = self.client.config.get("advertised_queues") self.advertised_images = self.client.config.get("advertised_images") + location = self.client.config.get("location") + if location: + self.client.post_agent_data({"location": location}) + if self.advertised_queues: + self.client.post_agent_data({"queues": self.advertised_queues}) if self.advertised_queues or self.advertised_images: self.status_proc = multiprocessing.Process( target=self._status_worker @@ -51,6 +56,7 @@ def _status_worker(self): time.sleep(120) def set_state(self, state): + self.client.post_agent_data({"state": state}) self._state.value = state.encode("utf-8") def get_offline_files(self): @@ -128,6 +134,7 @@ def process_jobs(self): try: job = TestflingerJob(job_data, self.client) logger.info("Starting job %s", job.job_id) + self.client.post_agent_data({"job_id": job.job_id}) rundir = os.path.join( self.client.config.get("execution_basedir"), job.job_id ) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index e1c1292e..b26d739b 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -37,6 +37,7 @@ def __init__(self, config): ) if not self.server.lower().startswith("http"): self.server = "http://" + self.server + self.session = self._requests_retry(retries=5) def _requests_retry(self, retries=3): session = requests.Session() @@ -95,8 +96,7 @@ def repost_job(self, job_data): ) self.post_live_output(job_id, job_output) try: - session = self._requests_retry(retries=5) - job_request = session.post(job_uri, json=job_data) + job_request = self.session.post(job_uri, json=job_data) except Exception as e: logger.exception(e) raise TFServerError("other exception") @@ -251,3 +251,17 @@ def post_images(self, data): requests.post(images_uri, json=data, timeout=30) except Exception as e: logger.exception(e) + + def post_agent_data(self, data): + """Post the relevant data points to testflinger server + + :param data: + dict of various agent data points to send to the api server + """ + agent = self.config.get("agent_id") + agent_data_uri = urljoin(self.server, "/v1/agents/data") + agent_data_url = urljoin(agent_data_uri, agent) + try: + self.session.post(agent_data_url, json=data, timeout=30) + except Exception as e: + logger.exception(e) From 7a1093b8989a3953b6fb5cff2df6505078160d3f Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 15 Feb 2023 23:11:18 -0800 Subject: [PATCH 410/569] update test_agent unit test to account for state --- testflinger_agent/tests/test_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 4362be63..beefb3a1 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -180,6 +180,7 @@ def test_recovery_failed(self, agent, requests_mock): "job_queue": "test", "provision_data": {"url": "foo"}, "test_data": {"test_cmds": "foo"}, + "state": "offline", } # In this case we are making sure that the repost job request # gets good status From 05e007fbf10afb797adb61962488bfb3de63f422 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 15 Feb 2023 23:16:38 -0800 Subject: [PATCH 411/569] update test_agent unit test to account for state --- testflinger_agent/tests/test_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index beefb3a1..2199d95f 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -180,7 +180,6 @@ def test_recovery_failed(self, agent, requests_mock): "job_queue": "test", "provision_data": {"url": "foo"}, "test_data": {"test_cmds": "foo"}, - "state": "offline", } # In this case we are making sure that the repost job request # gets good status @@ -195,6 +194,7 @@ def test_recovery_failed(self, agent, requests_mock): text="{}", ) m.post("http://127.0.0.1:8000/v1/job", json={"job_id": job_id}) + m.post("http://127.0.0.1:8000/v1/job", json={"state": "offline"}) agent.process_jobs() assert agent.check_offline() # These are the args we would expect when it reposts the job From 639835ae2e806faa3b62e7d54ff30f2e0544f750 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 15 Feb 2023 23:45:59 -0800 Subject: [PATCH 412/569] remove agent unit test changes --- testflinger_agent/tests/test_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 2199d95f..4362be63 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -194,7 +194,6 @@ def test_recovery_failed(self, agent, requests_mock): text="{}", ) m.post("http://127.0.0.1:8000/v1/job", json={"job_id": job_id}) - m.post("http://127.0.0.1:8000/v1/job", json={"state": "offline"}) agent.process_jobs() assert agent.check_offline() # These are the args we would expect when it reposts the job From de3b5299a16862a259995f4feb9bb4debe2c5dee Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 14 Feb 2023 16:39:25 -0600 Subject: [PATCH 413/569] Break out code to update results from the phase from run_test_phase --- testflinger_agent/job.py | 47 +++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 11a97175..d1feae8d 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -63,6 +63,7 @@ def run_test_phase(self, phase, rundir): return 0 if phase == "reserve" and not self.job_data.get("reserve_data"): return 0 + results_file = os.path.join(rundir, "testflinger-outcome.json") output_log = os.path.join(rundir, phase + ".log") serial_log = os.path.join(rundir, phase + "-serial.log") logger.info("Running %s_command: %s", phase, cmd) @@ -78,24 +79,40 @@ def run_test_phase(self, phase, rundir): except Exception as e: logger.exception(e) finally: - with open(os.path.join(rundir, "testflinger-outcome.json")) as f: - outcome_data = json.load(f) + self._update_phase_results( + results_file, phase, exitcode, output_log, serial_log + ) + sys.exit(exitcode) + + def _update_phase_results( + self, results_file, phase, exitcode, output_log, serial_log + ): + """Update the results file with the results of the specified phase + + :param results_file: + Path to the results file + :param phase: + Name of the phase + :param exitcode: + Exitcode from the device agent + :param output_log: + Path to the output log file + :param serial_log: + Path to the serial log file + """ + with open(results_file, "r+") as results: + outcome_data = json.load(results) if os.path.exists(output_log): - with open(output_log, "r+", encoding="utf-8") as f: - self._set_truncate(f) - outcome_data[phase + "_output"] = f.read() + with open(output_log, "r+", encoding="utf-8") as logfile: + self._set_truncate(logfile) + outcome_data[phase + "_output"] = logfile.read() if os.path.exists(serial_log): - with open(serial_log, "r+", encoding="utf-8") as f: - self._set_truncate(f) - outcome_data[phase + "_serial"] = f.read() + with open(serial_log, "r+", encoding="utf-8") as logfile: + self._set_truncate(logfile) + outcome_data[phase + "_serial"] = logfile.read() outcome_data[phase + "_status"] = exitcode - with open( - os.path.join(rundir, "testflinger-outcome.json"), - "w", - encoding="utf-8", - ) as f: - json.dump(outcome_data, f) - sys.exit(exitcode) + results.seek(0) + json.dump(outcome_data, results) def _set_truncate(self, f, size=1024 * 1024): """Set up an open file so that we don't read more than a specified From 828641a531aa367aebd5ff12f79bb339cf9bcd12 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 16 Feb 2023 15:18:25 -0800 Subject: [PATCH 414/569] assign rmock post to var to scope failing job_data assertion --- testflinger_agent/tests/test_agent.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 4362be63..e0f97ee7 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -193,10 +193,11 @@ def test_recovery_failed(self, agent, requests_mock): "http://127.0.0.1:8000/v1/result/" + job_id + "/output", text="{}", ) - m.post("http://127.0.0.1:8000/v1/job", json={"job_id": job_id}) + mpost_job_json = m.post( + "http://127.0.0.1:8000/v1/job", json={"job_id": job_id}) agent.process_jobs() assert agent.check_offline() # These are the args we would expect when it reposts the job - assert m.last_request.json() == fake_job_data + assert mpost_job_json.last_request.json() == fake_job_data if os.path.exists(OFFLINE_FILE): os.unlink(OFFLINE_FILE) From b8659fac33c1b6c1a2e2e950b03c49ec6d53c261 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 16 Feb 2023 15:20:31 -0800 Subject: [PATCH 415/569] assign rmock post to var to scope failing job_data assertion --- testflinger_agent/tests/test_agent.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index e0f97ee7..550f8514 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -194,7 +194,8 @@ def test_recovery_failed(self, agent, requests_mock): text="{}", ) mpost_job_json = m.post( - "http://127.0.0.1:8000/v1/job", json={"job_id": job_id}) + "http://127.0.0.1:8000/v1/job", json={"job_id": job_id} + ) agent.process_jobs() assert agent.check_offline() # These are the args we would expect when it reposts the job From 7e7988fbcce1ae7ad83c66dd483b828003bd8fa8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 17 Feb 2023 16:44:42 -0600 Subject: [PATCH 416/569] pylint fixes for schema.py --- testflinger_agent/schema.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index 5edcccbc..36d7720e 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016-2020 Canonical +# Copyright (C) 2016-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -11,6 +11,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +"""Schema validation for testflinger-agent config files""" import voluptuous @@ -49,5 +50,5 @@ def validate(data): :param data: Data to validate """ - v1 = voluptuous.Schema(SCHEMA_V1) - return v1(data) + schema_v1 = voluptuous.Schema(SCHEMA_V1) + return schema_v1(data) From 5a94e19b41ce0449f927d6c8729cd395311a7927 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 17 Feb 2023 09:42:16 -0600 Subject: [PATCH 417/569] Convert to using pyproject.toml for setup --- pyproject.toml | 24 ++++++++++++++++++++++++ setup.py | 38 +++----------------------------------- snappy-device-agent | 25 ------------------------- tox.ini | 6 +++--- 4 files changed, 30 insertions(+), 63 deletions(-) delete mode 100755 snappy-device-agent diff --git a/pyproject.toml b/pyproject.toml index a8f43fef..fdc46549 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,26 @@ +[build-system] +requires = [ + "setuptools", + "setuptools-scm", +] +build-backend = "setuptools.build_meta" + +[project] +name = "snappy-device-agents" +version = "0.0.2" +description = "Testflinger device agents" +license = {text = "GPLv3"} +readme = "README.rst" +requires-python = ">=3.8" +dependencies = [ + "PyYAML>=3.11", +] + +[project.scripts] +snappy-device-agent = "snappy_device_agents.cmd:main" + +[tool.setuptools.packages.find] +include = ["snappy_device_agents", "devices"] + [tool.black] line-length = 79 diff --git a/setup.py b/setup.py index d7acc0cb..8104ab0f 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (C) 2015 Canonical +# Copyright (C) 2015-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -13,38 +13,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os -import sys +from setuptools import setup -from setuptools import ( - find_packages, - setup, -) - -assert sys.version_info >= (3,), "Python 3 is required" - - -VERSION = "0.0.1" - -datafiles = [ - (d, [os.path.join(d, f) for f in files]) - for d, folders, files in os.walk("data") -] - -setup( - name="snappy-device-agents", - version=VERSION, - description=( - "Device agents scripts for provisioning and running " - "tests on Snappy devices" - ), - author="Snappy Device Agents Developers", - author_email="paul.larson@canonical.com", - url="https://launchpad.net/snappy-device-agents", - license="GPLv3", - packages=find_packages(), - data_files=datafiles, - setup_requires=["pytest-runner"], - install_requires=["PyYAML>=3.11"], - scripts=["snappy-device-agent"], -) +setup() diff --git a/snappy-device-agent b/snappy-device-agent deleted file mode 100755 index e6d2c57a..00000000 --- a/snappy-device-agent +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python -# Copyright (C) 2015 Canonical -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - - -import logging - -from snappy_device_agents.cmd import main - -logger = logging.getLogger() -logger.setLevel(logging.INFO) - -if __name__ == "__main__": - main() diff --git a/tox.ini b/tox.ini index 54d73caa..08a3be87 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = pytest-cov commands = {envbindir}/python setup.py develop - {envbindir}/python -m black --check setup.py snappy-device-agent snappy_device_agents devices tests - {envbindir}/python -m flake8 setup.py snappy-device-agent snappy_device_agents devices - #{envbindir}/python -m pylint snappy-device-agent snappy_device_agents devices + {envbindir}/python -m black --check setup.py snappy_device_agents devices tests + {envbindir}/python -m flake8 setup.py snappy_device_agents devices + #{envbindir}/python -m pylint snappy_device_agents devices {envbindir}/python -m pytest --doctest-modules --cov=snappy_device_agents --cov=devices From bb876cd79e3d6522792cb673faa4d6f248f7fffa Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 21 Feb 2023 20:36:07 -0800 Subject: [PATCH 418/569] pack location and queue data in same request --- testflinger_agent/agent.py | 8 ++++---- testflinger_agent/client.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index aac19645..0078f011 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -33,10 +33,10 @@ def __init__(self, client): self.advertised_queues = self.client.config.get("advertised_queues") self.advertised_images = self.client.config.get("advertised_images") location = self.client.config.get("location") - if location: - self.client.post_agent_data({"location": location}) - if self.advertised_queues: - self.client.post_agent_data({"queues": self.advertised_queues}) + if self.advertised_queues or location: + self.client.post_agent_data( + {"queues": self.advertised_queues, "location": location} + ) if self.advertised_queues or self.advertised_images: self.status_proc = multiprocessing.Process( target=self._status_worker diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index b26d739b..138d33fd 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -259,7 +259,7 @@ def post_agent_data(self, data): dict of various agent data points to send to the api server """ agent = self.config.get("agent_id") - agent_data_uri = urljoin(self.server, "/v1/agents/data") + agent_data_uri = urljoin(self.server, "/v1/agents/data/") agent_data_url = urljoin(agent_data_uri, agent) try: self.session.post(agent_data_url, json=data, timeout=30) From ffd18167512300c047e2c70c5070f7c32794c445 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 21 Feb 2023 22:58:37 -0800 Subject: [PATCH 419/569] clear job id data when on job exit --- testflinger_agent/agent.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 0078f011..5c912288 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -203,6 +203,8 @@ def process_jobs(self): rundir, ), ) + # clear job id + self.client.post_agent_data({"job_id": ""}) proc.start() proc.join() From bbaafa72561c1a810dab0a72c192343ff42b5bb4 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 21 Feb 2023 23:13:35 -0800 Subject: [PATCH 420/569] add agent data logging (api) --- testflinger_agent/__init__.py | 102 ++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index e0b92c30..71eb6383 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -17,6 +17,11 @@ import os import time import yaml +import requests +from requests.adapters import HTTPAdapter, Retry +from urllib.parse import urljoin +from collections import deque +from threading import Timer from testflinger_agent import schema from testflinger_agent.agent import TestflingerAgent @@ -26,6 +31,93 @@ logger = logging.getLogger(__name__) +class ReqBufferTimer(Timer): + """Requests buffer flush""" + + def run(self): + """Loop timer""" + while not self.finished.wait(self.interval): + self.function(*self.args, **self.kwargs) + + +class ReqBufferHandler(logging.Handler): + """Requests logging handler""" + + def __init__(self, agent, server): + super().__init__() + self.server = server + uri = urljoin(self.server, "/v1/agents/data/") + self.url = urljoin(uri, agent) + self.qdepth = 100 # messages + self.buffer = deque([], maxlen=self.qdepth) + self.reqbuf_timer = None + self.reqbuf_interval = 10.0 # seconds + self._start_rb_timer() + # reuse socket + self.session = self._requests_retry() + + def _requests_retry(self, retries=3): + """Retry api server""" + session = requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=0.3, + status_forcelist=(500, 502, 503, 504), + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount("http://", adapter) + session.mount("https://", adapter) + return session + + def _start_rb_timer(self): + """Periodically check and send buffer""" + self.reqbuf_timer = ReqBufferTimer( + self.reqbuf_interval, + self.flush + ) + # terminate timer on exit + self.reqbuf_timer.daemon = True + self.reqbuf_timer.start() + + def emit(self, record): + """Write logging events to buffer""" + if len(self.buffer) >= self.qdepth: + self.buffer.popleft() + + self.buffer.append(record) + + def flush(self): + """Flush and post buffer""" + try: + for record in self.buffer: + self.session.post( + url=self.url, + json=self.format(record), + timeout=3 + ) + except Exception as e: + logger.exception(e) + + # preserve buffer + if len(self.buffer) <= self.qdepth: + return + + self.buffer = [] + + def close(self): + """Cleanup on handler close""" + self.reqbuf_timer.cancel() + + +class ReqBufferFormatter(logging.Formatter): + """Format logging messages""" + + def format(self, record): + return {"log": [record.getMessage()]} + + def start_agent(): args = parse_args() config = load_config(args.config) @@ -77,6 +169,16 @@ def configure_logging(config): ) file_log.setFormatter(logfmt) logger.addHandler(file_log) + # requests logging + # inherit from logger __name__ + req_logger = logging.getLogger() + request_formatter = ReqBufferFormatter() + request_handler = ReqBufferHandler( + config.get("agent_id"), + config.get("server_address") + ) + request_handler.setFormatter(request_formatter) + req_logger.addHandler(request_handler) if not config.get("logging_quiet"): console_log = logging.StreamHandler() console_log.setFormatter(logfmt) From 81504021d88f105e01309f292ac02fa3b6f6ffb6 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 21 Feb 2023 23:35:08 -0800 Subject: [PATCH 421/569] add agent data logging (api) --- testflinger_agent/__init__.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 71eb6383..6fe4b270 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -73,10 +73,7 @@ def _requests_retry(self, retries=3): def _start_rb_timer(self): """Periodically check and send buffer""" - self.reqbuf_timer = ReqBufferTimer( - self.reqbuf_interval, - self.flush - ) + self.reqbuf_timer = ReqBufferTimer(self.reqbuf_interval, self.flush) # terminate timer on exit self.reqbuf_timer.daemon = True self.reqbuf_timer.start() @@ -93,9 +90,7 @@ def flush(self): try: for record in self.buffer: self.session.post( - url=self.url, - json=self.format(record), - timeout=3 + url=self.url, json=self.format(record), timeout=3 ) except Exception as e: logger.exception(e) @@ -174,8 +169,7 @@ def configure_logging(config): req_logger = logging.getLogger() request_formatter = ReqBufferFormatter() request_handler = ReqBufferHandler( - config.get("agent_id"), - config.get("server_address") + config.get("agent_id"), config.get("server_address") ) request_handler.setFormatter(request_formatter) req_logger.addHandler(request_handler) From a84760f428e94b125a0d1b31713bd996d064ba6b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 17 Feb 2023 14:48:28 -0600 Subject: [PATCH 422/569] Restructure to use src-tree layout --- pyproject.toml | 5 +++-- .../snappy_device_agents}/__init__.py | 0 .../snappy_device_agents}/cmd.py | 4 ++-- .../snappy_device_agents/data}/extrausers/group | 0 .../snappy_device_agents/data}/extrausers/gshadow | 0 .../snappy_device_agents/data}/extrausers/passwd | 0 .../snappy_device_agents/data}/extrausers/shadow | 0 .../snappy_device_agents/data}/extrausers/subgid | 0 .../snappy_device_agents/data}/extrausers/subuid | 0 .../data}/pi-desktop/oem-config.service | 0 .../data}/pi-desktop/preseed.cfg | 0 .../snappy_device_agents/devices}/__init__.py | 5 +++-- .../snappy_device_agents/devices}/cm3/__init__.py | 12 +++++++++--- .../snappy_device_agents/devices}/cm3/cm3.py | 8 ++++---- .../devices}/dragonboard/__init__.py | 12 +++++++++--- .../devices}/dragonboard/dragonboard.py | 5 +++-- .../devices}/maas2/__init__.py | 11 ++++++----- .../snappy_device_agents/devices}/maas2/maas2.py | 5 +++-- .../devices}/muxpi/__init__.py | 12 +++++++++--- .../snappy_device_agents/devices}/muxpi/muxpi.py | 9 ++++----- .../devices}/netboot/__init__.py | 14 +++++++------- .../devices}/netboot/netboot.py | 9 +++++---- .../devices}/noprovision/__init__.py | 9 ++++----- .../devices}/noprovision/noprovision.py | 5 +++-- .../devices}/oemrecovery/__init__.py | 7 ++++--- .../devices}/oemrecovery/oemrecovery.py | 5 +++-- {tests => src/tests}/__init__.py | 0 {tests => src/tests}/test_snappy_device_agents.py | 0 tox.ini | 10 +++++----- 29 files changed, 86 insertions(+), 61 deletions(-) rename {snappy_device_agents => src/snappy_device_agents}/__init__.py (100%) rename {snappy_device_agents => src/snappy_device_agents}/cmd.py (95%) rename {data => src/snappy_device_agents/data}/extrausers/group (100%) rename {data => src/snappy_device_agents/data}/extrausers/gshadow (100%) rename {data => src/snappy_device_agents/data}/extrausers/passwd (100%) rename {data => src/snappy_device_agents/data}/extrausers/shadow (100%) rename {data => src/snappy_device_agents/data}/extrausers/subgid (100%) rename {data => src/snappy_device_agents/data}/extrausers/subuid (100%) rename {data => src/snappy_device_agents/data}/pi-desktop/oem-config.service (100%) rename {data => src/snappy_device_agents/data}/pi-desktop/preseed.cfg (100%) rename {devices => src/snappy_device_agents/devices}/__init__.py (99%) rename {devices => src/snappy_device_agents/devices}/cm3/__init__.py (89%) rename {devices => src/snappy_device_agents/devices}/cm3/cm3.py (98%) rename {devices => src/snappy_device_agents/devices}/dragonboard/__init__.py (87%) rename {devices => src/snappy_device_agents/devices}/dragonboard/dragonboard.py (99%) rename {devices => src/snappy_device_agents/devices}/maas2/__init__.py (93%) rename {devices => src/snappy_device_agents/devices}/maas2/maas2.py (99%) rename {devices => src/snappy_device_agents/devices}/muxpi/__init__.py (89%) rename {devices => src/snappy_device_agents/devices}/muxpi/muxpi.py (99%) rename {devices => src/snappy_device_agents/devices}/netboot/__init__.py (97%) rename {devices => src/snappy_device_agents/devices}/netboot/netboot.py (98%) rename {devices => src/snappy_device_agents/devices}/noprovision/__init__.py (86%) rename {devices => src/snappy_device_agents/devices}/noprovision/noprovision.py (96%) rename {devices => src/snappy_device_agents/devices}/oemrecovery/__init__.py (87%) rename {devices => src/snappy_device_agents/devices}/oemrecovery/oemrecovery.py (97%) rename {tests => src/tests}/__init__.py (100%) rename {tests => src/tests}/test_snappy_device_agents.py (100%) diff --git a/pyproject.toml b/pyproject.toml index fdc46549..52df52fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [build-system] requires = [ + "wheel", "setuptools", "setuptools-scm", ] @@ -19,8 +20,8 @@ dependencies = [ [project.scripts] snappy-device-agent = "snappy_device_agents.cmd:main" -[tool.setuptools.packages.find] -include = ["snappy_device_agents", "devices"] +[tool.setuptools.package-data] +snappy_device_agents = ["snappy_device_agents/data/pi-desktop/*"] [tool.black] line-length = 79 diff --git a/snappy_device_agents/__init__.py b/src/snappy_device_agents/__init__.py similarity index 100% rename from snappy_device_agents/__init__.py rename to src/snappy_device_agents/__init__.py diff --git a/snappy_device_agents/cmd.py b/src/snappy_device_agents/cmd.py similarity index 95% rename from snappy_device_agents/cmd.py rename to src/snappy_device_agents/cmd.py index 2539c5c8..a8aaa916 100755 --- a/snappy_device_agents/cmd.py +++ b/src/snappy_device_agents/cmd.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2015 Canonical +# Copyright (C) 2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -20,7 +20,7 @@ import argparse import logging -from devices import load_devices +from snappy_device_agents.devices import load_devices logger = logging.getLogger() diff --git a/data/extrausers/group b/src/snappy_device_agents/data/extrausers/group similarity index 100% rename from data/extrausers/group rename to src/snappy_device_agents/data/extrausers/group diff --git a/data/extrausers/gshadow b/src/snappy_device_agents/data/extrausers/gshadow similarity index 100% rename from data/extrausers/gshadow rename to src/snappy_device_agents/data/extrausers/gshadow diff --git a/data/extrausers/passwd b/src/snappy_device_agents/data/extrausers/passwd similarity index 100% rename from data/extrausers/passwd rename to src/snappy_device_agents/data/extrausers/passwd diff --git a/data/extrausers/shadow b/src/snappy_device_agents/data/extrausers/shadow similarity index 100% rename from data/extrausers/shadow rename to src/snappy_device_agents/data/extrausers/shadow diff --git a/data/extrausers/subgid b/src/snappy_device_agents/data/extrausers/subgid similarity index 100% rename from data/extrausers/subgid rename to src/snappy_device_agents/data/extrausers/subgid diff --git a/data/extrausers/subuid b/src/snappy_device_agents/data/extrausers/subuid similarity index 100% rename from data/extrausers/subuid rename to src/snappy_device_agents/data/extrausers/subuid diff --git a/data/pi-desktop/oem-config.service b/src/snappy_device_agents/data/pi-desktop/oem-config.service similarity index 100% rename from data/pi-desktop/oem-config.service rename to src/snappy_device_agents/data/pi-desktop/oem-config.service diff --git a/data/pi-desktop/preseed.cfg b/src/snappy_device_agents/data/pi-desktop/preseed.cfg similarity index 100% rename from data/pi-desktop/preseed.cfg rename to src/snappy_device_agents/data/pi-desktop/preseed.cfg diff --git a/devices/__init__.py b/src/snappy_device_agents/devices/__init__.py similarity index 99% rename from devices/__init__.py rename to src/snappy_device_agents/devices/__init__.py index 6ae7c7da..53698ab4 100644 --- a/devices/__init__.py +++ b/src/snappy_device_agents/devices/__init__.py @@ -17,13 +17,14 @@ import multiprocessing import os import select -import snappy_device_agents import socket import subprocess import time +from datetime import datetime, timedelta + import yaml -from datetime import datetime, timedelta +import snappy_device_agents class ProvisioningError(Exception): diff --git a/devices/cm3/__init__.py b/src/snappy_device_agents/devices/cm3/__init__.py similarity index 89% rename from devices/cm3/__init__.py rename to src/snappy_device_agents/devices/cm3/__init__.py index 91b97511..bbf0eb64 100644 --- a/devices/cm3/__init__.py +++ b/src/snappy_device_agents/devices/cm3/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2019 Canonical +# Copyright (C) 2017-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,12 +15,18 @@ """Ubuntu Raspberry PI CM3 support code.""" import logging + import yaml import snappy_device_agents -from devices.cm3.cm3 import CM3 from snappy_device_agents import logmsg -from devices import catch, DefaultDevice, RecoveryError, SerialLogger +from snappy_device_agents.devices import ( + DefaultDevice, + RecoveryError, + SerialLogger, + catch, +) +from snappy_device_agents.devices.cm3.cm3 import CM3 device_name = "cm3" diff --git a/devices/cm3/cm3.py b/src/snappy_device_agents/devices/cm3/cm3.py similarity index 98% rename from devices/cm3/cm3.py rename to src/snappy_device_agents/devices/cm3/cm3.py index 3dd5812f..35d65fab 100644 --- a/devices/cm3/cm3.py +++ b/src/snappy_device_agents/devices/cm3/cm3.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2020 Canonical +# Copyright (C) 2017-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -19,11 +19,11 @@ import os import subprocess import time -import yaml - from contextlib import contextmanager -from devices import ProvisioningError, RecoveryError +import yaml + +from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() diff --git a/devices/dragonboard/__init__.py b/src/snappy_device_agents/devices/dragonboard/__init__.py similarity index 87% rename from devices/dragonboard/__init__.py rename to src/snappy_device_agents/devices/dragonboard/__init__.py index 45d0ccd9..56553b0d 100644 --- a/devices/dragonboard/__init__.py +++ b/src/snappy_device_agents/devices/dragonboard/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016-2019 Canonical +# Copyright (C) 2016-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,12 +15,18 @@ """Dragonboard support code.""" import logging + import yaml import snappy_device_agents -from devices.dragonboard.dragonboard import Dragonboard from snappy_device_agents import logmsg -from devices import catch, DefaultDevice, RecoveryError, SerialLogger +from snappy_device_agents.devices import ( + DefaultDevice, + RecoveryError, + SerialLogger, + catch, +) +from snappy_device_agents.devices.dragonboard.dragonboard import Dragonboard device_name = "dragonboard" diff --git a/devices/dragonboard/dragonboard.py b/src/snappy_device_agents/devices/dragonboard/dragonboard.py similarity index 99% rename from devices/dragonboard/dragonboard.py rename to src/snappy_device_agents/devices/dragonboard/dragonboard.py index da7a5cd7..2f181e90 100644 --- a/devices/dragonboard/dragonboard.py +++ b/src/snappy_device_agents/devices/dragonboard/dragonboard.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -20,10 +20,11 @@ import os import subprocess import time + import yaml import snappy_device_agents -from devices import ProvisioningError, RecoveryError +from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() diff --git a/devices/maas2/__init__.py b/src/snappy_device_agents/devices/maas2/__init__.py similarity index 93% rename from devices/maas2/__init__.py rename to src/snappy_device_agents/devices/maas2/__init__.py index f0f834f7..7130ba3c 100644 --- a/devices/maas2/__init__.py +++ b/src/snappy_device_agents/devices/maas2/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2019 Canonical +# Copyright (C) 2017-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,17 +16,18 @@ import logging -import snappy_device_agents import yaml -from devices import ( + +import snappy_device_agents +from snappy_device_agents import logmsg +from snappy_device_agents.devices import ( DefaultDevice, ProvisioningError, RecoveryError, SerialLogger, catch, ) -from devices.maas2.maas2 import Maas2 -from snappy_device_agents import logmsg +from snappy_device_agents.devices.maas2.maas2 import Maas2 device_name = "maas2" diff --git a/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py similarity index 99% rename from devices/maas2/maas2.py rename to src/snappy_device_agents/devices/maas2/maas2.py index a13c6c27..c7f8bbb9 100644 --- a/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Canonical +# Copyright (C) 2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -22,7 +22,8 @@ from collections import OrderedDict import yaml -from devices import ProvisioningError, RecoveryError + +from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() diff --git a/devices/muxpi/__init__.py b/src/snappy_device_agents/devices/muxpi/__init__.py similarity index 89% rename from devices/muxpi/__init__.py rename to src/snappy_device_agents/devices/muxpi/__init__.py index 892acb5c..1cf18b93 100644 --- a/devices/muxpi/__init__.py +++ b/src/snappy_device_agents/devices/muxpi/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2019 Canonical +# Copyright (C) 2017-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,12 +15,18 @@ """Ubuntu Raspberry PI muxpi support code.""" import logging + import yaml import snappy_device_agents -from devices.muxpi.muxpi import MuxPi from snappy_device_agents import logmsg -from devices import catch, RecoveryError, DefaultDevice, SerialLogger +from snappy_device_agents.devices import ( + DefaultDevice, + RecoveryError, + SerialLogger, + catch, +) +from snappy_device_agents.devices.muxpi.muxpi import MuxPi device_name = "muxpi" diff --git a/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py similarity index 99% rename from devices/muxpi/muxpi.py rename to src/snappy_device_agents/devices/muxpi/muxpi.py index 6daf85e5..85dc6dc4 100644 --- a/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2020 Canonical +# Copyright (C) 2017-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -20,13 +20,12 @@ import os import subprocess import time +from contextlib import contextmanager + import yaml import snappy_device_agents - -from contextlib import contextmanager - -from devices import ProvisioningError, RecoveryError +from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() diff --git a/devices/netboot/__init__.py b/src/snappy_device_agents/devices/netboot/__init__.py similarity index 97% rename from devices/netboot/__init__.py rename to src/snappy_device_agents/devices/netboot/__init__.py index f16a752d..27bf4e10 100644 --- a/devices/netboot/__init__.py +++ b/src/snappy_device_agents/devices/netboot/__init__.py @@ -16,20 +16,20 @@ import logging import multiprocessing -import yaml -import snappy_device_agents -from devices.netboot.netboot import Netboot -from snappy_device_agents import logmsg - -from devices import ( - catch, +import yaml +from snappy_device_agents.devices import ( DefaultDevice, ProvisioningError, RecoveryError, SerialLogger, + catch, ) +import snappy_device_agents +from snappy_device_agents import logmsg +from snappy_device_agents.devices.netboot.netboot import Netboot + device_name = "netboot" diff --git a/devices/netboot/netboot.py b/src/snappy_device_agents/devices/netboot/netboot.py similarity index 98% rename from devices/netboot/netboot.py rename to src/snappy_device_agents/devices/netboot/netboot.py index 43fe5f1e..3bbe519c 100644 --- a/devices/netboot/netboot.py +++ b/src/snappy_device_agents/devices/netboot/netboot.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,12 +16,13 @@ import logging import subprocess -import urllib.request import time +import urllib.request + import yaml -from snappy_device_agents import runcmd, CmdTimeoutError -from devices import ProvisioningError, RecoveryError +from snappy_device_agents import CmdTimeoutError, runcmd +from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() diff --git a/devices/noprovision/__init__.py b/src/snappy_device_agents/devices/noprovision/__init__.py similarity index 86% rename from devices/noprovision/__init__.py rename to src/snappy_device_agents/devices/noprovision/__init__.py index acb8ef6e..dc92d3eb 100644 --- a/devices/noprovision/__init__.py +++ b/src/snappy_device_agents/devices/noprovision/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2019 Canonical +# Copyright (C) 2017-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,14 +15,13 @@ """Noprovision support code.""" import logging -import yaml +import yaml +from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch import snappy_device_agents -from devices.noprovision.noprovision import Noprovision from snappy_device_agents import logmsg - -from devices import catch, RecoveryError, DefaultDevice +from snappy_device_agents.devices.noprovision.noprovision import Noprovision device_name = "noprovision" diff --git a/devices/noprovision/noprovision.py b/src/snappy_device_agents/devices/noprovision/noprovision.py similarity index 96% rename from devices/noprovision/noprovision.py rename to src/snappy_device_agents/devices/noprovision/noprovision.py index 0a7e5183..904179c1 100644 --- a/devices/noprovision/noprovision.py +++ b/src/snappy_device_agents/devices/noprovision/noprovision.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 Canonical +# Copyright (C) 2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,9 +17,10 @@ import logging import subprocess import time + import yaml -from devices import ProvisioningError, RecoveryError +from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() diff --git a/devices/oemrecovery/__init__.py b/src/snappy_device_agents/devices/oemrecovery/__init__.py similarity index 87% rename from devices/oemrecovery/__init__.py rename to src/snappy_device_agents/devices/oemrecovery/__init__.py index 5d08762e..4f7fbd5b 100644 --- a/devices/oemrecovery/__init__.py +++ b/src/snappy_device_agents/devices/oemrecovery/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2018-2019 Canonical +# Copyright (C) 2018-2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,12 +15,13 @@ """Ubuntu OEM Recovery provisioner support code.""" import logging + import yaml import snappy_device_agents -from devices.oemrecovery.oemrecovery import OemRecovery from snappy_device_agents import logmsg -from devices import catch, RecoveryError, DefaultDevice +from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch +from snappy_device_agents.devices.oemrecovery.oemrecovery import OemRecovery device_name = "oemrecovery" diff --git a/devices/oemrecovery/oemrecovery.py b/src/snappy_device_agents/devices/oemrecovery/oemrecovery.py similarity index 97% rename from devices/oemrecovery/oemrecovery.py rename to src/snappy_device_agents/devices/oemrecovery/oemrecovery.py index 8d01a03a..e98eb4b1 100644 --- a/devices/oemrecovery/oemrecovery.py +++ b/src/snappy_device_agents/devices/oemrecovery/oemrecovery.py @@ -1,4 +1,4 @@ -# Copyright (C) 2018 Canonical +# Copyright (C) 2023 Canonical # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,10 +18,11 @@ import logging import subprocess import time + import yaml -from devices import ProvisioningError, RecoveryError from snappy_device_agents import CmdTimeoutError +from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() diff --git a/tests/__init__.py b/src/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to src/tests/__init__.py diff --git a/tests/test_snappy_device_agents.py b/src/tests/test_snappy_device_agents.py similarity index 100% rename from tests/test_snappy_device_agents.py rename to src/tests/test_snappy_device_agents.py diff --git a/tox.ini b/tox.ini index 08a3be87..54c75049 100644 --- a/tox.ini +++ b/tox.ini @@ -10,8 +10,8 @@ deps = pylint pytest-cov commands = - {envbindir}/python setup.py develop - {envbindir}/python -m black --check setup.py snappy_device_agents devices tests - {envbindir}/python -m flake8 setup.py snappy_device_agents devices - #{envbindir}/python -m pylint snappy_device_agents devices - {envbindir}/python -m pytest --doctest-modules --cov=snappy_device_agents --cov=devices + {envbindir}/pip3 install . + {envbindir}/python -m black --check src + {envbindir}/python -m flake8 src + #{envbindir}/python -m pylint src + {envbindir}/python -m pytest --doctest-modules --cov=src From 51d84468af024c454da2d67661aebccd26e5f5fe Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 22 Feb 2023 11:35:56 -0800 Subject: [PATCH 423/569] minor handler refactor --- testflinger_agent/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 6fe4b270..78712fba 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -45,8 +45,7 @@ class ReqBufferHandler(logging.Handler): def __init__(self, agent, server): super().__init__() - self.server = server - uri = urljoin(self.server, "/v1/agents/data/") + uri = urljoin(server, "/v1/agents/data/") self.url = urljoin(uri, agent) self.qdepth = 100 # messages self.buffer = deque([], maxlen=self.qdepth) From 8c1259f8e94dfd2cf1d448d80e7419a5b0218cfa Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 23 Feb 2023 00:53:26 -0800 Subject: [PATCH 424/569] scope requests exception handling, req_logger matches logger level, fix buffer passthrough --- testflinger_agent/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 78712fba..94e1480d 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -91,12 +91,10 @@ def flush(self): self.session.post( url=self.url, json=self.format(record), timeout=3 ) - except Exception as e: - logger.exception(e) + except requests.RequestException as error: + logger.debug(error) - # preserve buffer - if len(self.buffer) <= self.qdepth: - return + return # preserve buffer self.buffer = [] @@ -172,6 +170,7 @@ def configure_logging(config): ) request_handler.setFormatter(request_formatter) req_logger.addHandler(request_handler) + req_logger.setLevel(log_level) if not config.get("logging_quiet"): console_log = logging.StreamHandler() console_log.setFormatter(logfmt) From 3d845c3fdb8f35e240a3340bc65fada09c6fac64 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 23 Feb 2023 01:33:34 -0800 Subject: [PATCH 425/569] fix empty location and enforce adv_queue data type --- testflinger_agent/agent.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 5c912288..abbb74d7 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -30,10 +30,14 @@ def __init__(self, client): self.client = client self._state = multiprocessing.Array("c", 16) self.set_state("waiting") - self.advertised_queues = self.client.config.get("advertised_queues") + self.advertised_queues = self.client.config.get( + "advertised_queues", {} + ) self.advertised_images = self.client.config.get("advertised_images") location = self.client.config.get("location") if self.advertised_queues or location: + if not location: + location = "" self.client.post_agent_data( {"queues": self.advertised_queues, "location": location} ) From 73516c048604545240745c4d90a3ce5006d49ddd Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 22 Feb 2023 14:54:33 -0600 Subject: [PATCH 426/569] Add a starting place for the multi-device agent --- pyproject.toml | 1 + .../devices/multi/__init__.py | 49 +++++++ .../devices/multi/multi.py | 96 +++++++++++++ .../devices/multi/tfclient.py | 129 ++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 src/snappy_device_agents/devices/multi/__init__.py create mode 100644 src/snappy_device_agents/devices/multi/multi.py create mode 100644 src/snappy_device_agents/devices/multi/tfclient.py diff --git a/pyproject.toml b/pyproject.toml index 52df52fa..3df69ab8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ readme = "README.rst" requires-python = ">=3.8" dependencies = [ "PyYAML>=3.11", + "requests", ] [project.scripts] diff --git a/src/snappy_device_agents/devices/multi/__init__.py b/src/snappy_device_agents/devices/multi/__init__.py new file mode 100644 index 00000000..5a4d0b34 --- /dev/null +++ b/src/snappy_device_agents/devices/multi/__init__.py @@ -0,0 +1,49 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu multi-device support code.""" + +import json +import logging +import yaml + +import snappy_device_agents +from snappy_device_agents import logmsg +from snappy_device_agents.devices import ( + DefaultDevice, +) +from snappy_device_agents.devices.multi.multi import Multi +from snappy_device_agents.devices.multi.tfclient import TFClient + +device_name = "multi" + + +class DeviceAgent(DefaultDevice): + + """Device Agent for provisioning multiple devices at the same time""" + + def provision(self, args): + """Method called when the command is invoked.""" + with open(args.config, encoding="utf-8") as configfile: + config = yaml.safe_load(configfile) + with open(args.job_data, encoding="utf-8") as jobfile: + job_data = json.load(jobfile) + snappy_device_agents.configure_logging(config) + testflinger_server = config.get("testflinger_server") + tfclient = TFClient(testflinger_server) + device = Multi(config, job_data, tfclient) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py new file mode 100644 index 00000000..427abd02 --- /dev/null +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -0,0 +1,96 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu multi-device support code.""" + +import logging +import os +import time + +from snappy_device_agents.devices import ProvisioningError + +logger = logging.getLogger() + + +class Multi: + + """Device Agent for multi-device""" + + def __init__(self, config, job_data, client): + """Initialize the multi-device agent. + + :param config: path to the config file + :param job_data: path to the job data file + :param client: client object for talking to the Testflinger server + """ + self.config = config + self.job_data = job_data + self.agent_name = self.config.get("agent_name") + self.mount_point = os.path.join("/mnt", self.agent_name) + self.client = client + self.jobs = [] + + def provision(self): + """Provision the multi-device agent by creating the specified jobs""" + self.create_jobs() + + # Wait for all jobs to reach the "allocated" state + unallocated = self.jobs.copy() + + while unallocated: + time.sleep(10) + for job in unallocated: + state = self.client.get_status(job) + if state == "allocated": + unallocated.remove(job) + break + if state in ("cancelled", "complete"): + logger.error( + "Job %s failed to allocate, cancelling remaining jobs", + job, + ) + self.cancel_jobs() + raise ProvisioningError("Unable to allocate all jobs") + + def create_jobs(self): + """Create the jobs for the multi-device agent""" + jobs_list = self.job_data.get("provision_data", {}).get("jobs") + if not jobs_list: + raise ProvisioningError( + "You must specify a list of 'jobs' in " + "the 'provision_data' section of " + "your job." + ) + + logger.info("Creating test jobs") + for job in jobs_list: + try: + job_id = self.client.submit_job(job) + except OSError as exc: + logger.exception("Unable to create job: %s", job_id) + self.cancel_jobs() + raise ProvisioningError( + f"Unable to create job: {job_id}" + ) from exc + + logger.info("Created job %s", job_id) + self.jobs.append(job_id) + + def cancel_jobs(self): + """Try to cancel any jobs that were created""" + for job in self.jobs: + try: + self.client.cancel_job(job) + except OSError: + logger.exception("Unable to cancel job: %s", job) diff --git a/src/snappy_device_agents/devices/multi/tfclient.py b/src/snappy_device_agents/devices/multi/tfclient.py new file mode 100644 index 00000000..12d71b28 --- /dev/null +++ b/src/snappy_device_agents/devices/multi/tfclient.py @@ -0,0 +1,129 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Client for talking to Testflinger Server""" + +import json +import logging +import urllib.parse +import requests + +logger = logging.getLogger() + + +class TFClient: + """Testflinger connection class""" + + def __init__(self, url): + """Initialize the client with the url of the server + + :param url: URL of the Testflinger server + """ + self.server = url + + def get(self, uri_frag, timeout=15): + """Submit a GET request to the server + :param uri_frag: + endpoint for the GET request + :return: + String containing the response from the server + """ + uri = urllib.parse.urljoin(self.server, uri_frag) + try: + req = requests.get(uri, timeout=timeout) + except requests.exceptions.ConnectionError: + logger.error("Unable to communicate with specified server.") + raise + except IOError: + # This should catch all other timeout cases + logger.error( + "Timeout while trying to communicate with the server." + ) + raise + + try: + # If anything else went wrong, raise the proper exception + req.raise_for_status() + except OSError: + logger.error( + "Received status code %s from server.", req.status_code + ) + raise + return req.text + + def post(self, uri_frag, data, timeout=15): + """Submit a POST request to the server + :param uri_frag: + endpoint for the POST request + :return: + String containing the response from the server + """ + uri = urllib.parse.urljoin(self.server, uri_frag) + try: + req = requests.post(uri, json=data, timeout=timeout) + except requests.exceptions.ConnectTimeout: + logger.error("Timout while trying to communicate with the server.") + raise + except requests.exceptions.ConnectionError: + logger.error("Unable to communicate with specified server.") + raise + + try: + # If anything else went wrong, raise the proper exception + req.raise_for_status() + except OSError: + logger.error( + "Received status code %s from server.", req.status_code + ) + raise + return req.text + + def get_status(self, job_id): + """Get the status of a test job + + :param job_id: + ID for the test job + :return: + String containing the job_state for the specified ID + (waiting, setup, provision, test, reserved, released, + cancelled, complete) + """ + try: + endpoint = f"/v1/result/{job_id}" + data = json.loads(self.get(endpoint)) + state = data.get("job_state") + except OSError: + logger.exception("Unable to get status for job %s", job_id) + state = "unknown" + return state + + def submit_job(self, job_data): + """Submit a test job to the testflinger server + + :param job_data: + dict of data for the job to submit + :return: + ID for the test job + """ + endpoint = "/v1/job" + response = self.post(endpoint, job_data) + return json.loads(response).get("job_id") + + def cancel_job(self, job_id): + """Tell the server to cancel a specified JOB_ID""" + try: + self.post(f"/v1/job/{job_id}/action", {"action": "cancel"}) + except OSError: + logger.error("Unable to cancel job %s", job_id) + raise From 89d37f6a341e1e0e1d425f9911c63fa6116c9c22 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 23 Feb 2023 23:25:09 -0800 Subject: [PATCH 427/569] consistent naming, proper buffer clear, contain request exceptions --- testflinger_agent/__init__.py | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 94e1480d..f8f8a8ee 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -19,6 +19,7 @@ import yaml import requests from requests.adapters import HTTPAdapter, Retry +from urllib3.exceptions import HTTPError from urllib.parse import urljoin from collections import deque from threading import Timer @@ -45,13 +46,15 @@ class ReqBufferHandler(logging.Handler): def __init__(self, agent, server): super().__init__() + if not server.lower().startswith("http"): + server = "http://" + server uri = urljoin(server, "/v1/agents/data/") self.url = urljoin(uri, agent) self.qdepth = 100 # messages - self.buffer = deque([], maxlen=self.qdepth) - self.reqbuf_timer = None - self.reqbuf_interval = 10.0 # seconds - self._start_rb_timer() + self.reqbuffer = deque([], maxlen=self.qdepth) + self.reqbuff_timer = None + self.reqbuff_interval = 10.0 # seconds + self._start_reqbuff_timer() # reuse socket self.session = self._requests_retry() @@ -64,43 +67,46 @@ def _requests_retry(self, retries=3): connect=retries, backoff_factor=0.3, status_forcelist=(500, 502, 503, 504), + method_whitelist=False, # allow post retry ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) return session - def _start_rb_timer(self): + def _start_reqbuff_timer(self): """Periodically check and send buffer""" - self.reqbuf_timer = ReqBufferTimer(self.reqbuf_interval, self.flush) + self.reqbuff_timer = ReqBufferTimer( + self.reqbuff_interval, self.flush + ) # terminate timer on exit - self.reqbuf_timer.daemon = True - self.reqbuf_timer.start() + self.reqbuff_timer.daemon = True + self.reqbuff_timer.start() def emit(self, record): """Write logging events to buffer""" - if len(self.buffer) >= self.qdepth: - self.buffer.popleft() + if len(self.reqbuffer) >= self.qdepth: + self.reqbuffer.popleft() - self.buffer.append(record) + self.reqbuffer.append(record) def flush(self): """Flush and post buffer""" try: - for record in self.buffer: + for record in self.reqbuffer: self.session.post( url=self.url, json=self.format(record), timeout=3 ) - except requests.RequestException as error: + except (requests.RequestException, HTTPError) as error: logger.debug(error) return # preserve buffer - self.buffer = [] + self.reqbuffer.clear() def close(self): """Cleanup on handler close""" - self.reqbuf_timer.cancel() + self.reqbuff_timer.cancel() class ReqBufferFormatter(logging.Formatter): From 12df5ac2d5a64f7be9e89948c0eba23fd80b149d Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 23 Feb 2023 23:27:09 -0800 Subject: [PATCH 428/569] consistent naming, proper buffer clear, contain request exceptions --- testflinger_agent/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index f8f8a8ee..20a2622a 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -76,9 +76,7 @@ def _requests_retry(self, retries=3): def _start_reqbuff_timer(self): """Periodically check and send buffer""" - self.reqbuff_timer = ReqBufferTimer( - self.reqbuff_interval, self.flush - ) + self.reqbuff_timer = ReqBufferTimer(self.reqbuff_interval, self.flush) # terminate timer on exit self.reqbuff_timer.daemon = True self.reqbuff_timer.start() From 22096c2bb3c8310e173a55cb17b14204b1d84905 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 28 Feb 2023 13:32:10 -0800 Subject: [PATCH 429/569] minor refactoring - location and exception handling --- testflinger_agent/agent.py | 12 ++++++++---- testflinger_agent/client.py | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index abbb74d7..2f664f89 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -34,10 +34,8 @@ def __init__(self, client): "advertised_queues", {} ) self.advertised_images = self.client.config.get("advertised_images") - location = self.client.config.get("location") + location = self.client.config.get("location", "") if self.advertised_queues or location: - if not location: - location = "" self.client.post_agent_data( {"queues": self.advertised_queues, "location": location} ) @@ -59,8 +57,14 @@ def _status_worker(self): self.client.post_images(self.advertised_images) time.sleep(120) + def _post_agent_data(self, data): + try: + self.client.post_agent_data(data) + except Exception: + pass + def set_state(self, state): - self.client.post_agent_data({"state": state}) + self._post_agent_data({"state": state}) self._state.value = state.encode("utf-8") def get_offline_files(self): diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 138d33fd..fd2e4d0e 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -21,6 +21,7 @@ import time from urllib.parse import urljoin +from urllib3.exceptions import HTTPError from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry @@ -47,6 +48,7 @@ def _requests_retry(self, retries=3): connect=retries, backoff_factor=0.3, status_forcelist=(500, 502, 503, 504), + method_whitelist=False, # allow post retry ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) @@ -263,5 +265,5 @@ def post_agent_data(self, data): agent_data_url = urljoin(agent_data_uri, agent) try: self.session.post(agent_data_url, json=data, timeout=30) - except Exception as e: - logger.exception(e) + except (requests.RequestException, HTTPError) as error: + logger.exception(error) From f42deaeabe77c5044abff56e9f0113b5f26c6294 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 28 Feb 2023 13:44:15 -0800 Subject: [PATCH 430/569] remove erroneous post method --- testflinger_agent/agent.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 2f664f89..2858642d 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -57,14 +57,8 @@ def _status_worker(self): self.client.post_images(self.advertised_images) time.sleep(120) - def _post_agent_data(self, data): - try: - self.client.post_agent_data(data) - except Exception: - pass - def set_state(self, state): - self._post_agent_data({"state": state}) + self.client.post_agent_data({"state": state}) self._state.value = state.encode("utf-8") def get_offline_files(self): From e3599fbbec1ff91f81e8dfc87a3048a6347ba142 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 28 Feb 2023 14:41:03 -0800 Subject: [PATCH 431/569] enable atomic iteration over buffer queue to address deque mutation errors (simultaneous access) --- testflinger_agent/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 20a2622a..8dcfb5ab 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -91,7 +91,8 @@ def emit(self, record): def flush(self): """Flush and post buffer""" try: - for record in self.reqbuffer: + # atomic queue iteration + for record in list(self.reqbuffer): self.session.post( url=self.url, json=self.format(record), timeout=3 ) From 407ea10850ee5cfa549ee6385db629c44eed02ee Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 28 Feb 2023 14:43:52 -0800 Subject: [PATCH 432/569] bump up request post timeout to avoid excessive resets --- testflinger_agent/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 8dcfb5ab..12346b45 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -94,7 +94,7 @@ def flush(self): # atomic queue iteration for record in list(self.reqbuffer): self.session.post( - url=self.url, json=self.format(record), timeout=3 + url=self.url, json=self.format(record), timeout=5 ) except (requests.RequestException, HTTPError) as error: logger.debug(error) From e8c011080241486d93628b8c35c68b9ae1e2cc86 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 28 Feb 2023 16:54:44 -0800 Subject: [PATCH 433/569] add location to test_agent config data --- testflinger_agent/agent.py | 2 +- testflinger_agent/tests/test_agent.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 2858642d..b5f02caa 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -136,11 +136,11 @@ def process_jobs(self): try: job = TestflingerJob(job_data, self.client) logger.info("Starting job %s", job.job_id) - self.client.post_agent_data({"job_id": job.job_id}) rundir = os.path.join( self.client.config.get("execution_basedir"), job.job_id ) os.makedirs(rundir) + self.client.post_agent_data({"job_id": job.job_id}) # Dump the job data to testflinger.json in our execution dir with open(os.path.join(rundir, "testflinger.json"), "w") as f: json.dump(job_data, f) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 550f8514..15d92a56 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -27,6 +27,7 @@ def agent(self): "logging_basedir": self.tmpdir, "results_basedir": os.path.join(self.tmpdir, "results"), "test_string": "ThisIsATest", + "location": "foo", } testflinger_agent.configure_logging(self.config) client = _TestflingerClient(self.config) From 5f120169c17478e668836de311c1bf753d55bf83 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 1 Mar 2023 00:14:58 -0800 Subject: [PATCH 434/569] added agent data mock request and associated mock data --- testflinger_agent/tests/test_agent.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 15d92a56..4bec851f 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -182,6 +182,14 @@ def test_recovery_failed(self, agent, requests_mock): "provision_data": {"url": "foo"}, "test_data": {"test_cmds": "foo"}, } + fake_agent_data = { + "agent": self.config.get("agent_id"), + "log": "foo", + "state": "foo", + "job_id": job_id, + "location": self.config.get("location"), + "queues": self.config.get("job_queues") + } # In this case we are making sure that the repost job request # gets good status with rmock.Mocker() as m: @@ -194,6 +202,12 @@ def test_recovery_failed(self, agent, requests_mock): "http://127.0.0.1:8000/v1/result/" + job_id + "/output", text="{}", ) + m.post( + "http://127.0.0.1:8000/v1/agents/data/" + self.config.get( + "agent_id" + ), + json=fake_agent_data + ) mpost_job_json = m.post( "http://127.0.0.1:8000/v1/job", json={"job_id": job_id} ) From 1768df10f5285ac17a80d32160acf7cce1c0b6d7 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 1 Mar 2023 00:16:56 -0800 Subject: [PATCH 435/569] added agent data mock request and associated mock data --- testflinger_agent/tests/test_agent.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 4bec851f..73803371 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -188,7 +188,7 @@ def test_recovery_failed(self, agent, requests_mock): "state": "foo", "job_id": job_id, "location": self.config.get("location"), - "queues": self.config.get("job_queues") + "queues": self.config.get("job_queues"), } # In this case we are making sure that the repost job request # gets good status @@ -203,10 +203,9 @@ def test_recovery_failed(self, agent, requests_mock): text="{}", ) m.post( - "http://127.0.0.1:8000/v1/agents/data/" + self.config.get( - "agent_id" - ), - json=fake_agent_data + "http://127.0.0.1:8000/v1/agents/data/" + + self.config.get("agent_id"), + json=fake_agent_data, ) mpost_job_json = m.post( "http://127.0.0.1:8000/v1/job", json={"job_id": job_id} From 6180910ff9a180d4acbeffc6d58aab241b802f9f Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 2 Mar 2023 00:33:53 -0800 Subject: [PATCH 436/569] update flush() logic to serially empty buffer --- testflinger_agent/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 12346b45..d1270ce0 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -90,18 +90,17 @@ def emit(self, record): def flush(self): """Flush and post buffer""" - try: - # atomic queue iteration - for record in list(self.reqbuffer): + for record in list(self.reqbuffer): + try: self.session.post( url=self.url, json=self.format(record), timeout=5 ) - except (requests.RequestException, HTTPError) as error: - logger.debug(error) + except (requests.RequestException, HTTPError) as error: + logger.debug(error) - return # preserve buffer + return # preserve buffer - self.reqbuffer.clear() + self.reqbuffer.popleft() def close(self): """Cleanup on handler close""" From 0feecb501fe03d8653d9037071e5d46b935e0019 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 2 Mar 2023 10:54:36 -0600 Subject: [PATCH 437/569] Remove unneeded mock data from test_agent --- testflinger_agent/tests/test_agent.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 73803371..eade3645 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -27,7 +27,6 @@ def agent(self): "logging_basedir": self.tmpdir, "results_basedir": os.path.join(self.tmpdir, "results"), "test_string": "ThisIsATest", - "location": "foo", } testflinger_agent.configure_logging(self.config) client = _TestflingerClient(self.config) @@ -182,14 +181,6 @@ def test_recovery_failed(self, agent, requests_mock): "provision_data": {"url": "foo"}, "test_data": {"test_cmds": "foo"}, } - fake_agent_data = { - "agent": self.config.get("agent_id"), - "log": "foo", - "state": "foo", - "job_id": job_id, - "location": self.config.get("location"), - "queues": self.config.get("job_queues"), - } # In this case we are making sure that the repost job request # gets good status with rmock.Mocker() as m: @@ -205,11 +196,12 @@ def test_recovery_failed(self, agent, requests_mock): m.post( "http://127.0.0.1:8000/v1/agents/data/" + self.config.get("agent_id"), - json=fake_agent_data, + text="OK", ) mpost_job_json = m.post( "http://127.0.0.1:8000/v1/job", json={"job_id": job_id} ) + agent.process_jobs() assert agent.check_offline() # These are the args we would expect when it reposts the job From a1eed2aeb1284838a82a235c42bcbf318005a632 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 2 Mar 2023 11:09:26 -0600 Subject: [PATCH 438/569] Fix deprecated API Co-authored-by: Adrian Lane --- testflinger_agent/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index fd2e4d0e..d10b5568 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -48,7 +48,7 @@ def _requests_retry(self, retries=3): connect=retries, backoff_factor=0.3, status_forcelist=(500, 502, 503, 504), - method_whitelist=False, # allow post retry + allowed_methods=False, # allow retry on all methods ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) From bbfac139e447acb833696333d8455534135a5919 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 2 Mar 2023 12:16:30 -0600 Subject: [PATCH 439/569] Improve exception handling --- testflinger_agent/__init__.py | 5 ++++- testflinger_agent/client.py | 38 +++++++++++++++++------------------ 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 12346b45..ffb8b067 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -155,7 +155,10 @@ def configure_logging(config): if not isinstance(log_level, int): log_level = logging.INFO logfmt = logging.Formatter( - fmt="[%(asctime)s] %(levelname)+7.7s: %(message)s", + fmt=( + "[%(asctime)s] %(levelname)+7.7s: " + "(%(filename)s:%(lineno)d)| %(message)s" + ), datefmt="%y-%m-%d %H:%M:%S", ) log_path = os.path.join( diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index d10b5568..1ce004ae 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -21,9 +21,9 @@ import time from urllib.parse import urljoin -from urllib3.exceptions import HTTPError from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry +from requests.exceptions import RequestException from testflinger_agent.errors import TFServerError @@ -71,8 +71,8 @@ def check_jobs(self): return job_request.json() else: return None - except Exception as e: - logger.exception(e) + except RequestException as exc: + logger.error(exc) # Wait a little extra before trying again time.sleep(60) @@ -99,9 +99,9 @@ def repost_job(self, job_data): self.post_live_output(job_id, job_output) try: job_request = self.session.post(job_uri, json=job_data) - except Exception as e: - logger.exception(e) - raise TFServerError("other exception") + except RequestException as exc: + logger.error(exc) + raise TFServerError("other exception") from exc if not job_request: logger.error( "Unable to re-post job to: %s (error: %s)" @@ -121,9 +121,9 @@ def post_result(self, job_id, data): result_uri = urljoin(result_uri, job_id) try: job_request = requests.post(result_uri, json=data, timeout=30) - except Exception as e: - logger.exception(e) - raise TFServerError("other exception") + except RequestException as exc: + logger.error(exc) + raise TFServerError("other exception") from exc if not job_request: logger.error( "Unable to post results to: %s (error: %s)" @@ -144,8 +144,8 @@ def get_result(self, job_id): result_uri = urljoin(result_uri, job_id) try: job_request = requests.get(result_uri, timeout=30) - except Exception as e: - logger.exception(e) + except RequestException as exc: + logger.error(exc) return {} if not job_request: logger.error( @@ -225,8 +225,8 @@ def post_live_output(self, job_id, data): job_request = requests.post( output_uri, data=data.encode("utf-8"), timeout=60 ) - except Exception as e: - logger.exception(e) + except RequestException as exc: + logger.error(exc) return False return bool(job_request) @@ -239,8 +239,8 @@ def post_queues(self, data): queues_uri = urljoin(self.server, "/v1/agents/queues") try: requests.post(queues_uri, json=data, timeout=30) - except Exception as e: - logger.exception(e) + except RequestException as exc: + logger.error(exc) def post_images(self, data): """Post the list of advertised images to testflinger server @@ -251,8 +251,8 @@ def post_images(self, data): images_uri = urljoin(self.server, "/v1/agents/images") try: requests.post(images_uri, json=data, timeout=30) - except Exception as e: - logger.exception(e) + except RequestException as exc: + logger.error(exc) def post_agent_data(self, data): """Post the relevant data points to testflinger server @@ -265,5 +265,5 @@ def post_agent_data(self, data): agent_data_url = urljoin(agent_data_uri, agent) try: self.session.post(agent_data_url, json=data, timeout=30) - except (requests.RequestException, HTTPError) as error: - logger.exception(error) + except RequestException as exc: + logger.error(exc) From 7f994991c7b30016a36c26e4d55e5e5e5f7caf2e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 2 Mar 2023 16:38:07 -0600 Subject: [PATCH 440/569] Timeout multi-device jobs if we fail to reach allocated state before timeout --- src/snappy_device_agents/devices/multi/multi.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py index 427abd02..11f45ae4 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -48,6 +48,12 @@ def provision(self): # Wait for all jobs to reach the "allocated" state unallocated = self.jobs.copy() + # Set default timeout to allocate all devices to 2 hours + allocation_timeout = self.job_data.get( + "allocation_timeout", 2 * 60 * 60 + ) + start_time = time.time() + while unallocated: time.sleep(10) for job in unallocated: @@ -61,7 +67,13 @@ def provision(self): job, ) self.cancel_jobs() - raise ProvisioningError("Unable to allocate all jobs") + raise ProvisioningError("Unable to allocate all devices") + # Timeout if we've been waiting too long for devices to allocate + if time.time() - start_time > allocation_timeout: + self.cancel_jobs() + raise ProvisioningError( + "Timed out waiting for devices to allocate" + ) def create_jobs(self): """Create the jobs for the multi-device agent""" From 164881df3b012af5aecec4caf649a8ae921e0959 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sat, 4 Mar 2023 02:17:02 -0600 Subject: [PATCH 441/569] Remove references to check_job_state from agent since it moved to client --- testflinger_agent/agent.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index b5f02caa..2930cb5e 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -113,11 +113,6 @@ def check_restart(self): ) break - def check_job_state(self, job_id): - job_data = self.client.get_result(job_id) - if job_data: - return job_data.get("job_state") - def mark_device_offline(self): # Create the offline file, this should work even if it exists open(self.get_offline_files()[0], "w").close() @@ -152,7 +147,7 @@ def process_jobs(self): for phase in TEST_PHASES: # First make sure the job hasn't been cancelled - if self.check_job_state(job.job_id) == "cancelled": + if self.client.check_job_state(job.job_id) == "cancelled": logger.info("Job cancellation was requested, exiting.") break # Try to update the job_state on the testflinger server @@ -174,7 +169,8 @@ def process_jobs(self): while proc.is_alive(): proc.join(10) if ( - self.check_job_state(job.job_id) == "cancelled" + self.client.check_job_state(job.job_id) + == "cancelled" and phase != "provision" ): logger.info( From 708bc6c1d4bb6138f303dc4b40e43f8f08a2f395 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 7 Mar 2023 14:44:48 -0600 Subject: [PATCH 442/569] Use pathlib on muxpi agent --- .../devices/muxpi/muxpi.py | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 85dc6dc4..c807dca3 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -17,10 +17,10 @@ import json import logging import multiprocessing -import os import subprocess import time from contextlib import contextmanager +from pathlib import Path import yaml @@ -48,7 +48,7 @@ def __init__(self, config, job_data): with open(job_data) as j: self.job_data = json.load(j) self.agent_name = self.config.get("agent_name") - self.mount_point = os.path.join("/mnt", self.agent_name) + self.mount_point = Path("/mnt") / self.agent_name def _run_control(self, cmd, timeout=60): """ @@ -195,7 +195,7 @@ def _get_part_labels(self): lsblk_json = json.loads(lsblk_data.decode()) # List of (name, label) pairs return [ - (x.get("name"), os.path.join(self.mount_point, x.get("label"))) + (x.get("name"), self.mount_point / x.get("label")) for x in lsblk_json["blockdevices"][0]["children"] if x.get("name") and x.get("label") ] @@ -253,7 +253,7 @@ def check_path(dir): for path, img_type in self.IMAGE_PATH_IDS.items(): try: - path = os.path.join(self.mount_point, path) + path = self.mount_point / path check_path(path) logger.info("Image type detected: {}".format(img_type)) return img_type @@ -309,16 +309,14 @@ def create_user(self, image_type): try: if image_type == "pi-desktop": # make a spot to scp files to - remote_tmp = os.path.join("/tmp", self.agent_name) + remote_tmp = Path("/tmp") / self.agent_name self._run_control("mkdir -p {}".format(remote_tmp)) - data_path = os.path.join( - os.path.dirname(__file__), "../../data/pi-desktop" - ) + data_path = Path(__file__).parent / "../../data/pi-desktop" # Override oem-config so that it uses the preseed self._copy_to_control( - os.path.join(data_path, "oem-config.service"), remote_tmp + data_path / "oem-config.service", remote_tmp ) cmd = ( "sudo cp {}/oem-config.service " @@ -328,9 +326,7 @@ def create_user(self, image_type): self._run_control(cmd) # Copy the preseed - self._copy_to_control( - os.path.join(data_path, "preseed.cfg"), remote_tmp - ) + self._copy_to_control(data_path / "preseed.cfg", remote_tmp) cmd = "sudo cp {}/preseed.cfg {}/writable/preseed.cfg".format( remote_tmp, self.mount_point ) @@ -360,8 +356,8 @@ def create_user(self, image_type): ) return if image_type == "core20": - base = os.path.join(self.mount_point, "ubuntu-seed") - ci_path = os.path.join(base, "data/etc/cloud/cloud.cfg.d") + base = self.mount_point / "ubuntu-seed" + ci_path = base / "data/etc/cloud/cloud.cfg.d" self._run_control("sudo mkdir -p {}".format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( @@ -369,12 +365,12 @@ def create_user(self, image_type): ) else: # For core or ubuntu classic images - base = os.path.join(self.mount_point, "writable") + base = self.mount_point / "writable" if image_type == "core": - base = os.path.join(base, "system-data") + base = base / "system-data" if image_type == "ubuntu-cpc": - base = os.path.join(self.mount_point, "cloudimg-rootfs") - ci_path = os.path.join(base, "var/lib/cloud/seed/nocloud-net") + base = self.mount_point / "cloudimg-rootfs" + ci_path = base / "var/lib/cloud/seed/nocloud-net" self._run_control("sudo mkdir -p {}".format(ci_path)) write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" self._run_control( @@ -387,9 +383,7 @@ def create_user(self, image_type): # This needs to be removed on classic for rpi, else # cloud-init won't find the user-data we give it rm_cmd = "sudo rm -f {}".format( - os.path.join( - base, "etc/cloud/cloud.cfg.d/99-fake?cloud.cfg" - ) + base / "etc/cloud/cloud.cfg.d/99-fake?cloud.cfg" ) self._run_control(rm_cmd) except Exception: From f99372c03c025d7f6efcb3373b2099b76e475b4d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 8 Mar 2023 13:39:16 -0600 Subject: [PATCH 443/569] Add limerick device support to muxpi device agent --- .../data/limerick/user-data | 14 ++++++++++ .../devices/muxpi/muxpi.py | 26 +++++++++++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 src/snappy_device_agents/data/limerick/user-data diff --git a/src/snappy_device_agents/data/limerick/user-data b/src/snappy_device_agents/data/limerick/user-data new file mode 100644 index 00000000..ef4d0d40 --- /dev/null +++ b/src/snappy_device_agents/data/limerick/user-data @@ -0,0 +1,14 @@ +#cloud-config +ssh_pwauth: True +users: + - name: ubuntu + gecos: ubuntu + uid: 1000 + shell: /bin/bash + lock_passwd: False + groups: [ adm, dialout, cdrom, floppy, sudo, audio, dip, video, plugdev, lxd, netdev, render ] + plain_text_passwd: 'ubuntu' +chpasswd: + list: | + ubuntu:ubuntu + expire: False diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index c807dca3..40ceddec 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -251,6 +251,15 @@ def get_image_type(self): def check_path(dir): self._run_control("test -e {}".format(dir)) + # First check if this is a limerick image + try: + disk_info_path = self.mount_point / "writable/.disk/info" + self._run_control(f"grep limerick {disk_info_path}") + return "limerick" + except subprocess.CalledProcessError: + # Not a limerick image + pass + for path, img_type in self.IMAGE_PATH_IDS.items(): try: path = self.mount_point / path @@ -306,17 +315,22 @@ def create_user(self, image_type): ) base = self.mount_point + remote_tmp = Path("/tmp") / self.agent_name try: + data_path = Path(__file__).parent / "../../data" + if image_type == "limerick": + self._copy_to_control( + data_path / "limerick/user-data", remote_tmp + ) + cmd = f"sudo cp {remote_tmp}/user-data {base}/system-boot/" + self._run_control(cmd) if image_type == "pi-desktop": # make a spot to scp files to - remote_tmp = Path("/tmp") / self.agent_name self._run_control("mkdir -p {}".format(remote_tmp)) - data_path = Path(__file__).parent / "../../data/pi-desktop" - # Override oem-config so that it uses the preseed self._copy_to_control( - data_path / "oem-config.service", remote_tmp + data_path / "pi-desktop/oem-config.service", remote_tmp ) cmd = ( "sudo cp {}/oem-config.service " @@ -326,7 +340,9 @@ def create_user(self, image_type): self._run_control(cmd) # Copy the preseed - self._copy_to_control(data_path / "preseed.cfg", remote_tmp) + self._copy_to_control( + data_path / "pi-desktop/preseed.cfg", remote_tmp + ) cmd = "sudo cp {}/preseed.cfg {}/writable/preseed.cfg".format( remote_tmp, self.mount_point ) From b90969eb0cd725462834e205997077b9c5c944c4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Mar 2023 13:24:43 -0600 Subject: [PATCH 444/569] Fix exception expected for failed _run_control --- src/snappy_device_agents/devices/muxpi/muxpi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 40ceddec..79ef0d42 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -256,7 +256,7 @@ def check_path(dir): disk_info_path = self.mount_point / "writable/.disk/info" self._run_control(f"grep limerick {disk_info_path}") return "limerick" - except subprocess.CalledProcessError: + except ProvisioningError: # Not a limerick image pass From 52bf12faba264601f7d92215534ce537b9af58e9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Mar 2023 15:42:04 -0600 Subject: [PATCH 445/569] Remove the multiprocess cancellation watcher from process_jobs --- testflinger_agent/agent.py | 31 +------------ testflinger_agent/job.py | 71 +++++++++++++++-------------- testflinger_agent/tests/test_job.py | 5 ++ 3 files changed, 45 insertions(+), 62 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 2930cb5e..e090d99b 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -158,26 +158,7 @@ def process_jobs(self): except TFServerError: pass self.set_state(phase) - proc = multiprocessing.Process( - target=job.run_test_phase, - args=( - phase, - rundir, - ), - ) - proc.start() - while proc.is_alive(): - proc.join(10) - if ( - self.client.check_job_state(job.job_id) - == "cancelled" - and phase != "provision" - ): - logger.info( - "Job cancellation was requested, exiting." - ) - proc.terminate() - exitcode = proc.exitcode + exitcode = job.run_test_phase(phase, rundir) # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline @@ -194,17 +175,9 @@ def process_jobs(self): logger.exception(e) finally: # Always run the cleanup, even if the job was cancelled - proc = multiprocessing.Process( - target=job.run_test_phase, - args=( - "cleanup", - rundir, - ), - ) + job.run_test_phase("cleanup", rundir) # clear job id self.client.post_agent_data({"job_id": ""}) - proc.start() - proc.join() try: self.client.transmit_job_outcome(rundir) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index d1feae8d..7d1fa4f1 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -16,7 +16,6 @@ import json import logging import os -import select import signal import sys import subprocess @@ -82,7 +81,7 @@ def run_test_phase(self, phase, rundir): self._update_phase_results( results_file, phase, exitcode, output_log, serial_log ) - sys.exit(exitcode) + return exitcode def _update_phase_results( self, results_file, phase, exitcode, output_log, serial_log @@ -153,7 +152,6 @@ def run_with_log(self, cmd, logfile, cwd=None): start_time = time.time() with open(logfile, "a", encoding="utf-8") as f: live_output_buffer = "" - readpoll = select.poll() buffer_timeout = time.time() process = subprocess.Popen( cmd, @@ -169,19 +167,21 @@ def cleanup(signum, frame): signal.signal(signal.SIGTERM, cleanup) set_nonblock(process.stdout.fileno()) - readpoll.register(process.stdout, select.POLLIN) - while process.poll() is None: - # Check if there's any new data, timeout after 10s - data_ready = readpoll.poll(10000) - if data_ready: - buf = process.stdout.read().decode( - sys.stdout.encoding, errors="replace" - ) - if buf: - sys.stdout.write(buf) - live_output_buffer += buf - f.write(buf) - f.flush() + + while True: + line = process.stdout.readline() + if not line and process.poll() is not None: + # Process exited + break + + if line: + # Write the latest output to the log file, stdout, and + # the live output buffer + buf = line.decode(sys.stdout.encoding, errors="replace") + sys.stdout.write(buf) + live_output_buffer += buf + f.write(buf) + f.flush() else: if ( self.phase == "test" @@ -189,12 +189,22 @@ def cleanup(signum, frame): ): buf = ( "\nERROR: Output timeout reached! " - "({}s)\n".format(output_timeout) + f"({output_timeout}s)\n" ) live_output_buffer += buf f.write(buf) process.kill() break + + # Check if it's time to send the output buffer to the server + if live_output_buffer and time.time() - buffer_timeout > 10: + if self.client.post_live_output( + self.job_id, live_output_buffer + ): + live_output_buffer = "" + buffer_timeout = time.time() + + # Check global timeout if ( self.phase != "reserve" and time.time() - start_time > global_timeout @@ -206,24 +216,19 @@ def cleanup(signum, frame): f.write(buf) process.kill() break - # Don't spam the server, only flush the buffer if there - # is output and it's been more than 10s - if live_output_buffer and time.time() - buffer_timeout > 10: - buffer_timeout = time.time() - # Try to stream output, if we can't connect, then - # keep buffer for the next pass through this - if self.client.post_live_output( - self.job_id, live_output_buffer - ): - live_output_buffer = "" - buf = process.stdout.read() - if buf: - buf = buf.decode(sys.stdout.encoding, errors="replace") - sys.stdout.write(buf) - live_output_buffer += buf - f.write(buf) + + # Check if job was canceled + if ( + self.client.check_job_state(self.job_id) == "cancelled" + and self.phase != "provision" + ): + logger.info("Job cancellation was requested, exiting.") + process.kill() + break + if live_output_buffer: self.client.post_live_output(self.job_id, live_output_buffer) + try: status = process.wait(10) # process.returncode except TimeoutError: diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 79d8b49e..76dc3863 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -61,6 +61,7 @@ def test_job_global_timeout(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"global_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" job.run_with_log("sleep 3", logfile) @@ -75,6 +76,7 @@ def test_config_global_timeout(self, client, requests_mock): self.config["global_timeout"] = 1 fake_job_data = {"global_timeout": 3} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" job.run_with_log("sleep 3", logfile) @@ -88,6 +90,7 @@ def test_job_output_timeout(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"output_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" # unfortunately, we need to sleep for longer that 10 seconds here @@ -104,6 +107,7 @@ def test_config_output_timeout(self, client, requests_mock): self.config["output_timeout"] = 1 fake_job_data = {"output_timeout": 30} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" # unfortunately, we need to sleep for longer that 10 seconds here @@ -119,6 +123,7 @@ def test_no_output_timeout_in_provision(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"output_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "provision" # unfortunately, we need to sleep for longer that 10 seconds here From 6a211687448289ad8508a2d2e7c8603e82acb72a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 10 Mar 2023 16:30:56 -0600 Subject: [PATCH 446/569] Fix deprecated requests.adapters.Retry API --- testflinger_agent/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index ffb8b067..b6450e41 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -67,7 +67,7 @@ def _requests_retry(self, retries=3): connect=retries, backoff_factor=0.3, status_forcelist=(500, 502, 503, 504), - method_whitelist=False, # allow post retry + allowed_methods=False, # allow retry on all methods ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) From 5aaa15237cf536e1f17a2815dcc9dfa6f8b2d80b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 13 Mar 2023 17:21:14 -0500 Subject: [PATCH 447/569] Break out post_job_state to a method and rename set_agent_state --- testflinger_agent/agent.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index e090d99b..f7630418 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -29,7 +29,7 @@ class TestflingerAgent: def __init__(self, client): self.client = client self._state = multiprocessing.Array("c", 16) - self.set_state("waiting") + self.set_agent_state("waiting") self.advertised_queues = self.client.config.get( "advertised_queues", {} ) @@ -57,7 +57,8 @@ def _status_worker(self): self.client.post_images(self.advertised_images) time.sleep(120) - def set_state(self, state): + def set_agent_state(self, state): + """Send the agent state to the server""" self.client.post_agent_data({"state": state}) self._state.value = state.encode("utf-8") @@ -93,9 +94,9 @@ def check_offline(self): possible_files = self.get_offline_files() for offline_file in possible_files: if os.path.exists(offline_file): - self.set_state("offline") + self.set_agent_state("offline") return offline_file - self.set_state("waiting") + self.set_agent_state("waiting") return "" def check_restart(self): @@ -105,7 +106,7 @@ def check_restart(self): try: os.unlink(restart_file) logger.info("Restarting agent") - self.set_state("offline") + self.set_agent_state("offline") raise SystemExit("Restart Requested") except OSError: logger.error( @@ -150,14 +151,8 @@ def process_jobs(self): if self.client.check_job_state(job.job_id) == "cancelled": logger.info("Job cancellation was requested, exiting.") break - # Try to update the job_state on the testflinger server - try: - self.client.post_result( - job.job_id, {"job_state": phase} - ) - except TFServerError: - pass - self.set_state(phase) + self.post_job_state(job.job_id, phase) + self.set_agent_state(phase) exitcode = job.run_test_phase(phase, rundir) # exit code 46 is our indication that recovery failed! @@ -188,7 +183,7 @@ def process_jobs(self): logger.exception(e) results_basedir = self.client.config.get("results_basedir") shutil.move(rundir, results_basedir) - self.set_state("waiting") + self.set_agent_state("waiting") self.check_restart() if self.check_offline(): @@ -196,6 +191,13 @@ def process_jobs(self): break job_data = self.client.check_jobs() + def post_job_state(self, job_id, phase): + """Update the job_state on the testflinger server""" + try: + self.client.post_result(job_id, {"job_state": phase}) + except TFServerError: + pass + def retry_old_results(self): """Retry sending results that we previously failed to send""" From 4389269fa8db57e420c5528b2386348e2a2e2a13 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 20 Mar 2023 17:08:40 -0500 Subject: [PATCH 448/569] create the remote tmpdir for limerick in case it's missing --- src/snappy_device_agents/devices/muxpi/muxpi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 79ef0d42..1e66220b 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -319,6 +319,7 @@ def create_user(self, image_type): try: data_path = Path(__file__).parent / "../../data" if image_type == "limerick": + self._run_control("mkdir -p {}".format(remote_tmp)) self._copy_to_control( data_path / "limerick/user-data", remote_tmp ) From 6ef85530e35cac6e8fe325cd7e84356e62eef711 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 21 Mar 2023 12:49:23 -0500 Subject: [PATCH 449/569] pylint cleanups on oemrecovery --- .../devices/oemrecovery/oemrecovery.py | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/snappy_device_agents/devices/oemrecovery/oemrecovery.py b/src/snappy_device_agents/devices/oemrecovery/oemrecovery.py index e98eb4b1..ba34dd1e 100644 --- a/src/snappy_device_agents/devices/oemrecovery/oemrecovery.py +++ b/src/snappy_device_agents/devices/oemrecovery/oemrecovery.py @@ -21,7 +21,6 @@ import yaml -from snappy_device_agents import CmdTimeoutError from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -32,12 +31,12 @@ class OemRecovery: """Device Agent for OEM Recovery.""" def __init__(self, config, job_data): - with open(config) as configfile: + with open(config, encoding="utf-8") as configfile: self.config = yaml.safe_load(configfile) - with open(job_data) as j: - self.job_data = json.load(j) + with open(job_data, encoding="utf-8") as job_json: + self.job_data = json.load(job_json) - def _run_device(self, cmd, timeout=60): + def run_on_control_host(self, cmd, timeout=60): """ Run a command on the control host over ssh @@ -46,7 +45,7 @@ def _run_device(self, cmd, timeout=60): :param timeout: Timeout (default 60) :returns: - Return output from the command, if any + returncode, stdout """ try: test_username = self.job_data.get("test_data", {}).get( @@ -60,16 +59,17 @@ def _run_device(self, cmd, timeout=60): "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", - "{}@{}".format(test_username, self.config["device_ip"]), + f"{test_username}@{self.config['device_ip']}", cmd, ] - try: - output = subprocess.check_output( - ssh_cmd, stderr=subprocess.STDOUT, timeout=timeout - ) - except subprocess.CalledProcessError as e: - raise ProvisioningError(e.output) - return output + proc = subprocess.run( + ssh_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + check=False, + ) + return proc.returncode, proc.stdout def provision(self): """Provision the device""" @@ -87,6 +87,7 @@ def provision(self): self.check_device_booted() def copy_ssh_id(self): + """Copy the ssh id to the device""" try: test_username = self.job_data.get("test_data", {}).get( "test_username", "ubuntu" @@ -106,11 +107,12 @@ def copy_ssh_id(self): "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", - "{}@{}".format(test_username, self.config["device_ip"]), + f"{test_username}@{self.config['device_ip']}", ] subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) def check_device_booted(self): + """Check to see if the device is booted and reachable with ssh""" logger.info("Checking to see if the device is available.") started = time.time() # Wait for provisioning to complete - can take a very long time @@ -119,7 +121,7 @@ def check_device_booted(self): time.sleep(90) self.copy_ssh_id() return True - except Exception: + except subprocess.SubprocessError: pass # If we get here, then we didn't boot in time agent_name = self.config.get("agent_name") @@ -140,9 +142,15 @@ def _run_cmd_list(self, cmdlist): for cmd in cmdlist: logger.info("Running %s", cmd) try: - output = self._run_device(cmd, timeout=600) - except CmdTimeoutError: - raise ProvisioningError("timeout reaching control host!") + return_code, output = self.run_on_control_host( + cmd, timeout=600 + ) + except subprocess.TimeoutExpired as exc: + raise ProvisioningError( + "timeout reaching control host!" + ) from exc + if return_code: + raise ProvisioningError(output) logger.info(output) def hardreset(self): @@ -160,5 +168,5 @@ def hardreset(self): logger.info("Running %s", cmd) try: subprocess.check_call(cmd.split(), timeout=120) - except Exception: - raise RecoveryError("timeout reaching control host!") + except subprocess.SubprocessError as exc: + raise RecoveryError("Error running reboot script!") from exc From 230c2391e5e9d0ad8cf903642edd8c7e9d9e5fc1 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 22 Mar 2023 14:33:27 -0500 Subject: [PATCH 450/569] Break out the sudo setup and also run it for limerick devices --- .../devices/muxpi/muxpi.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 79ef0d42..22a7ad4e 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -324,6 +324,7 @@ def create_user(self, image_type): ) cmd = f"sudo cp {remote_tmp}/user-data {base}/system-boot/" self._run_control(cmd) + self._configure_sudo() if image_type == "pi-desktop": # make a spot to scp files to self._run_control("mkdir -p {}".format(remote_tmp)) @@ -360,16 +361,7 @@ def create_user(self, image_type): ) self._run_control(cmd) - # Setup sudoers data - sudo_data = "ubuntu ALL=(ALL) NOPASSWD:ALL" - sudo_path = "{}/writable/etc/sudoers.d/ubuntu".format( - self.mount_point - ) - self._run_control( - "sudo bash -c \"echo '{}' > {}\"".format( - sudo_data, sudo_path - ) - ) + self._configure_sudo() return if image_type == "core20": base = self.mount_point / "ubuntu-seed" @@ -405,6 +397,14 @@ def create_user(self, image_type): except Exception: raise ProvisioningError("Error creating user files") + def _configure_sudo(self): + # Setup sudoers data + sudo_data = "ubuntu ALL=(ALL) NOPASSWD:ALL" + sudo_path = "{}/writable/etc/sudoers.d/ubuntu".format(self.mount_point) + self._run_control( + "sudo bash -c \"echo '{}' > {}\"".format(sudo_data, sudo_path) + ) + def check_test_image_booted(self): logger.info("Checking if test image booted.") started = time.time() From f6ffa84a5f9f54b8f4c70cc0f69831d6cce0877e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 23 Mar 2023 14:02:36 -0500 Subject: [PATCH 451/569] Change default logging level back to info --- src/snappy_device_agents/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/snappy_device_agents/__init__.py b/src/snappy_device_agents/__init__.py index 1528d7c3..a0b65010 100644 --- a/src/snappy_device_agents/__init__.py +++ b/src/snappy_device_agents/__init__.py @@ -277,9 +277,10 @@ def filter(self, record): return True logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(agent_name)s %(levelname)s: " "DEVICE AGENT: " - "%(message)s" + "%(message)s", ) agent_name = config.get("agent_name", "") logger.addFilter(AgentFilter(agent_name)) From 489003100df633a94124e5b81dce065bce40f4b2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 28 Mar 2023 20:40:47 -0500 Subject: [PATCH 452/569] Add oemscript device agent --- .../data/oemscript/recovery-from-iso.sh | 566 ++++++++++++++++++ .../devices/oemscript/__init__.py | 43 ++ .../devices/oemscript/oemscript.py | 213 +++++++ 3 files changed, 822 insertions(+) create mode 100755 src/snappy_device_agents/data/oemscript/recovery-from-iso.sh create mode 100644 src/snappy_device_agents/devices/oemscript/__init__.py create mode 100644 src/snappy_device_agents/devices/oemscript/oemscript.py diff --git a/src/snappy_device_agents/data/oemscript/recovery-from-iso.sh b/src/snappy_device_agents/data/oemscript/recovery-from-iso.sh new file mode 100755 index 00000000..d20543d5 --- /dev/null +++ b/src/snappy_device_agents/data/oemscript/recovery-from-iso.sh @@ -0,0 +1,566 @@ +#!/bin/bash +set -ex + +jenkins_job_for_iso="" +jenkins_job_build_no="lastSuccessfulBuild" +script_on_target_machine="inject_recovery_from_iso.sh" +additional_grub_for_ubuntu_recovery="99_ubuntu_recovery" +user_on_target="ubuntu" +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" +SSH="ssh $SSH_OPTS" +SCP="scp $SSH_OPTS" +#TAR="tar -C $temp_folder" +temp_folder="$(mktemp -d -p "$PWD")" +GIT="git -C $temp_folder" +ubuntu_release="" +enable_sb="no" + +enable_secureboot() { + if [ "${ubr}" != "yes" ] && [ "$enable_sb" = "yes" ]; then + ssh -o StrictHostKeyChecking=no "$user_on_target"@"$target_ip" sudo sb_fixup + ssh -o StrictHostKeyChecking=no "$user_on_target"@"$target_ip" sudo reboot + fi +} + +clear_all() { + rm -rf "$temp_folder" + # remove Ubiquity in the end to match factory and Stock Ubuntu image behavior. + # and it also workaround some debsum error from ubiquity. + ssh -o StrictHostKeyChecking=no "$user_on_target"@"$target_ip" sudo apt-get -o DPkg::Lock::Timeout=-1 purge -y ubiquity +} +trap clear_all EXIT +# shellcheck disable=SC2046 +eval set -- $(getopt -o "su:c:j:b:t:h" -l "local-iso:,sync,url:,jenkins-credential:,jenkins-job:,jenkins-job-build-no:,oem-share-url:,oem-share-credential:,target-ip:,ubr,enable-secureboot,inject-ssh-key:,help" -- "$@") + +usage() { + set +x +cat << EOF +Usage: + # This triggers sync job, downloads the image from oem-share, upload the + # image to target DUT, and starts recovery. + $(basename "$0") \\ + -s -u http://10.102.135.50:8080 \\ + -c JENKINS_USERNAME:JENKINS_CREDENTIAL \\ + -j dell-bto-jammy-jellyfish -b 17 \\ + --oem-share-url https://oem-share.canonical.com/share/lyoncore/jenkins/job \\ + --oem-share-credential OEM_SHARE_USERNAME:OEM_SHARE_PASSWORD \\ + -t 192.168.101.68 + + # This downloads the image from Jenkins, upload the image to target DUT, + # and starts recovery. + $(basename "$0") \\ + -u 10.101.46.50 \\ + -j dell-bto-jammy-jellyfish -b 17 \\ + -t 192.168.101.68 + + # This upload the image from local to target DUT, and starts recovery. + $(basename "$0") \\ + --local-iso ./dell-bto-jammy-jellyfish-X10-20220519-17.iso \\ + -t 192.168.101.68 + + # This upload the image from local to target DUT, and starts recovery. The + # image is using ubuntu-recovery. + $(basename "$0") \\ + --local-iso ./pc-stella-cmit-focal-amd64-X00-20210618-1563.iso \\ + --ubr -t 192.168.101.68 + +Limition: + It will failed when target recovery partition size smaller than target iso + file. + +The assumption of using this tool: + - An root account 'ubuntu' on target machine. + - The root account 'ubuntu' can execute command with root permission with + \`sudo\` without password. + - Host executing this tool can access target machine without password over ssh. + +OPTIONS: + --local-iso + Use local + + -s | --sync + Trigger sync job \`infrastructure-swift-client\` in Jenkins in --url, + then download image from --oem-share-url. + + -u | --url + URL of jenkins server. + + -c | --jenkins-credential + Jenkins credential in the form of username:password, used with --sync. + + -j | --jenkins-job + Get iso from jenkins-job. + + -b | --jenkins-job-build-no + The build number of the Jenkins job assigned by --jenkins-job. + + --oem-share-url + URL of oem-share, used with --sync. + + --oem-share-credential + Credential in the form of username:password of lyoncore, used with --sync. + + -t | --target-ip + The IP address of target machine. It will be used for ssh accessing. + Please put your ssh key on target machine. This tool no yet support + keyphase for ssh. + + --enable-secureboot + Enable Secure Boot. When this option is on, the script will not install + file that prevents turning on Secure Boot after installation. Only + effective with dell-recovery images that enables Secure Boot on + Somerville platforms. + + --ubr + DUT which using ubuntu recovery (volatile-task). + + --inject-ssh-key + Path to ssh key to inject into the target machine. + + -h | --help + Print this message +EOF + set -x +exit 1 +} + +download_preseed() { + echo " == download_preseed == " + if [ "${ubr}" == "yes" ]; then + if [ "$enable_sb" = "yes" ]; then + echo "error: --enable-secureboot does not apply to ubuntu-recovery images" + exit 1 + fi + # TODO: sync togother + # replace $GIT clone https://git.launchpad.net/~oem-solutions-engineers/pc-enablement/+git/oem-fix-misc-cnl-no-secureboot --depth 1 + # Why need it? + # reokace $GIT clone https://git.launchpad.net/~oem-solutions-engineers/pc-enablement/+git/oem-fix-misc-cnl-skip-storage-selecting --depth 1 + mkdir "$temp_folder/preseed/" + echo "# Ubuntu Recovery configuration preseed + +ubiquity ubuntu-oobe/user-interface string dynamic +ubiquity ubuntu-recovery/recovery_partition_filesystem string 0c +ubiquity ubuntu-recovery/active_partition string 1 +ubiquity ubuntu-recovery/dual_boot_layout string primary +ubiquity ubuntu-recovery/disk_layout string gpt +ubiquity ubuntu-recovery/swap string dynamic +ubiquity ubuntu-recovery/dual_boot boolean false +ubiquity ubiquity/reboot boolean true +ubiquity ubiquity/poweroff boolean false +ubiquity ubuntu-recovery/recovery_hotkey/partition_label string PQSERVICE +ubiquity ubuntu-recovery/recovery_type string dev +" | tee ubuntu-recovery.cfg + mv ubuntu-recovery.cfg "$temp_folder/preseed" + $SCP "$user_on_target"@"$target_ip":/cdrom/preseed/project.cfg ./ + sed -i 's%ubiquity/reboot boolean false%ubiquity/reboot boolean true%' ./project.cfg + sed -i 's%ubiquity/poweroff boolean true%ubiquity/poweroff boolean false%' ./project.cfg + mv project.cfg "$temp_folder/preseed" + + mkdir -p "$temp_folder/oem-fix-set-local-repo/scripts/chroot-scripts/fish/" + mkdir -p "$temp_folder/oem-fix-set-local-repo/scripts/chroot-scripts/os-post/" + cat < "$temp_folder/oem-fix-set-local-repo/scripts/chroot-scripts/fish/00-setup-local-repo" +#!/bin/bash -ex +# setup local repo +mkdir /tmp/cdrom_debs +apt-ftparchive packages /cdrom/debs > /tmp/cdrom_debs/Packages +echo 'deb [ trusted=yes ] file:/. /tmp/cdrom_debs/' >> /etc/apt/sources.list.d/$(basename "$0")_$$.list +sudo apt-get update +EOF + + cat < "$temp_folder/oem-fix-set-local-repo/scripts/chroot-scripts/os-post/99-remove-local-repo" +#!/bin/bash -ex +# remove local repo +rm -f /etc/apt/sources.list.d/$(basename "$0")_$$.list +sudo apt update +EOF + else + # get checkbox pkgs and prepare-checkbox + # get pkgs to skip OOBE + if [ "$enable_sb" = "yes" ]; then + $GIT clone https://git.launchpad.net/~oem-solutions-engineers/pc-enablement/+git/oem-fix-misc-cnl-install-sbhelper --depth 1 + else + $GIT clone https://git.launchpad.net/~oem-solutions-engineers/pc-enablement/+git/oem-fix-misc-cnl-no-secureboot --depth 1 + fi + $GIT clone https://git.launchpad.net/~oem-solutions-engineers/pc-enablement/+git/oem-fix-misc-cnl-skip-storage-selecting --depth 1 + fi + + # install packages related to skip oobe + skip_oobe_branch="master" + if [ -n "$ubuntu_release" ]; then + # set ubuntu_release to jammy or focal, depending on detected release + skip_oobe_branch="$ubuntu_release" + fi + $GIT clone https://git.launchpad.net/~oem-solutions-engineers/pc-enablement/+git/oem-fix-misc-cnl-skip-oobe --depth 1 -b "$skip_oobe_branch" + # get pkgs for ssh key and skip disk checking. + $GIT clone https://git.launchpad.net/~oem-solutions-engineers/pc-enablement/+git/oem-fix-misc-cnl-misc-for-automation --depth 1 misc_for_automation + + if [ "${ubr}" == "yes" ]; then + mkdir -p "$temp_folder"/preseed + cat < "$temp_folder/preseed/$additional_grub_for_ubuntu_recovery" +#!/bin/bash -e +source /usr/lib/grub/grub-mkconfig_lib +cat < "$temp_folder/preseed/set_env_for_ubuntu_recovery" +#!/bin/bash -ex +# replace the grub entry which ubuntu_recovery expected +recover_p=\$(lsblk -l | grep efi | cut -d ' ' -f 1 | sed 's/.$/2'/) +UUID_OF_RECOVERY_PARTITION=\$(ls -l /dev/disk/by-uuid/ | grep \$recover_p | awk '{print \$9}') +echo partition = \$UUID_OF_RECOVERY_PARTITION +sed -i "s/UUID_OF_RECOVERY_PARTITION/\$UUID_OF_RECOVERY_PARTITION/" push_preseed/preseed/$additional_grub_for_ubuntu_recovery +sudo rm -f /etc/grub.d/99_dell_recovery || true +chmod 766 push_preseed/preseed/$additional_grub_for_ubuntu_recovery +sudo cp push_preseed/preseed/$additional_grub_for_ubuntu_recovery /etc/grub.d/ + +# Force changing the recovery partition label to PQSERVICE for ubuntu-recovery +sudo fatlabel /dev/\$recover_p PQSERVICE +EOF + fi + + return 0 +} +push_preseed() { + echo " == download_preseed == " + $SSH "$user_on_target"@"$target_ip" rm -rf push_preseed + $SSH "$user_on_target"@"$target_ip" mkdir -p push_preseed + $SSH "$user_on_target"@"$target_ip" touch push_preseed/SUCCSS_push_preseed + $SSH "$user_on_target"@"$target_ip" sudo rm -f /cdrom/SUCCSS_push_preseed + + if [ "${ubr}" == "yes" ]; then + $SCP -r "$temp_folder/preseed" "$user_on_target"@"$target_ip":~/push_preseed || $SSH "$user_on_target"@"$target_ip" sudo rm -f push_preseed/SUCCSS_push_preseed + folders=( + "oem-fix-set-local-repo" + ) + else + folders=( + "oem-fix-misc-cnl-skip-storage-selecting" + ) + if [ "$enable_sb" = "yes" ]; then + folders+=("oem-fix-misc-cnl-install-sbhelper") + else + folders+=("oem-fix-misc-cnl-no-secureboot") + fi + fi + + folders+=("misc_for_automation" "oem-fix-misc-cnl-skip-oobe") + + for folder in "${folders[@]}"; do + tar -C "$temp_folder/$folder" -zcvf "$temp_folder/$folder".tar.gz . + $SCP "$temp_folder/$folder".tar.gz "$user_on_target"@"$target_ip":~ + $SSH "$user_on_target"@"$target_ip" tar -C push_preseed -zxvf "$folder".tar.gz || $SSH "$user_on_target"@"$target_ip" sudo rm -f push_preseed/SUCCSS_push_preseed + done + + $SSH "$user_on_target"@"$target_ip" sudo cp -r push_preseed/* /cdrom/ + return 0 +} +inject_preseed() { + echo " == inject_preseed == " + $SSH "$user_on_target"@"$target_ip" rm -rf /tmp/SUCCSS_inject_preseed + download_preseed && \ + push_preseed + $SCP "$user_on_target"@"$target_ip":/cdrom/SUCCSS_push_preseed "$temp_folder" || usage + + if [ "${ubr}" == "yes" ]; then + $SSH "$user_on_target"@"$target_ip" bash \$HOME/push_preseed/preseed/set_env_for_ubuntu_recovery || usage + fi + $SSH "$user_on_target"@"$target_ip" touch /tmp/SUCCSS_inject_preseed +} + +download_image() { + img_path=$1 + img_name=$2 + user=$3 + + MAX_RETRIES=10 + local retries=0 + + echo "downloading $img_name from $img_path" + curl_cmd=(curl --retry 3 --fail --show-error) + if [ -n "$user" ]; then + curl_cmd+=(--user "$user") + fi + + pushd "$temp_folder" + + while [ "$retries" -lt "$MAX_RETRIES" ]; do + ((retries+=1)) || true # arithmetics, see https://www.shellcheck.net/wiki/SC2219 + echo "Downloading checksum and image, tries $retries/$MAX_RETRIES" + "${curl_cmd[@]}" -O "$img_path/$img_name".md5sum || true + "${curl_cmd[@]}" -O "$img_path/$img_name" || true + if md5sum -c "$img_name".md5sum; then + break + fi + sleep 10; continue + done + + if [ "$retries" -ge "$MAX_RETRIES" ]; then + echo "error: max retries reached" + exit 1 + fi + + local_iso="$PWD/$img_name" + + popd +} + +download_from_jenkins() { + path="ftp://$jenkins_url/jenkins_host/jobs/$jenkins_job_for_iso/builds/$jenkins_job_build_no/archive/out" + img_name=$(wget -q "$path/" -O - | grep -o 'href=.*iso"' | awk -F/ '{print $NF}' | tr -d \") + download_image "$path" "$img_name" +} + +sync_to_swift() { + if [ -z "$jenkins_url" ] ; then + echo "error: --url not set" + exit 1 + elif [ -z "$jenkins_credential" ]; then + echo "error: --jenkins-credential not set" + exit 1 + elif [ -z "$jenkins_job_for_iso" ]; then + echo "error: --jenkins-job not set" + exit 1 + elif [ -z "$jenkins_job_build_no" ]; then + echo "error: --jenkins-job-build-no not set" + exit 1 + elif [ -z "$oem_share_url" ]; then + echo "error: --oem-share-url not set" + exit 1 + elif [ -z "$oem_share_credential" ]; then + echo "error: --oem-share-credential not set" + exit 1 + fi + + jenkins_job_name="infrastructure-swift-client" + jenkins_job_url="$jenkins_url/job/$jenkins_job_name/buildWithParameters" + curl_cmd=(curl --retry 3 --max-time 10 -sS) + headers_path="$temp_folder/build_request_headers" + + echo "sending build request" + "${curl_cmd[@]}" --user "$jenkins_credential" -X POST -D "$headers_path" "$jenkins_job_url" \ + --data option=sync \ + --data "jenkins_job=$jenkins_job_for_iso" \ + --data "build_no=$jenkins_job_build_no" + + echo "getting job id from queue" + queue_url=$(grep '^Location: ' "$headers_path" | awk '{print $2}' | tr -d '\r') + duration=0 + timeout=600 + url= + until [ -n "$timeout" ] && [[ $duration -ge $timeout ]]; do + url=$("${curl_cmd[@]}" --user "$jenkins_credential" "${queue_url}api/json" | jq -r '.executable | .url') + if [ "$url" != "null" ]; then + break + fi + sleep 5 + duration=$((duration+5)) + done + if [ "$url" = "null" ]; then + echo "error: sync job was not created in time" + exit 1 + fi + + echo "polling build status" + duration=0 + timeout=1800 + until [ -n "$timeout" ] && [[ $duration -ge $timeout ]]; do + result=$("${curl_cmd[@]}" --user "$jenkins_credential" "${url}api/json" | jq -r .result) + if [ "$result" = "SUCCESS" ]; then + break + fi + if [ "$result" = "FAILURE" ]; then + echo "error: sync job failed" + exit 1 + fi + sleep 30 + duration=$((duration+30)) + done + if [ "$result" != "SUCCESS" ]; then + echo "error: sync job has not been done in time" + exit 1 + fi + + oem_share_path="$oem_share_url/$jenkins_job_for_iso/$jenkins_job_build_no" + img_name=$(curl -sS --user "$oem_share_credential" "$oem_share_path/" | grep -o 'href=.*iso"' | tr -d \") + img_name=${img_name#"href="} + download_image "$oem_share_path" "$img_name" "$oem_share_credential" +} + +download_iso() { + if [ "$enable_sync_to_swift" = true ]; then + sync_to_swift + else + download_from_jenkins + fi +} + +inject_recovery_iso() { + if [ -z "$local_iso" ]; then + download_iso + fi + + img_name="$(basename "$local_iso")" + if [ -z "${img_name##*stella*}" ] || + [ -z "${img_name##*sutton*}" ]; then + ubr="yes" + fi + if [ -z "${img_name##*jammy*}" ]; then + ubuntu_release="jammy" + elif [ -z "${img_name##*focal*}" ]; then + ubuntu_release="focal" + fi + rsync_opts="--exclude=efi --delete --temp-dir=/var/tmp/rsync" + $SCP "$local_iso" "$user_on_target"@"$target_ip":~/ +cat < "$temp_folder/$script_on_target_machine" +#!/bin/bash +set -ex +sudo umount /cdrom /mnt || true +sudo mount -o loop $img_name /mnt && \ +recover_p=\$(lsblk -l | grep efi | cut -d ' ' -f 1 | sed 's/.$/2'/) && \ +sudo mount /dev/\$recover_p /cdrom && \ +df | grep "cdrom\|mnt" | awk '{print \$2" "\$6}' | sort | tail -n1 | grep -q cdrom && \ +sudo mkdir -p /var/tmp/rsync && \ +sudo rsync -alv /mnt/ /cdrom/ $rsync_opts && \ +sudo cp /mnt/.disk/ubuntu_dist_channel /cdrom/.disk/ && \ +touch /tmp/SUCCSS_inject_recovery_iso +EOF + $SCP "$temp_folder"/"$script_on_target_machine" "$user_on_target"@"$target_ip":~/ + $SSH "$user_on_target"@"$target_ip" chmod +x "\$HOME/$script_on_target_machine" + $SSH "$user_on_target"@"$target_ip" "\$HOME/$script_on_target_machine" + $SCP "$user_on_target"@"$target_ip":/tmp/SUCCSS_inject_recovery_iso "$temp_folder" || usage +} +prepare() { + echo "prepare" + inject_recovery_iso + inject_preseed +} + +inject_ssh_key() { + while(:); do + echo "Attempting to inject ssh key" + if [ "$(sshpass -p u ssh-copy-id $SSH_OPTS -f -i "$ssh_key" "$user_on_target@$target_ip")" ] ; then + break + fi + sleep 180 + done +} + +poll_recovery_status() { + while(:); do + if [ "$($SSH "$user_on_target"@"$target_ip" systemctl is-active ubiquity)" = "inactive" ] ; then + break + fi + sleep 180 + done +} + +do_recovery() { + if [ "${ubr}" == "yes" ]; then + echo GRUB_DEFAULT='"ubuntu-recovery restore"' | $SSH "$user_on_target"@"$target_ip" -T "sudo tee -a /etc/default/grub.d/automatic-oem-config.cfg" + echo GRUB_TIMEOUT_STYLE=menu | $SSH "$user_on_target"@"$target_ip" -T "sudo tee -a /etc/default/grub.d/automatic-oem-config.cfg" + echo GRUB_TIMEOUT=5 | $SSH "$user_on_target"@"$target_ip" -T "sudo tee -a /etc/default/grub.d/automatic-oem-config.cfg" + $SSH "$user_on_target"@"$target_ip" sudo update-grub + $SSH "$user_on_target"@"$target_ip" sudo reboot & + else + $SSH "$user_on_target"@"$target_ip" sudo dell-restore-system -y & + fi + sleep 300 # sleep to make sure the target system has been rebooted to recovery mode. + if [ -n "$ssh_key" ]; then + inject_ssh_key + fi + poll_recovery_status +} + +main() { + while [ $# -gt 0 ] + do + case "$1" in + --local-iso) + shift + local_iso="$1" + ;; + -s | --sync) + enable_sync_to_swift=true + ;; + -u | --url) + shift + jenkins_url="$1" + ;; + -c | --jenkins-credential) + shift + jenkins_credential="$1" + ;; + -j | --jenkins-job) + shift + jenkins_job_for_iso="$1" + ;; + -b | --jenkins-job-build-no) + shift + jenkins_job_build_no="$1" + ;; + --oem-share-url) + shift + oem_share_url="$1" + ;; + --oem-share-credential) + shift + oem_share_credential="$1" + ;; + -t | --target-ip) + shift + target_ip="$1" + ;; + --ubr) + ubr="yes" + ;; + --enable-secureboot) + enable_sb="yes" + ;; + --inject-ssh-key) + shift + ssh_key="$1" + ;; + -h | --help) + usage 0 + exit 0 + ;; + --) + ;; + *) + echo "Not recognize $1" + usage + exit 1 + ;; + esac + shift + done + prepare + do_recovery + clear_all + enable_secureboot +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/src/snappy_device_agents/devices/oemscript/__init__.py b/src/snappy_device_agents/devices/oemscript/__init__.py new file mode 100644 index 00000000..cf67ed95 --- /dev/null +++ b/src/snappy_device_agents/devices/oemscript/__init__.py @@ -0,0 +1,43 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu OEM Recovery provisioner support code.""" + +import logging + +import yaml + +import snappy_device_agents +from snappy_device_agents import logmsg +from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch +from snappy_device_agents.devices.oemscript.oemscript import OemScript + +device_name = "oemscript" + + +class DeviceAgent(DefaultDevice): + + """Tool for provisioning baremetal with a given image.""" + + @catch(RecoveryError, 46) + def provision(self, args): + """Method called when the command is invoked.""" + with open(args.config) as configfile: + config = yaml.safe_load(configfile) + snappy_device_agents.configure_logging(config) + device = OemScript(args.config, args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") diff --git a/src/snappy_device_agents/devices/oemscript/oemscript.py b/src/snappy_device_agents/devices/oemscript/oemscript.py new file mode 100644 index 00000000..59b2964f --- /dev/null +++ b/src/snappy_device_agents/devices/oemscript/oemscript.py @@ -0,0 +1,213 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu OEM Script Provisioner support code.""" + +import json +import logging +import os +from pathlib import Path +import subprocess +import time +import yaml + +from snappy_device_agents import download +from snappy_device_agents.devices import ProvisioningError, RecoveryError + +logger = logging.getLogger() + + +class OemScript: + + """Device Agent for OEM Script.""" + + def __init__(self, config, job_data): + with open(config, encoding="utf-8") as configfile: + self.config = yaml.safe_load(configfile) + with open(job_data, encoding="utf-8") as job_json: + self.job_data = json.load(job_json) + + def run_on_control_host(self, cmd, timeout=60): + """ + Run a command on the control host over ssh + + :param cmd: + Command to run + :param timeout: + Timeout (default 60) + :returns: + returncode, stdout + """ + try: + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + except AttributeError: + test_username = "ubuntu" + ssh_cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + f"{test_username}@{self.config['device_ip']}", + cmd, + ] + proc = subprocess.run( + ssh_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + check=False, + ) + return proc.returncode, proc.stdout + + def provision(self): + """Provision the device""" + + # First, ensure the device is online and reachable + try: + self.copy_ssh_id() + except subprocess.CalledProcessError: + self.hardreset() + self.check_device_booted() + + provision_data = self.job_data.get("provision_data", {}) + image_url = provision_data.get("url") + + # Download the .iso image from image_url + if not image_url: + logger.error( + "Please provide an image 'url' in the provision_data section" + ) + raise ProvisioningError("No image url provided") + image_file = download(image_url) + + self.run_recovery_script(image_file) + + self.check_device_booted() + + def run_recovery_script(self, image_file): + """Download and run the OEM recovery script""" + device_ip = self.config["device_ip"] + + data_path = Path(__file__).parent / "../../data/oemscript" + recovery_script = data_path / "recovery-from-iso.sh" + + # Run the recovery script + logger.info("Running recovery script") + cmd = [ + recovery_script, + "--local-iso", + image_file, + "--inject-ssh-key", + os.path.expanduser("~/.ssh/id_rsa.pub"), + "-t", + device_ip, + ] + proc = subprocess.run( + cmd, + timeout=60 * 60, # 1 hour - just in case + check=False, + ) + if proc.returncode: + logger.error( + "Recovery script failed with return code %s", proc.returncode + ) + raise ProvisioningError("Recovery script failed") + + def copy_ssh_id(self): + """Copy the ssh id to the device""" + try: + test_username = self.job_data.get("test_data", {}).get( + "test_username", "ubuntu" + ) + test_password = self.job_data.get("test_data", {}).get( + "test_password", "ubuntu" + ) + except AttributeError: + test_username = "ubuntu" + test_password = "ubuntu" + cmd = [ + "sshpass", + "-p", + test_password, + "ssh-copy-id", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + f"{test_username}@{self.config['device_ip']}", + ] + subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) + + def check_device_booted(self): + """Check to see if the device is booted and reachable with ssh""" + logger.info("Checking to see if the device is available.") + started = time.time() + # Wait for provisioning to complete - can take a very long time + while time.time() - started < 3600: + try: + time.sleep(90) + self.copy_ssh_id() + return True + except subprocess.SubprocessError: + pass + # If we get here, then we didn't boot in time + agent_name = self.config.get("agent_name") + logger.error( + "Device %s unreachable, provisioning" "failed!", agent_name + ) + raise ProvisioningError("Failed to boot test image!") + + def _run_cmd_list(self, cmdlist): + """ + Run a list of commands + + :param cmdlist: + List of commands to run + """ + if not cmdlist: + return + for cmd in cmdlist: + logger.info("Running %s", cmd) + try: + return_code, output = self.run_on_control_host( + cmd, timeout=600 + ) + except subprocess.TimeoutExpired as exc: + raise ProvisioningError( + "timeout reaching control host!" + ) from exc + if return_code: + raise ProvisioningError(output) + logger.info(output) + + def hardreset(self): + """ + Reboot the device. + + :raises RecoveryError: + If the command times out or anything else fails. + + .. note:: + This function runs the commands specified in 'reboot_script' + in the config yaml. + """ + for cmd in self.config["reboot_script"]: + logger.info("Running %s", cmd) + try: + subprocess.check_call(cmd.split(), timeout=120) + except subprocess.SubprocessError as exc: + raise RecoveryError("Error running reboot script!") from exc From 8d8c7fe4a0afdbec050ec5144cf6d9875d1ef188 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 28 Mar 2023 21:48:51 -0500 Subject: [PATCH 453/569] setuptools is weird --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3df69ab8..7d45b8ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ dependencies = [ snappy-device-agent = "snappy_device_agents.cmd:main" [tool.setuptools.package-data] -snappy_device_agents = ["snappy_device_agents/data/pi-desktop/*"] +snappy_device_agents = ["data/*"] +"snappy_device_agents.data.oemscript" = ["*.sh"] [tool.black] line-length = 79 From 72fad0c67afd4be8fe254f098f5632811368c4b6 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 28 Mar 2023 23:06:58 -0700 Subject: [PATCH 454/569] post buffer at once --- testflinger_agent/__init__.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index d1270ce0..0fd39e0b 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -90,17 +90,19 @@ def emit(self, record): def flush(self): """Flush and post buffer""" - for record in list(self.reqbuffer): - try: - self.session.post( - url=self.url, json=self.format(record), timeout=5 - ) - except (requests.RequestException, HTTPError) as error: - logger.debug(error) + # list conversion for atomic iteration + records = [record.getMessage() for record in list(self.reqbuffer)] - return # preserve buffer + try: + self.session.post( + url=self.url, json=self.format(records), timeout=5 + ) + except (requests.RequestException, HTTPError) as error: + logger.debug(error) - self.reqbuffer.popleft() + return # preserve buffer + + self.reqbuffer.clear() def close(self): """Cleanup on handler close""" @@ -110,8 +112,8 @@ def close(self): class ReqBufferFormatter(logging.Formatter): """Format logging messages""" - def format(self, record): - return {"log": [record.getMessage()]} + def format(self, records): + return {"log": records} def start_agent(): From 2a0fe46ada23ac3e0e17f618a3b881cdf0a0f1f2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 29 Mar 2023 10:46:25 -0500 Subject: [PATCH 455/569] Temporarily disable sending agent logs to the server --- testflinger_agent/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testflinger_agent/__init__.py b/testflinger_agent/__init__.py index 2bf2ac10..77c4eac0 100644 --- a/testflinger_agent/__init__.py +++ b/testflinger_agent/__init__.py @@ -172,6 +172,7 @@ def configure_logging(config): logger.addHandler(file_log) # requests logging # inherit from logger __name__ + """ DEBUG: Temporarily disable sending agent logs to the server req_logger = logging.getLogger() request_formatter = ReqBufferFormatter() request_handler = ReqBufferHandler( @@ -180,6 +181,7 @@ def configure_logging(config): request_handler.setFormatter(request_formatter) req_logger.addHandler(request_handler) req_logger.setLevel(log_level) + """ if not config.get("logging_quiet"): console_log = logging.StreamHandler() console_log.setFormatter(logfmt) From c41346109d6414b3d36a0596911d2a799674b66d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 29 Mar 2023 13:23:58 -0500 Subject: [PATCH 456/569] Revert "Remove the multiprocess cancellation watcher from process_jobs" This reverts commit 52bf12faba264601f7d92215534ce537b9af58e9. --- testflinger_agent/agent.py | 31 ++++++++++++- testflinger_agent/job.py | 71 ++++++++++++++--------------- testflinger_agent/tests/test_job.py | 5 -- 3 files changed, 62 insertions(+), 45 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index f7630418..441d4c83 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -153,7 +153,26 @@ def process_jobs(self): break self.post_job_state(job.job_id, phase) self.set_agent_state(phase) - exitcode = job.run_test_phase(phase, rundir) + proc = multiprocessing.Process( + target=job.run_test_phase, + args=( + phase, + rundir, + ), + ) + proc.start() + while proc.is_alive(): + proc.join(10) + if ( + self.client.check_job_state(job.job_id) + == "cancelled" + and phase != "provision" + ): + logger.info( + "Job cancellation was requested, exiting." + ) + proc.terminate() + exitcode = proc.exitcode # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline @@ -170,9 +189,17 @@ def process_jobs(self): logger.exception(e) finally: # Always run the cleanup, even if the job was cancelled - job.run_test_phase("cleanup", rundir) + proc = multiprocessing.Process( + target=job.run_test_phase, + args=( + "cleanup", + rundir, + ), + ) # clear job id self.client.post_agent_data({"job_id": ""}) + proc.start() + proc.join() try: self.client.transmit_job_outcome(rundir) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 7d1fa4f1..d1feae8d 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -16,6 +16,7 @@ import json import logging import os +import select import signal import sys import subprocess @@ -81,7 +82,7 @@ def run_test_phase(self, phase, rundir): self._update_phase_results( results_file, phase, exitcode, output_log, serial_log ) - return exitcode + sys.exit(exitcode) def _update_phase_results( self, results_file, phase, exitcode, output_log, serial_log @@ -152,6 +153,7 @@ def run_with_log(self, cmd, logfile, cwd=None): start_time = time.time() with open(logfile, "a", encoding="utf-8") as f: live_output_buffer = "" + readpoll = select.poll() buffer_timeout = time.time() process = subprocess.Popen( cmd, @@ -167,21 +169,19 @@ def cleanup(signum, frame): signal.signal(signal.SIGTERM, cleanup) set_nonblock(process.stdout.fileno()) - - while True: - line = process.stdout.readline() - if not line and process.poll() is not None: - # Process exited - break - - if line: - # Write the latest output to the log file, stdout, and - # the live output buffer - buf = line.decode(sys.stdout.encoding, errors="replace") - sys.stdout.write(buf) - live_output_buffer += buf - f.write(buf) - f.flush() + readpoll.register(process.stdout, select.POLLIN) + while process.poll() is None: + # Check if there's any new data, timeout after 10s + data_ready = readpoll.poll(10000) + if data_ready: + buf = process.stdout.read().decode( + sys.stdout.encoding, errors="replace" + ) + if buf: + sys.stdout.write(buf) + live_output_buffer += buf + f.write(buf) + f.flush() else: if ( self.phase == "test" @@ -189,22 +189,12 @@ def cleanup(signum, frame): ): buf = ( "\nERROR: Output timeout reached! " - f"({output_timeout}s)\n" + "({}s)\n".format(output_timeout) ) live_output_buffer += buf f.write(buf) process.kill() break - - # Check if it's time to send the output buffer to the server - if live_output_buffer and time.time() - buffer_timeout > 10: - if self.client.post_live_output( - self.job_id, live_output_buffer - ): - live_output_buffer = "" - buffer_timeout = time.time() - - # Check global timeout if ( self.phase != "reserve" and time.time() - start_time > global_timeout @@ -216,19 +206,24 @@ def cleanup(signum, frame): f.write(buf) process.kill() break - - # Check if job was canceled - if ( - self.client.check_job_state(self.job_id) == "cancelled" - and self.phase != "provision" - ): - logger.info("Job cancellation was requested, exiting.") - process.kill() - break - + # Don't spam the server, only flush the buffer if there + # is output and it's been more than 10s + if live_output_buffer and time.time() - buffer_timeout > 10: + buffer_timeout = time.time() + # Try to stream output, if we can't connect, then + # keep buffer for the next pass through this + if self.client.post_live_output( + self.job_id, live_output_buffer + ): + live_output_buffer = "" + buf = process.stdout.read() + if buf: + buf = buf.decode(sys.stdout.encoding, errors="replace") + sys.stdout.write(buf) + live_output_buffer += buf + f.write(buf) if live_output_buffer: self.client.post_live_output(self.job_id, live_output_buffer) - try: status = process.wait(10) # process.returncode except TimeoutError: diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 76dc3863..79d8b49e 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -61,7 +61,6 @@ def test_job_global_timeout(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"global_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) - requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" job.run_with_log("sleep 3", logfile) @@ -76,7 +75,6 @@ def test_config_global_timeout(self, client, requests_mock): self.config["global_timeout"] = 1 fake_job_data = {"global_timeout": 3} requests_mock.post(rmock.ANY, status_code=200) - requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" job.run_with_log("sleep 3", logfile) @@ -90,7 +88,6 @@ def test_job_output_timeout(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"output_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) - requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" # unfortunately, we need to sleep for longer that 10 seconds here @@ -107,7 +104,6 @@ def test_config_output_timeout(self, client, requests_mock): self.config["output_timeout"] = 1 fake_job_data = {"output_timeout": 30} requests_mock.post(rmock.ANY, status_code=200) - requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" # unfortunately, we need to sleep for longer that 10 seconds here @@ -123,7 +119,6 @@ def test_no_output_timeout_in_provision(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"output_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) - requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "provision" # unfortunately, we need to sleep for longer that 10 seconds here From c0d5fd82b5a397f1d9d14473033c74ac6087d076 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 31 Mar 2023 14:40:11 -0500 Subject: [PATCH 457/569] Inject allocate_data section into multi-agent sub-jobs --- .../devices/multi/multi.py | 14 ++++++ .../devices/multi/tests/test_multi.py | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/snappy_device_agents/devices/multi/tests/test_multi.py diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py index 11f45ae4..321fff97 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -87,6 +87,10 @@ def create_jobs(self): logger.info("Creating test jobs") for job in jobs_list: + if not isinstance(job, dict): + logger.error("Job is not a dict: %s", job) + continue + job = self.inject_allocate_data(job) try: job_id = self.client.submit_job(job) except OSError as exc: @@ -99,6 +103,16 @@ def create_jobs(self): logger.info("Created job %s", job_id) self.jobs.append(job_id) + def inject_allocate_data(self, job): + """Inject the allocate_data section into the job + + :param job: the job to inject the allocate data into + :returns: the job with the allocate_data injected + """ + allocate_data = {"allocate_data": {"allocate": True}} + job.update(allocate_data) + return job + def cancel_jobs(self): """Try to cancel any jobs that were created""" for job in self.jobs: diff --git a/src/snappy_device_agents/devices/multi/tests/test_multi.py b/src/snappy_device_agents/devices/multi/tests/test_multi.py new file mode 100644 index 00000000..5b97e723 --- /dev/null +++ b/src/snappy_device_agents/devices/multi/tests/test_multi.py @@ -0,0 +1,44 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Unit tests for multi-device support code.""" + +from uuid import uuid4 +from snappy_device_agents.devices.multi.multi import Multi +from snappy_device_agents.devices.multi.tfclient import TFClient + + +class MockTFClient(TFClient): + """Mock TFClient object""" + + def submit_job(self, job_data): + """Return a fake job id""" + return str(uuid4()) + + +def test_inject_allocate_data(): + """Test that allocate_data section is injected into job""" + test_config = {"agent_name": "test_agent"} + job_data = { + "provision_data": { + "jobs": [ + {"job_id": "1"}, + {"job_id": "2"}, + ] + } + } + test_agent = Multi(test_config, job_data, MockTFClient("http://localhost")) + test_agent.create_jobs() + for job in test_agent.job_data["provision_data"]["jobs"]: + assert job["allocate_data"]["allocate"] is True From d95d35693c164f1cc98896852df4b9dd2e0330dd Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 4 Apr 2023 20:50:00 -0500 Subject: [PATCH 458/569] Add allocate subcommand for all devices --- src/snappy_device_agents/cmd.py | 1 + src/snappy_device_agents/devices/__init__.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/snappy_device_agents/cmd.py b/src/snappy_device_agents/cmd.py index a8aaa916..7a0862bc 100755 --- a/src/snappy_device_agents/cmd.py +++ b/src/snappy_device_agents/cmd.py @@ -40,6 +40,7 @@ def main(): for cmd, func in ( ("provision", dev_module.provision), ("runtest", dev_module.runtest), + ("allocate", dev_module.allocate), ("reserve", dev_module.reserve), ): cmd_parser = cmd_subparser.add_parser(cmd) diff --git a/src/snappy_device_agents/devices/__init__.py b/src/snappy_device_agents/devices/__init__.py index 53698ab4..dea66d89 100644 --- a/src/snappy_device_agents/devices/__init__.py +++ b/src/snappy_device_agents/devices/__init__.py @@ -13,6 +13,7 @@ # along with this program. If not, see import imp +import json import logging import multiprocessing import os @@ -138,6 +139,16 @@ def runtest(self, args): snappy_device_agents.logmsg(logging.INFO, "END testrun") return exitcode + def allocate(self, args): + """Default method for allocating devices for multi-agent jobs""" + with open(args.config) as configfile: + config = yaml.safe_load(configfile) + device_ip = config["device_ip"] + device_info = {"device_ip": device_ip} + print(device_info) + with open("device-info.json", "w", encoding="utf-8") as devinfo_file: + devinfo_file.write(json.dumps(device_info)) + def reserve(self, args): """Default method for reserving systems""" with open(args.config) as configfile: From bf36aed163bb451ed50a6b753686d98a14469479 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 5 Apr 2023 08:06:08 -0500 Subject: [PATCH 459/569] Move post-job-state to client --- testflinger_agent/agent.py | 9 +-------- testflinger_agent/client.py | 7 +++++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 441d4c83..ffa1d04a 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -151,7 +151,7 @@ def process_jobs(self): if self.client.check_job_state(job.job_id) == "cancelled": logger.info("Job cancellation was requested, exiting.") break - self.post_job_state(job.job_id, phase) + self.client.post_job_state(job.job_id, phase) self.set_agent_state(phase) proc = multiprocessing.Process( target=job.run_test_phase, @@ -218,13 +218,6 @@ def process_jobs(self): break job_data = self.client.check_jobs() - def post_job_state(self, job_id, phase): - """Update the job_state on the testflinger server""" - try: - self.client.post_result(job_id, {"job_state": phase}) - except TFServerError: - pass - def retry_old_results(self): """Retry sending results that we previously failed to send""" diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 1ce004ae..cc5d7216 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -109,6 +109,13 @@ def repost_job(self, job_data): ) raise TFServerError(job_request.status_code) + def post_job_state(self, job_id, phase): + """Update the job_state on the testflinger server""" + try: + self.post_result(job_id, {"job_state": phase}) + except TFServerError: + pass + def post_result(self, job_id, data): """Post data to the testflinger server result for this job From 9f07441ec1059bdb21075e21b23d0cbf11d400f2 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 4 Apr 2023 20:59:06 -0500 Subject: [PATCH 460/569] Run the allocate phase from the agent --- testflinger_agent/agent.py | 2 +- testflinger_agent/job.py | 33 +++++++++++++++++++++++++++++++++ testflinger_agent/schema.py | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index ffa1d04a..3d69c9be 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -120,7 +120,7 @@ def mark_device_offline(self): def process_jobs(self): """Coordinate checking for new jobs and handling them if they exists""" - TEST_PHASES = ["setup", "provision", "test", "reserve"] + TEST_PHASES = ["setup", "provision", "test", "allocate", "reserve"] # First, see if we have any old results that we couldn't send last time self.retry_old_results() diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index d1feae8d..e74522d4 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -22,6 +22,8 @@ import subprocess import time +from testflinger_agent.errors import TFServerError + logger = logging.getLogger(__name__) @@ -61,6 +63,8 @@ def run_test_phase(self, phase, rundir): if phase == "test" and not self.job_data.get("test_data"): logger.info("No test_data defined in job data, skipping...") return 0 + if phase == "allocate" and not self.job_data.get("allocate_data"): + return 0 if phase == "reserve" and not self.job_data.get("reserve_data"): return 0 results_file = os.path.join(rundir, "testflinger-outcome.json") @@ -82,6 +86,8 @@ def run_test_phase(self, phase, rundir): self._update_phase_results( results_file, phase, exitcode, output_log, serial_log ) + if phase == "allocate": + self.allocate_phase(rundir) sys.exit(exitcode) def _update_phase_results( @@ -114,6 +120,33 @@ def _update_phase_results( results.seek(0) json.dump(outcome_data, results) + def allocate_phase(self, rundir): + """ + Read the json dict from "device-info.json" and send it to the server + so that the multi-device agent can find the IP addresses of all + subordinate jobs + """ + device_info_file = os.path.join(rundir, "device-info.json") + with open(device_info_file, "r") as f: + device_info = json.load(f) + + # The allocated state MUST be reflected on the server or the multi- + # device job can't continue + while True: + try: + self.client.post_result(self.job_id, device_info) + break + except TFServerError: + logger.warning("Failed to post device_info, retrying...") + time.sleep(60) + + self.client.post_job_state(self.job_id, "allocated") + + # For now, we can wait with signal.pause() because another thread is + # monitoring for completion. But if this changes, we'll need to watch + # for changes to our own job state + signal.pause() + def _set_truncate(self, f, size=1024 * 1024): """Set up an open file so that we don't read more than a specified size. We want to read from the end of the file rather than the diff --git a/testflinger_agent/schema.py b/testflinger_agent/schema.py index 36d7720e..f429686f 100644 --- a/testflinger_agent/schema.py +++ b/testflinger_agent/schema.py @@ -34,6 +34,7 @@ voluptuous.Required("setup_command", default=""): str, voluptuous.Required("provision_command", default=""): str, voluptuous.Required("test_command", default=""): str, + voluptuous.Required("allocate_command", default=""): str, voluptuous.Required("reserve_command", default=""): str, voluptuous.Required("cleanup_command", default=""): str, voluptuous.Optional("provision_type"): str, From 941fa6282d5c6a784ea7795c04821ec13ff9ea0b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 5 Apr 2023 17:54:07 -0500 Subject: [PATCH 461/569] Add cleanup subcommand --- src/snappy_device_agents/cmd.py | 1 + src/snappy_device_agents/devices/__init__.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/snappy_device_agents/cmd.py b/src/snappy_device_agents/cmd.py index 7a0862bc..7c58a45a 100755 --- a/src/snappy_device_agents/cmd.py +++ b/src/snappy_device_agents/cmd.py @@ -42,6 +42,7 @@ def main(): ("runtest", dev_module.runtest), ("allocate", dev_module.allocate), ("reserve", dev_module.reserve), + ("cleanup", dev_module.cleanup), ): cmd_parser = cmd_subparser.add_parser(cmd) cmd_parser.add_argument( diff --git a/src/snappy_device_agents/devices/__init__.py b/src/snappy_device_agents/devices/__init__.py index dea66d89..edca98b2 100644 --- a/src/snappy_device_agents/devices/__init__.py +++ b/src/snappy_device_agents/devices/__init__.py @@ -242,6 +242,10 @@ def reserve(self, args): ) time.sleep(int(timeout)) + def cleanup(self, _): + """Default method for cleaning up devices""" + pass + def catch(exception, returnval=0): """Decorator for catching Exceptions and returning values instead From 90261cd1fe50e2215eede36db00e1fd14faecc99 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 5 Apr 2023 17:56:04 -0500 Subject: [PATCH 462/569] Transmit and use device_info gathered during the allocate phase for multi-agent jobs --- src/snappy_device_agents/devices/__init__.py | 2 +- .../devices/multi/__init__.py | 89 +++++++++++++++++-- .../devices/multi/multi.py | 43 +++++++-- .../devices/multi/tfclient.py | 20 ++++- 4 files changed, 136 insertions(+), 18 deletions(-) diff --git a/src/snappy_device_agents/devices/__init__.py b/src/snappy_device_agents/devices/__init__.py index edca98b2..7fe1b11e 100644 --- a/src/snappy_device_agents/devices/__init__.py +++ b/src/snappy_device_agents/devices/__init__.py @@ -144,7 +144,7 @@ def allocate(self, args): with open(args.config) as configfile: config = yaml.safe_load(configfile) device_ip = config["device_ip"] - device_info = {"device_ip": device_ip} + device_info = {"device_info": {"device_ip": device_ip}} print(device_info) with open("device-info.json", "w", encoding="utf-8") as devinfo_file: devinfo_file.write(json.dumps(device_info)) diff --git a/src/snappy_device_agents/devices/multi/__init__.py b/src/snappy_device_agents/devices/multi/__init__.py index 5a4d0b34..2b1fa0d5 100644 --- a/src/snappy_device_agents/devices/multi/__init__.py +++ b/src/snappy_device_agents/devices/multi/__init__.py @@ -22,6 +22,7 @@ from snappy_device_agents import logmsg from snappy_device_agents.devices import ( DefaultDevice, + SerialLogger, ) from snappy_device_agents.devices.multi.multi import Multi from snappy_device_agents.devices.multi.tfclient import TFClient @@ -33,17 +34,87 @@ class DeviceAgent(DefaultDevice): """Device Agent for provisioning multiple devices at the same time""" - def provision(self, args): - """Method called when the command is invoked.""" + def init_device(self, args): + """Read config data and initialize the device object.""" with open(args.config, encoding="utf-8") as configfile: - config = yaml.safe_load(configfile) - with open(args.job_data, encoding="utf-8") as jobfile: - job_data = json.load(jobfile) - snappy_device_agents.configure_logging(config) - testflinger_server = config.get("testflinger_server") + self.config = yaml.safe_load(configfile) + self.job_data = snappy_device_agents.get_test_opportunity( + args.job_data + ) + snappy_device_agents.configure_logging(self.config) + testflinger_server = self.config.get("testflinger_server") tfclient = TFClient(testflinger_server) - device = Multi(config, job_data, tfclient) + self.device = Multi(self.config, self.job_data, tfclient) + + def provision(self, args): + """Method called when the command is invoked.""" + self.init_device(args) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") - device.provision() + self.device.provision() logmsg(logging.INFO, "END provision") + + def runtest(self, args): + """ + The runtest method for multi-device agents + + This is slightly different from the generic one because we also need + to import the job_list.json data and inject the device_ip for each + device into the environment + """ + self.init_device(args) + + logmsg(logging.INFO, "BEGIN testrun") + + test_cmds = self.job_data.get("test_data").get("test_cmds") + serial_host = self.config.get("serial_host") + serial_port = self.config.get("serial_port") + serial_proc = SerialLogger(serial_host, serial_port, "test-serial.log") + serial_proc.start() + + # Inject the IPs for each device into the environment + extra_env = self.get_device_ip_dict() + if "env" not in self.config: + self.config["env"] = {} + self.config["env"].update(extra_env) + + try: + exitcode = snappy_device_agents.run_test_cmds( + test_cmds, self.config + ) + except Exception as e: + raise e + finally: + serial_proc.stop() + snappy_device_agents.logmsg(logging.INFO, "END testrun") + return exitcode + + def get_job_list_data(self): + """Read job_list.json and return the data""" + with open("job_list.json") as job_list_file: + job_list_data = json.load(job_list_file) + return job_list_data + + def get_device_ip_dict(self): + """ + Read job_list.json and return a dict of device IPs like this that + can be used in the environment for the test commands: + { + "DEVICE_IP_1": "10.1.1.1", + "DEVICE_IP_2": "10.1.1.2" + } + """ + job_list_data = self.get_job_list_data() + device_ip_dict = {} + for i, job in enumerate(job_list_data): + key = "DEVICE_IP_{}".format(i + 1) + value = job.get("device_info", {}).get("device_ip") + device_ip_dict[key] = value + return device_ip_dict + + def cleanup(self, args): + """Cancel all subordinates jobs before finishing the multi-agent job""" + self.init_device(args) + job_list_data = self.get_job_list_data() + job_id_list = [job.get("job_id") for job in job_list_data] + self.device.cancel_jobs(job_id_list) diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py index 321fff97..5ad55b34 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -14,6 +14,7 @@ """Ubuntu multi-device support code.""" +import json import logging import os import time @@ -66,15 +67,45 @@ def provision(self): "Job %s failed to allocate, cancelling remaining jobs", job, ) - self.cancel_jobs() + self.cancel_jobs(self.jobs) raise ProvisioningError("Unable to allocate all devices") # Timeout if we've been waiting too long for devices to allocate if time.time() - start_time > allocation_timeout: - self.cancel_jobs() + self.cancel_jobs(self.jobs) raise ProvisioningError( "Timed out waiting for devices to allocate" ) + self.save_job_list_file() + + def save_job_list_file(self): + """ + Retrieve results for each job from the server, and look at the + "device_info" in each one to get the IP of the device, then + create a job_list.json file with a list of jobs that looks like: + [ + { + "job_id": "1234", + "device_info": { + "device_ip": "10.1.1.1" + } + }, + ... + ] + This file gets used by other steps that also need this information + """ + job_list = [] + for job in self.jobs: + device_info = self.client.get_results(job).get("device_info") + job_list.append( + { + "job_id": job, + "device_info": device_info, + } + ) + with open("job_list.json", "w") as json_file: + json.dump(job_list, json_file) + def create_jobs(self): """Create the jobs for the multi-device agent""" jobs_list = self.job_data.get("provision_data", {}).get("jobs") @@ -95,7 +126,7 @@ def create_jobs(self): job_id = self.client.submit_job(job) except OSError as exc: logger.exception("Unable to create job: %s", job_id) - self.cancel_jobs() + self.cancel_jobs(self.jobs) raise ProvisioningError( f"Unable to create job: {job_id}" ) from exc @@ -113,9 +144,9 @@ def inject_allocate_data(self, job): job.update(allocate_data) return job - def cancel_jobs(self): - """Try to cancel any jobs that were created""" - for job in self.jobs: + def cancel_jobs(self, jobs): + """Cancel all jobs in the specified list of job_ids""" + for job in jobs: try: self.client.cancel_job(job) except OSError: diff --git a/src/snappy_device_agents/devices/multi/tfclient.py b/src/snappy_device_agents/devices/multi/tfclient.py index 12d71b28..5b1daf6f 100644 --- a/src/snappy_device_agents/devices/multi/tfclient.py +++ b/src/snappy_device_agents/devices/multi/tfclient.py @@ -95,7 +95,7 @@ def get_status(self, job_id): :param job_id: ID for the test job :return: - String containing the job_state for the specified ID + String containing the job_state for the specified job_id (waiting, setup, provision, test, reserved, released, cancelled, complete) """ @@ -108,6 +108,22 @@ def get_status(self, job_id): state = "unknown" return state + def get_results(self, job_id): + """Get the results of a test job + + :param job_id: + ID for the test job + :return: + dict containing the results for the specified job_id + """ + try: + endpoint = f"/v1/result/{job_id}" + data = json.loads(self.get(endpoint)) + except OSError: + logger.exception("Unable to get results for job %s", job_id) + data = {} + return data + def submit_job(self, job_data): """Submit a test job to the testflinger server @@ -121,7 +137,7 @@ def submit_job(self, job_data): return json.loads(response).get("job_id") def cancel_job(self, job_id): - """Tell the server to cancel a specified JOB_ID""" + """Tell the server to cancel a specified job_id""" try: self.post(f"/v1/job/{job_id}/action", {"action": "cancel"}) except OSError: From 09454bd19b21eb79176af28c507b816f19f8c267 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 7 Apr 2023 09:54:31 -0500 Subject: [PATCH 463/569] Get output when confirming tpm clear sent --- src/snappy_device_agents/devices/maas2/maas2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index c7f8bbb9..d1ff8e74 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -212,7 +212,9 @@ def _run_tpm_clear_cmd(self): "ubuntu@{}".format(self.config["device_ip"]), "cat /sys/class/tpm/tpm0/ppi/request", ] - proc = subprocess.run(cmd, timeout=30, check=False) + proc = subprocess.run( + cmd, timeout=30, capture_output=True, check=False + ) if proc.returncode: return False From ba4704010ae264a775aafbed0d91b3f978baee32 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 13 Apr 2023 02:08:58 -0700 Subject: [PATCH 464/569] implement agent data importation into influxdb --- testflinger_agent/agent.py | 8 ++++-- testflinger_agent/client.py | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 3d69c9be..32b7b12d 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -120,7 +120,7 @@ def mark_device_offline(self): def process_jobs(self): """Coordinate checking for new jobs and handling them if they exists""" - TEST_PHASES = ["setup", "provision", "test", "allocate", "reserve"] + TEST_PHASES = ["setup", "provision", "test", "reserve"] # First, see if we have any old results that we couldn't send last time self.retry_old_results() @@ -153,6 +153,7 @@ def process_jobs(self): break self.client.post_job_state(job.job_id, phase) self.set_agent_state(phase) + self.client.post_influx(job.job_id, phase=phase) proc = multiprocessing.Process( target=job.run_test_phase, args=( @@ -161,6 +162,7 @@ def process_jobs(self): ), ) proc.start() + start_time = time.time() while proc.is_alive(): proc.join(10) if ( @@ -173,7 +175,6 @@ def process_jobs(self): ) proc.terminate() exitcode = proc.exitcode - # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline if exitcode == 46: @@ -188,6 +189,9 @@ def process_jobs(self): except Exception as e: logger.exception(e) finally: + end_time = time.time() + duration = end_time - start_time + self.client.post_influx(job.job_id, duration=duration) # Always run the cleanup, even if the job was cancelled proc = multiprocessing.Process( target=job.run_test_phase, diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index cc5d7216..560870df 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -19,6 +19,7 @@ import shutil import tempfile import time +import influxdb from urllib.parse import urljoin from requests.adapters import HTTPAdapter @@ -39,6 +40,8 @@ def __init__(self, config): if not self.server.lower().startswith("http"): self.server = "http://" + self.server self.session = self._requests_retry(retries=5) + self.influx_agent_db = "agent_jobs" + self.influx_client = self._configure_influx() def _requests_retry(self, retries=3): session = requests.Session() @@ -55,6 +58,23 @@ def _requests_retry(self, retries=3): session.mount("https://", adapter) return session + def _configure_influx(self): + host = os.environ.get("INFLUX_HOST") + port = os.environ.get("INFLUX_PORT", 8086) + user = os.environ.get("INFLUX_USER") + password = os.environ.get("INFLUX_PW") + + try: + # check if we've exports env vars successfully + influx_client = influxdb.InfluxDBClient( + host, int(port), user, password, self.influx_agent_db + ) + except influxdb.exceptions.InfluxDBClientError as exc: + logger.error(exc) + return None + + return influx_client + def check_jobs(self): """Check for new jobs for on the Testflinger server @@ -274,3 +294,34 @@ def post_agent_data(self, data): self.session.post(agent_data_url, json=data, timeout=30) except RequestException as exc: logger.error(exc) + + def post_influx(self, job_id, phase=None, duration=None, result=None): + if phase: + measurement = "job phase" + fields = {"job id": job_id, "job phase": phase} + if duration: + measurement = "phase duration" + fields = {"job id": job_id, "duration": duration} + if result: + measurement = "job result" + fields = {"job_id": job_id, "result": result} + + data = [ + { + "measurement": measurement, + "tags": {"agent": self.config.get("agent_id")}, + "fields": fields, + "time": int(time.time()), + }, + ] + + if self.influx_client: + try: + self.influx_client.write_points( + data, + database=self.influx_agent_db, + time_precision="s", + protocol="json", + ) + except influxdb.exceptions.InfluxDBClientError: + pass From 0b03438a80b6fea44b50a4ca2c5054d0d646b9a0 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 13 Apr 2023 02:16:41 -0700 Subject: [PATCH 465/569] implement agent data importation into influxdb --- testflinger_agent/agent.py | 3 ++- testflinger_agent/tests/test_agent.py | 1 + testflinger_agent/tests/test_client.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 32b7b12d..e2c3bf67 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -120,7 +120,7 @@ def mark_device_offline(self): def process_jobs(self): """Coordinate checking for new jobs and handling them if they exists""" - TEST_PHASES = ["setup", "provision", "test", "reserve"] + TEST_PHASES = ["setup", "provision", "test", "allocate", "reserve"] # First, see if we have any old results that we couldn't send last time self.retry_old_results() @@ -175,6 +175,7 @@ def process_jobs(self): ) proc.terminate() exitcode = proc.exitcode + # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline if exitcode == 46: diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index eade3645..07118aa6 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -5,6 +5,7 @@ import uuid import requests_mock as rmock import pytest +import influxdb from mock import patch diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index e7fd3a67..912a384e 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -14,6 +14,7 @@ import pytest import uuid +import influxdb import requests_mock as rmock From 04786e2a4aa69b9b95053f46513dab208813c996 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 13 Apr 2023 02:20:12 -0700 Subject: [PATCH 466/569] import and del influxdb module to address unit tests (potentially temp) --- testflinger_agent/tests/test_agent.py | 2 ++ testflinger_agent/tests/test_client.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 07118aa6..38adff7e 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -14,6 +14,8 @@ from testflinger_agent.client import TestflingerClient as _TestflingerClient from testflinger_agent.agent import TestflingerAgent as _TestflingerAgent +del influxdb + class TestClient: @pytest.fixture diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 912a384e..3e03a07c 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -20,6 +20,8 @@ from testflinger_agent.client import TestflingerClient as _TestflingerClient +del influxdb + class TestClient: @pytest.fixture From d5c7ae774f4d4e742e17fa48a3f0e38676a35686 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 13 Apr 2023 12:27:38 -0700 Subject: [PATCH 467/569] remove unnecessary unit test module importations --- testflinger_agent/tests/test_agent.py | 3 --- testflinger_agent/tests/test_client.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index 38adff7e..eade3645 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -5,7 +5,6 @@ import uuid import requests_mock as rmock import pytest -import influxdb from mock import patch @@ -14,8 +13,6 @@ from testflinger_agent.client import TestflingerClient as _TestflingerClient from testflinger_agent.agent import TestflingerAgent as _TestflingerAgent -del influxdb - class TestClient: @pytest.fixture diff --git a/testflinger_agent/tests/test_client.py b/testflinger_agent/tests/test_client.py index 3e03a07c..e7fd3a67 100644 --- a/testflinger_agent/tests/test_client.py +++ b/testflinger_agent/tests/test_client.py @@ -14,14 +14,11 @@ import pytest import uuid -import influxdb import requests_mock as rmock from testflinger_agent.client import TestflingerClient as _TestflingerClient -del influxdb - class TestClient: @pytest.fixture From 00e2484a4c12b50ff9a0609cef5a8b49de7a3361 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Sun, 16 Apr 2023 23:48:24 -0700 Subject: [PATCH 468/569] actually check influx env vars & add influxdb to pyprojectt.toml requirements --- pyproject.toml | 1 + testflinger_agent/client.py | 37 +++++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 337ffd4a..3f51fac8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "PyYAML", "requests", "voluptuous", + "influxdb", ] dynamic = ["version"] diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 560870df..3d6a08e6 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -59,21 +59,30 @@ def _requests_retry(self, retries=3): return session def _configure_influx(self): + """Configure InfluxDB client using environment variables. + + :return: InfluxDBClient object + """ + # required influxdb env vars + req_env_vars = ["INFLUX_HOST", "INFLUX_USER", "INFLUX_PW"] + + if not all(var in os.environ for var in req_env_vars): + return + host = os.environ.get("INFLUX_HOST") - port = os.environ.get("INFLUX_PORT", 8086) + port = int(os.environ.get("INFLUX_PORT", 8086)) user = os.environ.get("INFLUX_USER") password = os.environ.get("INFLUX_PW") try: - # check if we've exports env vars successfully + # check if we've exported env vars successfully influx_client = influxdb.InfluxDBClient( - host, int(port), user, password, self.influx_agent_db + host, port, user, password, self.influx_agent_db ) except influxdb.exceptions.InfluxDBClientError as exc: logger.error(exc) - return None - - return influx_client + else: + return influx_client def check_jobs(self): """Check for new jobs for on the Testflinger server @@ -296,20 +305,28 @@ def post_agent_data(self, data): logger.error(exc) def post_influx(self, job_id, phase=None, duration=None, result=None): + """Post the relevant data points to testflinger server + + :param data: + dict of various agent data points to send to the api server + """ if phase: measurement = "job phase" - fields = {"job id": job_id, "job phase": phase} + fields = {"job phase": phase} if duration: measurement = "phase duration" - fields = {"job id": job_id, "duration": duration} + fields = {"duration": duration} if result: measurement = "job result" - fields = {"job_id": job_id, "result": result} + fields = {"result": result} data = [ { "measurement": measurement, - "tags": {"agent": self.config.get("agent_id")}, + "tags": { + "agent": self.config.get("agent_id"), + "job_id": job_id, + }, "fields": fields, "time": int(time.time()), }, From 5dfac0f8475bc4fc74ea71ee7a548c17b47de235 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Sun, 16 Apr 2023 23:51:53 -0700 Subject: [PATCH 469/569] remove try block around influx_client constructor --- testflinger_agent/client.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 3d6a08e6..23bed004 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -74,15 +74,11 @@ def _configure_influx(self): user = os.environ.get("INFLUX_USER") password = os.environ.get("INFLUX_PW") - try: - # check if we've exported env vars successfully - influx_client = influxdb.InfluxDBClient( - host, port, user, password, self.influx_agent_db - ) - except influxdb.exceptions.InfluxDBClientError as exc: - logger.error(exc) - else: - return influx_client + influx_client = influxdb.InfluxDBClient( + host, port, user, password, self.influx_agent_db + ) + + return influx_client def check_jobs(self): """Check for new jobs for on the Testflinger server From 01e93bc3da267e22522ab6982195237695503822 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 17 Apr 2023 00:21:06 -0700 Subject: [PATCH 470/569] post phase, duration, result at once againt agent, job_id --- testflinger_agent/agent.py | 13 +++++++++---- testflinger_agent/client.py | 26 ++++++++++---------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index e2c3bf67..f68717a7 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -153,7 +153,6 @@ def process_jobs(self): break self.client.post_job_state(job.job_id, phase) self.set_agent_state(phase) - self.client.post_influx(job.job_id, phase=phase) proc = multiprocessing.Process( target=job.run_test_phase, args=( @@ -176,6 +175,15 @@ def process_jobs(self): proc.terminate() exitcode = proc.exitcode + end_time = time.time() + duration = end_time - start_time + self.client.post_influx( + job.job_id, + phase=phase, + duration=duration, + result=exitcode, + ) + # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline if exitcode == 46: @@ -190,9 +198,6 @@ def process_jobs(self): except Exception as e: logger.exception(e) finally: - end_time = time.time() - duration = end_time - start_time - self.client.post_influx(job.job_id, duration=duration) # Always run the cleanup, even if the job was cancelled proc = multiprocessing.Process( target=job.run_test_phase, diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 23bed004..dfed10ad 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -34,6 +34,7 @@ class TestflingerClient: def __init__(self, config): self.config = config + self.agent = self.config.get("agent_id") self.server = self.config.get( "server_address", "https://testflinger.canonical.com" ) @@ -292,38 +293,31 @@ def post_agent_data(self, data): :param data: dict of various agent data points to send to the api server """ - agent = self.config.get("agent_id") agent_data_uri = urljoin(self.server, "/v1/agents/data/") - agent_data_url = urljoin(agent_data_uri, agent) + agent_data_url = urljoin(agent_data_uri, self.agent) try: self.session.post(agent_data_url, json=data, timeout=30) except RequestException as exc: logger.error(exc) - def post_influx(self, job_id, phase=None, duration=None, result=None): + def post_influx(self, job_id, phase, duration, result): """Post the relevant data points to testflinger server :param data: dict of various agent data points to send to the api server """ - if phase: - measurement = "job phase" - fields = {"job phase": phase} - if duration: - measurement = "phase duration" - fields = {"duration": duration} - if result: - measurement = "job result" - fields = {"result": result} - data = [ { - "measurement": measurement, + "measurement": "phase result", "tags": { - "agent": self.config.get("agent_id"), + "agent": self.agent, "job_id": job_id, }, - "fields": fields, + "fields": { + "phase": phase, + "duration": duration, + "result": result, + }, "time": int(time.time()), }, ] From 11275e9cab1eb9bb75e4e137b4f921422ecbe8ad Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 17 Apr 2023 00:31:29 -0700 Subject: [PATCH 471/569] use config.get call to derive agent_id --- testflinger_agent/client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index dfed10ad..2ca356a5 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -34,7 +34,6 @@ class TestflingerClient: def __init__(self, config): self.config = config - self.agent = self.config.get("agent_id") self.server = self.config.get( "server_address", "https://testflinger.canonical.com" ) @@ -294,7 +293,7 @@ def post_agent_data(self, data): dict of various agent data points to send to the api server """ agent_data_uri = urljoin(self.server, "/v1/agents/data/") - agent_data_url = urljoin(agent_data_uri, self.agent) + agent_data_url = urljoin(agent_data_uri, self.config.get("agent_id")) try: self.session.post(agent_data_url, json=data, timeout=30) except RequestException as exc: @@ -310,7 +309,7 @@ def post_influx(self, job_id, phase, duration, result): { "measurement": "phase result", "tags": { - "agent": self.agent, + "agent": self.config.get("agent_id"), "job_id": job_id, }, "fields": { From 37db8d51ed550cb500e2dedacbf43ddb0383b020 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 17 Apr 2023 07:32:46 -0700 Subject: [PATCH 472/569] remove kwargs (use args instead) from client.post_influx call) --- testflinger_agent/agent.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index f68717a7..7daeb32c 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -179,9 +179,9 @@ def process_jobs(self): duration = end_time - start_time self.client.post_influx( job.job_id, - phase=phase, - duration=duration, - result=exitcode, + phase, + duration, + exitcode, ) # exit code 46 is our indication that recovery failed! From e489e2eda32826f38a3964bcf6f0c1be96109342 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 17 Apr 2023 16:40:51 -0700 Subject: [PATCH 473/569] move job_id to measurment field as a potential alternative to using tag or field --- testflinger_agent/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 2ca356a5..857f0f82 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -305,12 +305,12 @@ def post_influx(self, job_id, phase, duration, result): :param data: dict of various agent data points to send to the api server """ + measurement = "%s phase result" % job_id data = [ { - "measurement": "phase result", + "measurement": measurement, "tags": { "agent": self.config.get("agent_id"), - "job_id": job_id, }, "fields": { "phase": phase, From 8aa371164bf7d52fae13283e41d982674b3d8a23 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 19 Apr 2023 00:48:07 -0700 Subject: [PATCH 474/569] test if influxdb connection is established. allow empty INFLUX_USER & INFLUX_PW env vars --- testflinger_agent/client.py | 57 ++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 857f0f82..448d8b9c 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -19,12 +19,13 @@ import shutil import tempfile import time -import influxdb from urllib.parse import urljoin from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry -from requests.exceptions import RequestException +from requests.exceptions import RequestException, ConnectionError +from influxdb import InfluxDBClient +from influxdb.exceptions import InfluxDBClientError from testflinger_agent.errors import TFServerError @@ -61,24 +62,27 @@ def _requests_retry(self, retries=3): def _configure_influx(self): """Configure InfluxDB client using environment variables. - :return: InfluxDBClient object + :return: influxdb object or None """ - # required influxdb env vars - req_env_vars = ["INFLUX_HOST", "INFLUX_USER", "INFLUX_PW"] - - if not all(var in os.environ for var in req_env_vars): - return - - host = os.environ.get("INFLUX_HOST") + user = os.environ.get("INFLUX_USER", "") + password = os.environ.get("INFLUX_PW", "") port = int(os.environ.get("INFLUX_PORT", 8086)) - user = os.environ.get("INFLUX_USER") - password = os.environ.get("INFLUX_PW") + host = os.environ.get("INFLUX_HOST") + if not host: + logger.error("InfluxDB host undefined") + return - influx_client = influxdb.InfluxDBClient( + influx_client = InfluxDBClient( host, port, user, password, self.influx_agent_db ) - return influx_client + # ensure we can connect to influxdb host + try: + influx_client.ping() + except ConnectionError as exc: + logger.error(exc) + else: + return influx_client def check_jobs(self): """Check for new jobs for on the Testflinger server @@ -305,10 +309,12 @@ def post_influx(self, job_id, phase, duration, result): :param data: dict of various agent data points to send to the api server """ - measurement = "%s phase result" % job_id + if not self.influx_client: + return + data = [ { - "measurement": measurement, + "measurement": "phase result", "tags": { "agent": self.config.get("agent_id"), }, @@ -321,13 +327,12 @@ def post_influx(self, job_id, phase, duration, result): }, ] - if self.influx_client: - try: - self.influx_client.write_points( - data, - database=self.influx_agent_db, - time_precision="s", - protocol="json", - ) - except influxdb.exceptions.InfluxDBClientError: - pass + try: + self.influx_client.write_points( + data, + database=self.influx_agent_db, + time_precision="s", + protocol="json", + ) + except InfluxDBClientError as exc: + logger.error(exc) From 0ac97c1cfcfddd5a22f4c24087505c2adc350beb Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 19 Apr 2023 00:53:51 -0700 Subject: [PATCH 475/569] move phase and result to tags --- testflinger_agent/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 448d8b9c..8d2c9daa 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -317,11 +317,11 @@ def post_influx(self, job_id, phase, duration, result): "measurement": "phase result", "tags": { "agent": self.config.get("agent_id"), + "phase": phase, + "result": result, }, "fields": { - "phase": phase, "duration": duration, - "result": result, }, "time": int(time.time()), }, From 78e1840c5de8874cdb9c607233fe656982e32cf6 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 19 Apr 2023 15:25:07 -0500 Subject: [PATCH 476/569] Inject the parent job_id into subordinate jobs for multi-device --- .../devices/multi/multi.py | 12 ++++++++++++ .../devices/multi/tests/test_multi.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py index 5ad55b34..2e97fb4c 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -122,6 +122,8 @@ def create_jobs(self): logger.error("Job is not a dict: %s", job) continue job = self.inject_allocate_data(job) + job = self.inject_parent_jobid(job) + try: job_id = self.client.submit_job(job) except OSError as exc: @@ -144,6 +146,16 @@ def inject_allocate_data(self, job): job.update(allocate_data) return job + def inject_parent_jobid(self, job): + """Inject the parent job_id into the job + + :param job: the job to inject the parent job_id into + :returns: the job with parent_job_id added to it + """ + parent_job_id = {"parent_job_id": self.job_data.get("job_id")} + job.update(parent_job_id) + return job + def cancel_jobs(self, jobs): """Cancel all jobs in the specified list of job_ids""" for job in jobs: diff --git a/src/snappy_device_agents/devices/multi/tests/test_multi.py b/src/snappy_device_agents/devices/multi/tests/test_multi.py index 5b97e723..2af8066f 100644 --- a/src/snappy_device_agents/devices/multi/tests/test_multi.py +++ b/src/snappy_device_agents/devices/multi/tests/test_multi.py @@ -42,3 +42,22 @@ def test_inject_allocate_data(): test_agent.create_jobs() for job in test_agent.job_data["provision_data"]["jobs"]: assert job["allocate_data"]["allocate"] is True + + +def test_inject_parent_jobid(): + """Test that parent_jobid is injected into job""" + test_config = {"agent_name": "test_agent"} + parent_job_id = "11111111-1111-1111-1111-111111111111" + job_data = { + "job_id": parent_job_id, + "provision_data": { + "jobs": [ + {"job_id": "1"}, + {"job_id": "2"}, + ] + }, + } + test_agent = Multi(test_config, job_data, MockTFClient("http://localhost")) + test_agent.create_jobs() + for job in test_agent.job_data["provision_data"]["jobs"]: + assert job["parent_job_id"] == parent_job_id From fe20df97da6ab749abdc4ac569f02d1d54ae04a3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 19 Apr 2023 17:18:32 -0500 Subject: [PATCH 477/569] Wait for completion of the parent job then continue from allocate --- testflinger_agent/job.py | 21 ++++++++++++++++++--- testflinger_agent/tests/test_job.py | 11 +++++++++++ tox.ini | 1 + 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index e74522d4..3dc73a12 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -142,10 +142,25 @@ def allocate_phase(self, rundir): self.client.post_job_state(self.job_id, "allocated") - # For now, we can wait with signal.pause() because another thread is - # monitoring for completion. But if this changes, we'll need to watch + self.wait_for_completion() + + def wait_for_completion(self): + """Monitor the parent job and exit when it completes""" + # For now, we don't have to monitor our own job state, since the + # another thread monitors it. But if this changes, we'll need to watch # for changes to our own job state - signal.pause() + + while True: + try: + parent_job_state = self.client.check_job_state( + self.job_data.get("parent_job_id") + ) + if parent_job_state in ("completed", "cancelled"): + logger.info("Parent job completed, exiting...") + break + except TFServerError: + logger.warning("Failed to get parent job, retrying...") + time.sleep(60) def _set_truncate(self, f, size=1024 * 1024): """Set up an open file so that we don't read more than a specified diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 79d8b49e..37876dd4 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -146,3 +146,14 @@ def test_set_truncate(self, client): # It won't be exactly 100 bytes, because a warning is added assert len(contents) < 150 assert "WARNING" in contents + + @pytest.mark.timeout(1) + def test_wait_for_completion(self, client): + """Test that wait_for_completion works""" + + # Make sure we return "completed" for the parent job state + client.check_job_state = lambda _: "completed" + + job = _TestflingerJob({"parent_job_id": "999"}, client) + job.wait_for_completion() + # No assertions needed, just make sure we don't timeout diff --git a/tox.ini b/tox.ini index 53a3d0f8..cd335062 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ deps = pylint pytest-mock pytest-cov + pytest-timeout requests-mock commands = {envbindir}/python setup.py develop From 988dff87c73656e7c30e3736b8dcf6293548ac8d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 19 Apr 2023 22:39:26 -0500 Subject: [PATCH 478/569] If the job is cancelled, exit the loop after terminating the process --- testflinger_agent/agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 7daeb32c..bd1786d2 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -173,6 +173,7 @@ def process_jobs(self): "Job cancellation was requested, exiting." ) proc.terminate() + break exitcode = proc.exitcode end_time = time.time() From 6c00a4ccc14f7a359b88558b135233ab2f95f2cd Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 19 Apr 2023 22:43:35 -0500 Subject: [PATCH 479/569] For multi-device jobs, detect if we get cancelled during the provision phase --- src/snappy_device_agents/devices/multi/multi.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py index 2e97fb4c..5c64c1ae 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -58,6 +58,9 @@ def provision(self): while unallocated: time.sleep(10) for job in unallocated: + if self.this_job_complete(): + self.cancel_jobs(self.jobs) + raise ProvisioningError("Job cancelled or completed") state = self.client.get_status(job) if state == "allocated": unallocated.remove(job) @@ -78,6 +81,18 @@ def provision(self): self.save_job_list_file() + def this_job_complete(self): + """ + If the job is complete, or cancelled, then we need to exit the + provision phase, and cleanup the subordinate jobs + """ + + job_id = self.job_data.get("job_id") + status = self.client.get_status(job_id) + if status in ("cancelled", "completed"): + return True + return False + def save_job_list_file(self): """ Retrieve results for each job from the server, and look at the From 178d6f1eab5975875cfbc2528e6a292296ea5932 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 27 Apr 2023 21:51:44 -0700 Subject: [PATCH 480/569] create agent job db in influx if it doesn't exist --- testflinger_agent/client.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 8d2c9daa..f8b12b46 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -64,13 +64,13 @@ def _configure_influx(self): :return: influxdb object or None """ - user = os.environ.get("INFLUX_USER", "") - password = os.environ.get("INFLUX_PW", "") - port = int(os.environ.get("INFLUX_PORT", 8086)) host = os.environ.get("INFLUX_HOST") if not host: logger.error("InfluxDB host undefined") - return + return None + port = int(os.environ.get("INFLUX_PORT", 8086)) + user = os.environ.get("INFLUX_USER", "") + password = os.environ.get("INFLUX_PW", "") influx_client = InfluxDBClient( host, port, user, password, self.influx_agent_db @@ -81,8 +81,14 @@ def _configure_influx(self): influx_client.ping() except ConnectionError as exc: logger.error(exc) - else: - return influx_client + return None + + # create agent job db if it doesn't exist + db_list = influx_client.get_list_database() + if not any(db["name"] == self.influx_agent_db for db in db_list): + influx_client.create_database(self.influx_agent_db) + + return influx_client def check_jobs(self): """Check for new jobs for on the Testflinger server From 38564b217cca97490cdabdaa9061f11db1d0baa2 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 27 Apr 2023 22:01:32 -0700 Subject: [PATCH 481/569] use db list fetch as connectivity check instead of ping() --- testflinger_agent/client.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index f8b12b46..844af945 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -76,19 +76,17 @@ def _configure_influx(self): host, port, user, password, self.influx_agent_db ) - # ensure we can connect to influxdb host + # ensure we can connect to influxdb try: - influx_client.ping() + db_list = influx_client.get_list_database() except ConnectionError as exc: logger.error(exc) - return None - - # create agent job db if it doesn't exist - db_list = influx_client.get_list_database() - if not any(db["name"] == self.influx_agent_db for db in db_list): - influx_client.create_database(self.influx_agent_db) + else: + # create agent job db if it doesn't exist + if not any(db["name"] == self.influx_agent_db for db in db_list): + influx_client.create_database(self.influx_agent_db) - return influx_client + return influx_client def check_jobs(self): """Check for new jobs for on the Testflinger server From 0eb2417da28f8496e83cb4c853f60d823a7495c4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 2 May 2023 15:48:01 +0200 Subject: [PATCH 482/569] Download images directly for muxpi device agents --- .../devices/muxpi/muxpi.py | 33 ++++--------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 2308e2f2..17178dd1 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -16,7 +16,6 @@ import json import logging -import multiprocessing import subprocess import time from contextlib import contextmanager @@ -24,7 +23,6 @@ import yaml -import snappy_device_agents from snappy_device_agents.devices import ProvisioningError, RecoveryError logger = logging.getLogger() @@ -109,7 +107,6 @@ def _copy_to_control(self, local_file, remote_file): def provision(self): try: url = self.job_data["provision_data"]["url"] - snappy_device_agents.download(url, "snappy.img") except KeyError: raise ProvisioningError( 'You must specify a "url" value in ' @@ -120,21 +117,8 @@ def provision(self): self._run_control(cmd) time.sleep(5) logger.info("Flashing Test image") - image_file = snappy_device_agents.compress_file("snappy.img") - server_ip = snappy_device_agents.get_local_ip_addr() - serve_q = multiprocessing.Queue() - file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, - args=( - serve_q, - image_file, - ), - ) - file_server.start() - server_port = serve_q.get() try: - self.flash_test_image(server_ip, server_port) - file_server.terminate() + self.flash_test_image(url) with self.remote_mount(): image_type = self.get_image_type() logger.info("Creating Test User") @@ -148,23 +132,20 @@ def provision(self): except Exception: raise - def flash_test_image(self, server_ip, server_port): + def flash_test_image(self, url): """ Flash the image at :image_url to the sd card. - :param server_ip: - IP address of the image server. The image will be downloaded and - uncompressed over the SD card. - :param server_port: - TCP port to connect to on server_ip for downloading the image + :param url: + URL to download the image from :raises ProvisioningError: If the command times out or anything else fails. """ # First unmount, just in case self.unmount_writable_partition() - cmd = "nc.traditional {} {}| xzcat| sudo dd of={} bs=16M".format( - server_ip, server_port, self.config["test_device"] - ) + + test_device = self.config["test_device"] + cmd = f"curl -s {url} | xzcat| sudo dd of={test_device} bs=16M" logger.info("Running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! From fbdfaf607b34618e10273a3da94401595ce69fe4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 2 May 2023 21:34:55 +0200 Subject: [PATCH 483/569] Move some of the remaining user-data content out of source and into files --- pyproject.toml | 3 +- .../data/extrausers/group | 1 - .../data/extrausers/gshadow | 1 - .../data/extrausers/passwd | 1 - .../data/extrausers/shadow | 1 - .../data/extrausers/subgid | 1 - .../data/extrausers/subuid | 1 - .../data/muxpi/classic/user-data | 7 ++ .../data/{ => muxpi}/limerick/user-data | 0 .../data/muxpi/oemscript/README | 2 + .../oemscript/recovery-from-iso.sh | 0 .../data/muxpi/pi-desktop/oem-config.service | 26 +++++ .../data/muxpi/pi-desktop/preseed.cfg | 105 ++++++++++++++++++ .../data/muxpi/uc20/99_nocloud.cfg | 14 +++ .../devices/muxpi/muxpi.py | 53 +++------ .../devices/oemscript/oemscript.py | 2 +- 16 files changed, 169 insertions(+), 49 deletions(-) delete mode 100644 src/snappy_device_agents/data/extrausers/group delete mode 100644 src/snappy_device_agents/data/extrausers/gshadow delete mode 100644 src/snappy_device_agents/data/extrausers/passwd delete mode 100644 src/snappy_device_agents/data/extrausers/shadow delete mode 100644 src/snappy_device_agents/data/extrausers/subgid delete mode 100644 src/snappy_device_agents/data/extrausers/subuid create mode 100644 src/snappy_device_agents/data/muxpi/classic/user-data rename src/snappy_device_agents/data/{ => muxpi}/limerick/user-data (100%) create mode 100644 src/snappy_device_agents/data/muxpi/oemscript/README rename src/snappy_device_agents/data/{ => muxpi}/oemscript/recovery-from-iso.sh (100%) create mode 100644 src/snappy_device_agents/data/muxpi/pi-desktop/oem-config.service create mode 100644 src/snappy_device_agents/data/muxpi/pi-desktop/preseed.cfg create mode 100644 src/snappy_device_agents/data/muxpi/uc20/99_nocloud.cfg diff --git a/pyproject.toml b/pyproject.toml index 7d45b8ee..3fb556c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,7 @@ dependencies = [ snappy-device-agent = "snappy_device_agents.cmd:main" [tool.setuptools.package-data] -snappy_device_agents = ["data/*"] -"snappy_device_agents.data.oemscript" = ["*.sh"] +snappy_device_agents = ["data/**"] [tool.black] line-length = 79 diff --git a/src/snappy_device_agents/data/extrausers/group b/src/snappy_device_agents/data/extrausers/group deleted file mode 100644 index 790c1873..00000000 --- a/src/snappy_device_agents/data/extrausers/group +++ /dev/null @@ -1 +0,0 @@ -ubuntu:x:1000: diff --git a/src/snappy_device_agents/data/extrausers/gshadow b/src/snappy_device_agents/data/extrausers/gshadow deleted file mode 100644 index 9f0ddb53..00000000 --- a/src/snappy_device_agents/data/extrausers/gshadow +++ /dev/null @@ -1 +0,0 @@ -ubuntu:!:: diff --git a/src/snappy_device_agents/data/extrausers/passwd b/src/snappy_device_agents/data/extrausers/passwd deleted file mode 100644 index 5cf2d2f7..00000000 --- a/src/snappy_device_agents/data/extrausers/passwd +++ /dev/null @@ -1 +0,0 @@ -ubuntu:x:1000:1000:,,,:/home/ubuntu:/bin/bash diff --git a/src/snappy_device_agents/data/extrausers/shadow b/src/snappy_device_agents/data/extrausers/shadow deleted file mode 100644 index b598e79d..00000000 --- a/src/snappy_device_agents/data/extrausers/shadow +++ /dev/null @@ -1 +0,0 @@ -ubuntu:$6$EAwWeC6X$6ENp0R4rM9SG3MWbc1x2kSWYOjuiy9Se1IMp2//FCVBV20hYSE2b7tPr7klemaLZZ3W7QJ4KRZ3C3dZ6I0Zx50:17303:0:99999:7::: diff --git a/src/snappy_device_agents/data/extrausers/subgid b/src/snappy_device_agents/data/extrausers/subgid deleted file mode 100644 index fcd35d50..00000000 --- a/src/snappy_device_agents/data/extrausers/subgid +++ /dev/null @@ -1 +0,0 @@ -ubuntu:100000:65536 diff --git a/src/snappy_device_agents/data/extrausers/subuid b/src/snappy_device_agents/data/extrausers/subuid deleted file mode 100644 index fcd35d50..00000000 --- a/src/snappy_device_agents/data/extrausers/subuid +++ /dev/null @@ -1 +0,0 @@ -ubuntu:100000:65536 diff --git a/src/snappy_device_agents/data/muxpi/classic/user-data b/src/snappy_device_agents/data/muxpi/classic/user-data new file mode 100644 index 00000000..39335263 --- /dev/null +++ b/src/snappy_device_agents/data/muxpi/classic/user-data @@ -0,0 +1,7 @@ +#cloud-config +password: ubuntu +chpasswd: + list: + - ubuntu:ubuntu + expire: False +ssh_pwauth: True diff --git a/src/snappy_device_agents/data/limerick/user-data b/src/snappy_device_agents/data/muxpi/limerick/user-data similarity index 100% rename from src/snappy_device_agents/data/limerick/user-data rename to src/snappy_device_agents/data/muxpi/limerick/user-data diff --git a/src/snappy_device_agents/data/muxpi/oemscript/README b/src/snappy_device_agents/data/muxpi/oemscript/README new file mode 100644 index 00000000..0bca4482 --- /dev/null +++ b/src/snappy_device_agents/data/muxpi/oemscript/README @@ -0,0 +1,2 @@ +This recovery-from-iso.sh comes from: +https://git.launchpad.net/~oem-solutions-engineers/pc-enablement/+git/oem-scripts/tree/recovery-from-iso.sh diff --git a/src/snappy_device_agents/data/oemscript/recovery-from-iso.sh b/src/snappy_device_agents/data/muxpi/oemscript/recovery-from-iso.sh similarity index 100% rename from src/snappy_device_agents/data/oemscript/recovery-from-iso.sh rename to src/snappy_device_agents/data/muxpi/oemscript/recovery-from-iso.sh diff --git a/src/snappy_device_agents/data/muxpi/pi-desktop/oem-config.service b/src/snappy_device_agents/data/muxpi/pi-desktop/oem-config.service new file mode 100644 index 00000000..758caa5a --- /dev/null +++ b/src/snappy_device_agents/data/muxpi/pi-desktop/oem-config.service @@ -0,0 +1,26 @@ +[Unit] +Description=End-user configuration after initial OEM installation +ConditionFileIsExecutable=/usr/sbin/oem-config-firstboot +ConditionPathExists=/dev/tty1 + +# We never want to run the oem-config job in the live environment (as is the +# case in some custom configurations) or in recovery mode. +ConditionKernelCommandLine=!boot=casper +ConditionKernelCommandLine=!single +ConditionKernelCommandLine=!rescue +ConditionKernelCommandLine=!emergency + +[Service] +Type=oneshot +StandardInput=tty +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +ExecStart=/bin/sh -ec '\ + while ! debconf-set-selections /preseed.cfg; do sleep 30;done; \ + exec oem-config-firstboot --automatic' + +[Install] +WantedBy=oem-config.target diff --git a/src/snappy_device_agents/data/muxpi/pi-desktop/preseed.cfg b/src/snappy_device_agents/data/muxpi/pi-desktop/preseed.cfg new file mode 100644 index 00000000..45b8ed4a --- /dev/null +++ b/src/snappy_device_agents/data/muxpi/pi-desktop/preseed.cfg @@ -0,0 +1,105 @@ +#### Contents of the preconfiguration file (for groovy) +### Localization +# Preseeding only locale sets language, country and locale. +d-i localechooser/languagelist select en +d-i debian-installer/locale string en_US.UTF-8 + +# The values can also be preseeded individually for greater flexibility. +#d-i debian-installer/language string en +#d-i debian-installer/country string NL +#d-i debian-installer/locale string en_GB.UTF-8 +# Optionally specify additional locales to be generated. +#d-i localechooser/supported-locales multiselect en_US.UTF-8, nl_NL.UTF-8 + +# Keyboard selection. +# Disable automatic (interactive) keymap detection. +d-i console-setup/ask_detect boolean false +d-i keyboard-configuration/xkb-keymap select us +# To select a variant of the selected layout: +#d-i keyboard-configuration/xkb-keymap select us(dvorak) +# d-i keyboard-configuration/toggle select No toggling + +# netcfg will choose an interface that has link if possible. This makes it +# skip displaying a list if there is more than one interface. +d-i netcfg/choose_interface select auto +# Any hostname and domain names assigned from dhcp take precedence over +# values set here. However, setting the values still prevents the questions +# from being shown, even if values come from dhcp. +d-i netcfg/get_hostname string unassigned-hostname +d-i netcfg/get_domain string unassigned-domain +# Disable that annoying WEP key dialog. +d-i netcfg/wireless_wep string +# The wacky dhcp hostname that some ISPs use as a password of sorts. +#d-i netcfg/dhcp_hostname string radish + +### Mirror settings +# If you select ftp, the mirror/country string does not need to be set. +#d-i mirror/protocol string ftp +d-i mirror/country string manual +d-i mirror/http/hostname string archive.ubuntu.com +d-i mirror/http/directory string /ubuntu +d-i mirror/http/proxy string + + +# Set to true if you want to encrypt the first user's home directory. +d-i user-setup/encrypt-home boolean false + +### Clock and time zone setup +# Controls whether or not the hardware clock is set to UTC. +d-i clock-setup/utc boolean true + +# You may set this to any valid setting for $TZ; see the contents of +# /usr/share/zoneinfo/ for valid values. +d-i time/zone string US/Eastern + +# Controls whether to use NTP to set the clock during the install +d-i clock-setup/ntp boolean true +# NTP server to use. The default is almost always fine here. +#d-i clock-setup/ntp-server string ntp.example.com + +### Package selection +tasksel tasksel/first multiselect ubuntu-desktop +#tasksel tasksel/first multiselect lamp-server, print-server +#tasksel tasksel/first multiselect kubuntu-desktop + +# Avoid that last message about the install being complete. +d-i finish-install/reboot_in_progress note + +d-i netcfg/get_hostname string ubuntu +d-i mirror/http/hostname string archive.ubuntu.com +d-i passwd/auto-login boolean true +d-i passwd/user-fullname string Ubuntu User +d-i passwd/username string ubuntu +d-i passwd/user-password password ubuntu +d-i passwd/user-password-again password ubuntu +d-i user-setup/allow-password-weak boolean true +d-i debian-installer/allow_unauthenticated boolean true +d-i preseed/late_command string \ + systemctl start NetworkManager; \ + apt-get update; \ + apt-get install -y --force-yes ssh; \ + apt-get clean + +d-i console-setup/ask_detect boolean false +d-i console-setup/layoutcode string us +d-i debian-installer/locale string en_US +d-i keyboard-configuration/ask_detect boolean false +d-i keyboard-configuration/layoutcode string us +d-i keyboard-configuration/xkb-keymap select us +ubiquity countrychooser/shortlist select US +ubiquity languagechooser/language-name select English +ubiquity localechooser/supported-locales multiselect en_US.UTF-8 + +ubiquity ubiquity/summary note +ubiquity ubiquity/reboot boolean true +ubiquity ubiquity/poweroff boolean true +ubiquity ubiquity/success_command string \ + systemctl start NetworkManager; \ + apt-get update; \ + apt-get install -y --force-yes ssh; \ + apt-get clean + +# Enable extras.ubuntu.com. +d-i apt-setup/extras boolean true +# Install the Ubuntu desktop. +tasksel tasksel/first multiselect ubuntu-desktop diff --git a/src/snappy_device_agents/data/muxpi/uc20/99_nocloud.cfg b/src/snappy_device_agents/data/muxpi/uc20/99_nocloud.cfg new file mode 100644 index 00000000..2ea88244 --- /dev/null +++ b/src/snappy_device_agents/data/muxpi/uc20/99_nocloud.cfg @@ -0,0 +1,14 @@ +#cloud-config +datasource_list: [ NoCloud, None ] +datasource: + NoCloud: + user-data: | + #cloud-config + password: ubuntu + chpasswd: + list: + - ubuntu:ubuntu + expire: False + ssh_pwauth: True + meta-data: | + instance_id: cloud-image diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 17178dd1..6f35e767 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -267,38 +267,10 @@ def unmount_writable_partition(self): def create_user(self, image_type): """Create user account for default ubuntu user""" - metadata = "instance_id: cloud-image" - userdata = ( - "#cloud-config\n" - "password: ubuntu\n" - "chpasswd:\n" - " list:\n" - " - ubuntu:ubuntu\n" - " expire: False\n" - "ssh_pwauth: True" - ) - # For core20: - uc20_ci_data = ( - "#cloud-config\n" - "datasource_list: [ NoCloud, None ]\n" - "datasource:\n" - " NoCloud:\n" - " user-data: |\n" - " #cloud-config\n" - " password: ubuntu\n" - " chpasswd:\n" - " list:\n" - " - ubuntu:ubuntu\n" - " expire: False\n" - " ssh_pwauth: True\n" - " meta-data: |\n" - " instance_id: cloud-image" - ) - base = self.mount_point remote_tmp = Path("/tmp") / self.agent_name try: - data_path = Path(__file__).parent / "../../data" + data_path = Path(__file__).parent / "../../data/muxpi" if image_type == "limerick": self._run_control("mkdir -p {}".format(remote_tmp)) self._copy_to_control( @@ -348,11 +320,13 @@ def create_user(self, image_type): if image_type == "core20": base = self.mount_point / "ubuntu-seed" ci_path = base / "data/etc/cloud/cloud.cfg.d" - self._run_control("sudo mkdir -p {}".format(ci_path)) - write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" - self._run_control( - write_cmd.format(uc20_ci_data, ci_path, "99_nocloud.cfg") + self._run_control(f"sudo mkdir -p {ci_path}") + self._run_control("mkdir -p {}".format(remote_tmp)) + self._copy_to_control( + data_path / "uc20/99_nocloud.cfg", remote_tmp ) + cmd = f"sudo cp {remote_tmp}/99_nocloud.cfg {ci_path}" + self._run_control(cmd) else: # For core or ubuntu classic images base = self.mount_point / "writable" @@ -361,14 +335,13 @@ def create_user(self, image_type): if image_type == "ubuntu-cpc": base = self.mount_point / "cloudimg-rootfs" ci_path = base / "var/lib/cloud/seed/nocloud-net" - self._run_control("sudo mkdir -p {}".format(ci_path)) - write_cmd = "sudo bash -c \"echo '{}' > /{}/{}\"" - self._run_control( - write_cmd.format(metadata, ci_path, "meta-data") - ) - self._run_control( - write_cmd.format(userdata, ci_path, "user-data") + self._run_control(f"sudo mkdir -p {ci_path}") + self._run_control("mkdir -p {}".format(remote_tmp)) + self._copy_to_control( + data_path / "classic/user-data", remote_tmp ) + cmd = f"sudo cp {remote_tmp}/user-data {ci_path}" + self._run_control(cmd) if image_type == "ubuntu": # This needs to be removed on classic for rpi, else # cloud-init won't find the user-data we give it diff --git a/src/snappy_device_agents/devices/oemscript/oemscript.py b/src/snappy_device_agents/devices/oemscript/oemscript.py index 59b2964f..54a9e58c 100644 --- a/src/snappy_device_agents/devices/oemscript/oemscript.py +++ b/src/snappy_device_agents/devices/oemscript/oemscript.py @@ -102,7 +102,7 @@ def run_recovery_script(self, image_file): """Download and run the OEM recovery script""" device_ip = self.config["device_ip"] - data_path = Path(__file__).parent / "../../data/oemscript" + data_path = Path(__file__).parent / "../../data/muxpi/oemscript" recovery_script = data_path / "recovery-from-iso.sh" # Run the recovery script From d60b4bfc8b1320c962159d915c67d37d1a65665c Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 3 May 2023 14:24:59 +0200 Subject: [PATCH 484/569] use database creation as connection test --- testflinger_agent/client.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 844af945..cabd898f 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -78,14 +78,10 @@ def _configure_influx(self): # ensure we can connect to influxdb try: - db_list = influx_client.get_list_database() + influx_client.create_database(self.influx_agent_db) except ConnectionError as exc: logger.error(exc) else: - # create agent job db if it doesn't exist - if not any(db["name"] == self.influx_agent_db for db in db_list): - influx_client.create_database(self.influx_agent_db) - return influx_client def check_jobs(self): From f521546d5a556fae9d4a304e58a860e93ec6ce92 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 9 May 2023 15:09:21 -0500 Subject: [PATCH 485/569] Add unit test for multi-device agent this_job_complete() --- .../devices/multi/tests/test_multi.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/snappy_device_agents/devices/multi/tests/test_multi.py b/src/snappy_device_agents/devices/multi/tests/test_multi.py index 2af8066f..35753cef 100644 --- a/src/snappy_device_agents/devices/multi/tests/test_multi.py +++ b/src/snappy_device_agents/devices/multi/tests/test_multi.py @@ -61,3 +61,28 @@ def test_inject_parent_jobid(): test_agent.create_jobs() for job in test_agent.job_data["provision_data"]["jobs"]: assert job["parent_job_id"] == parent_job_id + +def test_this_job_complete(): + """Test that this_job_complete returns True only when the job is complete""" + test_config = {"agent_name": "test_agent"} + job_data = { + "job_id": "11111111-1111-1111-1111-111111111111", + } + + # completed state is complete + complete_client = MockTFClient("http://localhost") + complete_client.get_status = lambda job_id: "completed" + test_agent = Multi(test_config, job_data, complete_client) + assert test_agent.this_job_complete() is True + + # cancelled state is complete + cancelled_client = MockTFClient("http://localhost") + cancelled_client.get_status = lambda job_id: "cancelled" + test_agent = Multi(test_config, job_data, cancelled_client) + assert test_agent.this_job_complete() is True + + # anything else is not complete + incomplete_client = MockTFClient("http://localhost") + incomplete_client.get_status = lambda job_id: "something else" + test_agent = Multi(test_config, job_data, incomplete_client) + assert test_agent.this_job_complete() is False \ No newline at end of file From 88377dae557670d3e86af15e4fc374503ef95b1d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 9 May 2023 15:22:14 -0500 Subject: [PATCH 486/569] Multi-device: move check for parent completion out of the for loop --- src/snappy_device_agents/devices/multi/multi.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py index 5c64c1ae..d0e4ce5f 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -57,10 +57,8 @@ def provision(self): while unallocated: time.sleep(10) + self.terminate_if_parent_complete() for job in unallocated: - if self.this_job_complete(): - self.cancel_jobs(self.jobs) - raise ProvisioningError("Job cancelled or completed") state = self.client.get_status(job) if state == "allocated": unallocated.remove(job) @@ -81,6 +79,12 @@ def provision(self): self.save_job_list_file() + def terminate_if_parent_complete(self): + """If parent job is complete or cancelled, cancel sub jobs""" + if self.this_job_complete(): + self.cancel_jobs(self.jobs) + raise ProvisioningError("Job cancelled or completed") + def this_job_complete(self): """ If the job is complete, or cancelled, then we need to exit the From d066398fd8494ef444f921ef2669260b96b27a1a Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 9 May 2023 15:30:34 -0500 Subject: [PATCH 487/569] small static test fixes --- src/snappy_device_agents/devices/multi/tests/test_multi.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/snappy_device_agents/devices/multi/tests/test_multi.py b/src/snappy_device_agents/devices/multi/tests/test_multi.py index 35753cef..5ddf9279 100644 --- a/src/snappy_device_agents/devices/multi/tests/test_multi.py +++ b/src/snappy_device_agents/devices/multi/tests/test_multi.py @@ -62,8 +62,9 @@ def test_inject_parent_jobid(): for job in test_agent.job_data["provision_data"]["jobs"]: assert job["parent_job_id"] == parent_job_id + def test_this_job_complete(): - """Test that this_job_complete returns True only when the job is complete""" + """Test this_job_complete() returns True only when the job is complete""" test_config = {"agent_name": "test_agent"} job_data = { "job_id": "11111111-1111-1111-1111-111111111111", @@ -85,4 +86,4 @@ def test_this_job_complete(): incomplete_client = MockTFClient("http://localhost") incomplete_client.get_status = lambda job_id: "something else" test_agent = Multi(test_config, job_data, incomplete_client) - assert test_agent.this_job_complete() is False \ No newline at end of file + assert test_agent.this_job_complete() is False From ad7cc6745419d9f45191553ccd8e197e88419ac3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 12 May 2023 01:16:15 -0500 Subject: [PATCH 488/569] meta-data is also needed for ubuntu core 16/18 --- src/snappy_device_agents/data/muxpi/classic/meta-data | 1 + src/snappy_device_agents/devices/muxpi/muxpi.py | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 src/snappy_device_agents/data/muxpi/classic/meta-data diff --git a/src/snappy_device_agents/data/muxpi/classic/meta-data b/src/snappy_device_agents/data/muxpi/classic/meta-data new file mode 100644 index 00000000..e19f3471 --- /dev/null +++ b/src/snappy_device_agents/data/muxpi/classic/meta-data @@ -0,0 +1 @@ +instance_id: cloud-image diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 6f35e767..258c76ef 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -337,6 +337,11 @@ def create_user(self, image_type): ci_path = base / "var/lib/cloud/seed/nocloud-net" self._run_control(f"sudo mkdir -p {ci_path}") self._run_control("mkdir -p {}".format(remote_tmp)) + self._copy_to_control( + data_path / "classic/meta-data", remote_tmp + ) + cmd = f"sudo cp {remote_tmp}/meta-data {ci_path}" + self._run_control(cmd) self._copy_to_control( data_path / "classic/user-data", remote_tmp ) From 61d7d46c9bcd908cb181e8c89e03e97d21e3e661 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 17 May 2023 16:51:52 -0500 Subject: [PATCH 489/569] Make curl fail for bad URLs in muxpi device agent --- src/snappy_device_agents/devices/muxpi/muxpi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 17178dd1..1ec31b4b 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -145,7 +145,10 @@ def flash_test_image(self, url): self.unmount_writable_partition() test_device = self.config["test_device"] - cmd = f"curl -s {url} | xzcat| sudo dd of={test_device} bs=16M" + cmd = ( + f"(set -o pipefail; curl -sf {url} | xzcat| " + f"sudo dd of={test_device} bs=16M)" + ) logger.info("Running: %s", cmd) try: # XXX: I hope 30 min is enough? but maybe not! From ce037e8eb25ce2d18765218bfe1998698498d736 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 13 Apr 2023 15:42:04 -0500 Subject: [PATCH 490/569] Simplify job processing by eliminating multiprocess --- testflinger_agent/agent.py | 33 ++------------ testflinger_agent/job.py | 69 ++++++++++++++++------------- testflinger_agent/tests/test_job.py | 5 +++ 3 files changed, 45 insertions(+), 62 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index bd1786d2..bd6f9b72 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -153,28 +153,9 @@ def process_jobs(self): break self.client.post_job_state(job.job_id, phase) self.set_agent_state(phase) - proc = multiprocessing.Process( - target=job.run_test_phase, - args=( - phase, - rundir, - ), - ) - proc.start() + start_time = time.time() - while proc.is_alive(): - proc.join(10) - if ( - self.client.check_job_state(job.job_id) - == "cancelled" - and phase != "provision" - ): - logger.info( - "Job cancellation was requested, exiting." - ) - proc.terminate() - break - exitcode = proc.exitcode + exitcode = job.run_test_phase(phase, rundir) end_time = time.time() duration = end_time - start_time @@ -200,17 +181,9 @@ def process_jobs(self): logger.exception(e) finally: # Always run the cleanup, even if the job was cancelled - proc = multiprocessing.Process( - target=job.run_test_phase, - args=( - "cleanup", - rundir, - ), - ) + job.run_test_phase("cleanup", rundir) # clear job id self.client.post_agent_data({"job_id": ""}) - proc.start() - proc.join() try: self.client.transmit_job_outcome(rundir) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 3dc73a12..8b6f67cd 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -16,7 +16,6 @@ import json import logging import os -import select import signal import sys import subprocess @@ -88,7 +87,7 @@ def run_test_phase(self, phase, rundir): ) if phase == "allocate": self.allocate_phase(rundir) - sys.exit(exitcode) + return exitcode def _update_phase_results( self, results_file, phase, exitcode, output_log, serial_log @@ -201,7 +200,6 @@ def run_with_log(self, cmd, logfile, cwd=None): start_time = time.time() with open(logfile, "a", encoding="utf-8") as f: live_output_buffer = "" - readpoll = select.poll() buffer_timeout = time.time() process = subprocess.Popen( cmd, @@ -217,19 +215,21 @@ def cleanup(signum, frame): signal.signal(signal.SIGTERM, cleanup) set_nonblock(process.stdout.fileno()) - readpoll.register(process.stdout, select.POLLIN) - while process.poll() is None: - # Check if there's any new data, timeout after 10s - data_ready = readpoll.poll(10000) - if data_ready: - buf = process.stdout.read().decode( - sys.stdout.encoding, errors="replace" - ) - if buf: - sys.stdout.write(buf) - live_output_buffer += buf - f.write(buf) - f.flush() + + while True: + line = process.stdout.readline() + if not line and process.poll() is not None: + # Process exited + break + + if line: + # Write the latest output to the log file, stdout, and + # the live output buffer + buf = line.decode(sys.stdout.encoding, errors="replace") + sys.stdout.write(buf) + live_output_buffer += buf + f.write(buf) + f.flush() else: if ( self.phase == "test" @@ -243,6 +243,16 @@ def cleanup(signum, frame): f.write(buf) process.kill() break + + # Check if it's time to send the output buffer to the server + if live_output_buffer and time.time() - buffer_timeout > 10: + if self.client.post_live_output( + self.job_id, live_output_buffer + ): + live_output_buffer = "" + buffer_timeout = time.time() + + # Check global timeout if ( self.phase != "reserve" and time.time() - start_time > global_timeout @@ -254,24 +264,19 @@ def cleanup(signum, frame): f.write(buf) process.kill() break - # Don't spam the server, only flush the buffer if there - # is output and it's been more than 10s - if live_output_buffer and time.time() - buffer_timeout > 10: - buffer_timeout = time.time() - # Try to stream output, if we can't connect, then - # keep buffer for the next pass through this - if self.client.post_live_output( - self.job_id, live_output_buffer - ): - live_output_buffer = "" - buf = process.stdout.read() - if buf: - buf = buf.decode(sys.stdout.encoding, errors="replace") - sys.stdout.write(buf) - live_output_buffer += buf - f.write(buf) + + # Check if job was canceled + if ( + self.client.check_job_state(self.job_id) == "cancelled" + and self.phase != "provision" + ): + logger.info("Job cancellation was requested, exiting.") + process.kill() + break + if live_output_buffer: self.client.post_live_output(self.job_id, live_output_buffer) + try: status = process.wait(10) # process.returncode except TimeoutError: diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 37876dd4..627b3c96 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -61,6 +61,7 @@ def test_job_global_timeout(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"global_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" job.run_with_log("sleep 3", logfile) @@ -75,6 +76,7 @@ def test_config_global_timeout(self, client, requests_mock): self.config["global_timeout"] = 1 fake_job_data = {"global_timeout": 3} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" job.run_with_log("sleep 3", logfile) @@ -88,6 +90,7 @@ def test_job_output_timeout(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"output_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" # unfortunately, we need to sleep for longer that 10 seconds here @@ -104,6 +107,7 @@ def test_config_output_timeout(self, client, requests_mock): self.config["output_timeout"] = 1 fake_job_data = {"output_timeout": 30} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "test" # unfortunately, we need to sleep for longer that 10 seconds here @@ -119,6 +123,7 @@ def test_no_output_timeout_in_provision(self, client, requests_mock): logfile = os.path.join(self.tmpdir, "testlog") fake_job_data = {"output_timeout": 1} requests_mock.post(rmock.ANY, status_code=200) + requests_mock.get(rmock.ANY, status_code=200) job = _TestflingerJob(fake_job_data, client) job.phase = "provision" # unfortunately, we need to sleep for longer that 10 seconds here From fc61c2e55632ee46648107aaa99a4f529dbf5e05 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 14 Apr 2023 09:27:55 -0500 Subject: [PATCH 491/569] Use read() instead of readline() in run_with_log() --- testflinger_agent/job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 8b6f67cd..69178925 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -217,7 +217,7 @@ def cleanup(signum, frame): set_nonblock(process.stdout.fileno()) while True: - line = process.stdout.readline() + line = process.stdout.read() if not line and process.poll() is not None: # Process exited break From 5c019482e11db7c2c6cd1a16ca6fb97c5e67db6e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 18 May 2023 14:23:01 -0500 Subject: [PATCH 492/569] Remove _status_worker process --- testflinger_agent/agent.py | 55 ++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index bd6f9b72..32aa486c 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -14,7 +14,6 @@ import json import logging -import multiprocessing import os import shutil import time @@ -28,39 +27,43 @@ class TestflingerAgent: def __init__(self, client): self.client = client - self._state = multiprocessing.Array("c", 16) self.set_agent_state("waiting") - self.advertised_queues = self.client.config.get( - "advertised_queues", {} - ) - self.advertised_images = self.client.config.get("advertised_images") + self._post_initial_agent_data() + + def _post_initial_agent_data(self): + """Post the initial agent data to the server once on agent startup""" + location = self.client.config.get("location", "") - if self.advertised_queues or location: + advertised_queues = self._post_advertised_queues() + self._post_advertised_images() + + if advertised_queues or location: self.client.post_agent_data( - {"queues": self.advertised_queues, "location": location} + {"queues": advertised_queues, "location": location} ) - if self.advertised_queues or self.advertised_images: - self.status_proc = multiprocessing.Process( - target=self._status_worker - ) - self.status_proc.daemon = True - self.status_proc.start() - - def _status_worker(self): - # Report advertised queues to testflinger server when we are listening - while True: - # Post every 2min unless the agent is offline - if self._state.value.decode("utf-8") != "offline": - if self.advertised_queues: - self.client.post_queues(self.advertised_queues) - if self.advertised_images: - self.client.post_images(self.advertised_images) - time.sleep(120) + + def _post_advertised_queues(self): + """ + Get the advertised queues from the config and send them to the server + + :return: Dictionary of advertised queues + """ + advertised_queues = self.client.config.get("advertised_queues", {}) + if advertised_queues: + self.client.post_queues(advertised_queues) + return advertised_queues + + def _post_advertised_images(self): + """ + Get the advertised images from the config and post them to the server + """ + advertised_images = self.client.config.get("advertised_images") + if advertised_images: + self.client.post_images(advertised_images) def set_agent_state(self, state): """Send the agent state to the server""" self.client.post_agent_data({"state": state}) - self._state.value = state.encode("utf-8") def get_offline_files(self): # Return possible restart filenames with and without dashes From 1ac31f705195a10f1ed1c075086ab9763a4d644b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 13 Jun 2023 15:21:29 -0500 Subject: [PATCH 493/569] Raise an exception if server URL isn't specified for multi-agent --- .../devices/multi/tests/test_multi.py | 10 ++++++++++ src/snappy_device_agents/devices/multi/tfclient.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/src/snappy_device_agents/devices/multi/tests/test_multi.py b/src/snappy_device_agents/devices/multi/tests/test_multi.py index 5ddf9279..4bbf48b4 100644 --- a/src/snappy_device_agents/devices/multi/tests/test_multi.py +++ b/src/snappy_device_agents/devices/multi/tests/test_multi.py @@ -15,6 +15,8 @@ """Unit tests for multi-device support code.""" from uuid import uuid4 + +import pytest from snappy_device_agents.devices.multi.multi import Multi from snappy_device_agents.devices.multi.tfclient import TFClient @@ -27,6 +29,14 @@ def submit_job(self, job_data): return str(uuid4()) +def test_bad_tfclient_url(): + """Test that Multi raises an exception when TFClient URL is bad""" + with pytest.raises(ValueError): + TFClient(None) + with pytest.raises(ValueError): + TFClient("foo.com") + + def test_inject_allocate_data(): """Test that allocate_data section is injected into job""" test_config = {"agent_name": "test_agent"} diff --git a/src/snappy_device_agents/devices/multi/tfclient.py b/src/snappy_device_agents/devices/multi/tfclient.py index 5b1daf6f..ca7e849d 100644 --- a/src/snappy_device_agents/devices/multi/tfclient.py +++ b/src/snappy_device_agents/devices/multi/tfclient.py @@ -30,6 +30,11 @@ def __init__(self, url): :param url: URL of the Testflinger server """ + if not url or not url.startswith("http"): + raise ValueError( + "Config item testflinger_server URL for multi-device agents" + " must be specified and must start with http or https!" + ) self.server = url def get(self, uri_frag, timeout=15): From 42491cd01a88c1bc8fda171e9880480b0430cb6e Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 14 Jun 2023 23:58:21 -0700 Subject: [PATCH 494/569] maas device agent storage module wip --- .../devices/maas2/maas_storage.py | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 src/snappy_device_agents/devices/maas2/maas_storage.py diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py new file mode 100644 index 00000000..a87770b9 --- /dev/null +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -0,0 +1,491 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu MaaS 2.x CLI support code.""" + +import logging +import subprocess +import collections + +from snappy_device_agents.devices import ProvisioningError + +logger = logging.getLogger() + + +class ConfigureMaasStorage: + def __init__(self, maas_user, node_id): + self.maas_user = maas_user + self.node_id = node_id + self.node_info = self._node_read(maas_user, node_id) + + def _logger_info(self, message): + logger.info("MAAS: {}".format(message)) + + def _node_read(self): + cmd = ["maas", self.maas_user, "machine", "read", self.node_id] + return self.call_cmd(cmd) + + def _entries_of_type(self, config, entry_type): + """Get all of the config entries of a specific type.""" + return [entry for entry in config if entry["type"] == entry_type] + + def parse_disk_types(self, disk_list): + disks_by_type = collections.defaultdict(list) + disk_dict = {disk['id']: disk for disk in disk_list} + for disk in disk_list: + parent_id = disk.get('device') or disk.get('volume') + + # assign tlp to disk + if disk['type'] == 'disk': + disk['top_level_parent'] = disk['id'] # drop in block_id + + while parent_id and parent_id in disk_dict: + parent = disk_dict[parent_id] + parent_id = parent.get('device') or parent.get('volume') + if parent['type'] == 'disk': + disk['top_level_parent'] = parent['id'] # drop in block_id + + disks_by_type[disk["type"]].append(disk) + + return disks_by_type + + def call_cmd(self, cmd): + """subprocess placeholder""" + self._logger_info("Configuring node storage") + proc = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False + ) + if proc.returncode: + self._logger_error(f"maas error running: {' '.join(cmd)}") + raise ProvisioningError(proc.stdout.decode()) + + # def map_bucket_disks_to_machine_disks(self): + # """Go from abstract bucket disks to concrete machine disks. + + # This maps the indexes of disks in the config to blockdevices on + # this specific machine in MAAS, so we can refer to the blockdevices + # in the API later on. + + # We iterate over the set of disks referenced in the machine's bucket, + # and find a disk from the MAAS machine that matches the disk's + # characteristics. When we find a matching disk, we record that + # in the disks_to_blockdevices map, and add it to the used list + # so we don't try to use the same blockdevice more than once. + + # The mapping we end up with depends on the order of disks from + # the config, which can change, since it's a dictionary in python, + # and the order of the blockdevices listed in MAAS, which may or + # may not be stable. This means we may get different config disk + # to actual disk mappings for the same config applied to the same + # machine when this is run multiple times. That's ok - we don't + # care which physical disk is chosen, as long as it has matching + # characteristics. + # """ + # bucket = self.buckets[self.get_bucket_for_machine(self.node_info["fqdn"])] + # used = [] + # return { + # disk_id: self.find_matching_blockdevice(self.node_info, disk_info, used) + # for disk_id, disk_info in bucket["hardware"]["disks"].items() + # } + + def map_disk_device_to_blockdevice( + self, disk_config, config_disk_to_blockdevice + ): + """Maps the 'id' of each disk entry in the disk config + to a specific blockdevice on this specific machine in MAAS. + That gives us the ID we need to refer to the specific block + device during API calls to MAAS. It's handy to have this map + in addition to the "bucket config disk id" -> blockdevice map, + because the disk config clauses all refer to this disk device + id, not the bucket config disk id. + """ + disk_device_to_blockdevice = {} + for disk in self._entries_of_type(disk_config, "disk"): + blockdevice = config_disk_to_blockdevice[disk["disk"]] + disk_device_to_blockdevice[disk["id"]] = blockdevice + return disk_device_to_blockdevice + + def humanized_size(self, num, system_unit=1024): + for suffix in ["", "K", "M", "G", "T", "P", "E", "Z"]: + if num < system_unit: + return "%d%s" % (round(num), suffix) + num = num / system_unit + return "%dY" % (round(num)) + + def round_disk_size(self, disk_size): + return round(float(disk_size) / (5 * 1000**3)) * (5 * 1000**3) + + def find_matching_blockdevice(self, disk, used): # <------ + """Match MAAS blockdevice and config device.""" + if disk["businfo"] is None: + for blockdevice in self.node_info["blockdevice_set"]: + if blockdevice["type"] != "physical": + continue + size = self.humanized_size( + self.round_disk_size(blockdevice["size"]), system_unit=1000 + ) + if disk["size"] != size: + continue + if ( + disk["tags"] == blockdevice["tags"] + and blockdevice["id"] not in used + ): + used.append(blockdevice["id"]) + return blockdevice + raise KeyError("no blockdevice found for disk %s" % (disk)) + + block_device = self._match_block_device(self.node_info, disk, used) + if block_device: + return block_device + raise KeyError("no blockdevice found for disk %s" % (disk)) + + def _match_block_device(self, disk, used_devices): + # Post MAAS 2.4.0 implementation if matching is performed by using + # using an id_path retrieved via businfo + device_id_paths = self.get_block_id_paths_by_businfo( + str(self.node_info["system_id"]), disk["businfo"] + ) + for blockdevice in self.node_info["blockdevice_set"]: + if blockdevice["type"] != "physical": + continue + if ( + blockdevice["id_path"] in device_id_paths + and blockdevice["id"] not in used_devices + ): + used_devices.append(blockdevice["id"]) + return blockdevice + elif ( + blockdevice["serial"] in device_id_paths + and blockdevice["id"] not in used_devices + ): + used_devices.append(blockdevice["id"]) + return blockdevice + + def clear_storage_config(self): + blockdevice_set = self.read_blockdevices() + for blockdevice in blockdevice_set: + if blockdevice["type"] == "virtual": + continue + for partition in blockdevice["partitions"]: + self.call_cmd( + [ + "maas", + self.maas_user, + "partition", + "delete", + self.node_id, + str(blockdevice["id"]), + str(partition["id"]), + ] + ) + if blockdevice["filesystem"] is not None: + if blockdevice["filesystem"]["mount_point"] is not None: + self.call_cmd( + [ + "maas", + self.maas_user, + "block-device", + "unmount", + self.node_id, + str(blockdevice["id"]), + ] + ) + + self.call_cmd( + [ + "maas", + self.maas_user, + "block-device", + "unformat", + self.node_id, + blockdevice["id"], + ] + ) + self.call_cmd( + [ + "maas", + self.maas_user, + "block-device", + "unformat", + self.node_id, + str(blockdevice["id"]), + ] + ) + + def mount_blockdevice(self, blockdevice_id, mount_point): + self.call_cmd( + [ + "maas", + self.maas_user, + "block-device", + "mount", + self.node_id, + blockdevice_id, + f"mount_point={mount_point}", + ] + ) + + def mount_partition(self, blockdevice_id, partition_id, mount_point): + self.call_cmd( + [ + "maas", + self.maas_user, + "partition", + "mount", + self.node_id, + blockdevice_id, + partition_id, + f"mount_point={mount_point}", + ] + ) + + def format_partition(self, blockdevice_id, partition_id, fstype, label): + self.call_cmd( + [ + "maas", + self.maas_user, + "partition", + "format", + self.node_id, + blockdevice_id, + partition_id, + f"fstype={fstype}", + f"label={label}", + ] + ) + + def create_partition(self, blockdevice_id, size=None): + cmd = [ + "maas", + self.maas_user, + "partitions", + "create", + self.node_id, + blockdevice_id, + ] + if size is not None: + cmd.append(f"size={size}") + return self.call_cmd(cmd) + + def set_boot_disk(self, blockdevice_id): + self.call_cmd( + [ + "maas", + self.maas_user, + "block-device", + "set-boot-disk", + self.node_id, + blockdevice_id, + ] + ) + + def update_blockdevice(self, blockdevice_id, opts=None): + """Update a block-device. + + :param str self.maas_user: The maas cli profile to use. + :param str self.node_id: The self.node_id of the machine. + :param str blockdevice_id: The id of the block-device. + :param dict opts: A dictionary of options to apply. + :returns: The updated MAAS API block-device dictionary. + """ + cmd = [ + "maas", + self.maas_user, + "block-device", + "update", + self.node_id, + blockdevice_id, + ] + if opts is not None: + for k, v in opts.items(): + cmd.append(f"{k}={v}") + return self.call_cmd(cmd) + + def format_blockdevice(self, blockdevice_id, fstype, label): + self.call_cmd( + [ + "maas", + self.maas_user, + "block-device", + "format", + self.node_id, + blockdevice_id, + f"fstype={fstype}", + f"label={label}", + ] + ) + + def read_blockdevices(self): + cmd = [ + "maas", + self.maas_user, + "block-devices", + "read", + self.node_id, + ] + return self.call_cmd(cmd) + + def get_disksize_real_value(self, value): + """Sizes can use M, G, T suffixes.""" + try: + real_value = str(int(value)) + return real_value + except ValueError as error: + for n, suffix in enumerate(["M", "G", "T"]): + if value[-1].capitalize() == suffix: + return str(int(float(value[:-1]) * 1000 ** (n + 2))) + raise error + + def partition_disks(self, disk_config, disk_device_to_blockdevice): + """Partition the disks on a specific machine.""" + # Find and create the partitions on this disk + partitions = self._entries_of_type(disk_config, "partition") + partitions = sorted(partitions, key=lambda k: k["number"]) + # maps config partition ids to maas partition ids + partition_map = {} + for partition in partitions: + disk_maas_id = disk_device_to_blockdevice[partition["device"]][ + "id" + ] + self._logger_info("Creating partition %s", partition["id"]) + # If size is not specified, all avaiable space is used + if "size" not in partition or not partition["size"]: + disksize_value = None + else: + disksize_value = self.get_disksize_real_value( + partition["size"] + ) + + partition_id = self.create_partition( + self.self.maas_user, + self.node_id, + str(disk_maas_id), + size=disksize_value, + )["id"] + partition_map[partition["id"]] = { + "partition_id": partition_id, + "blockdevice_id": disk_maas_id, + } + return partition_map + + def update_disks(self, disk_config, disk_device_to_blockdevice): + """Update the settings for disks on a machine. + + :param str self.node_id: The self.node_id of the machine. + :param list disk_config: The disk config for a machine. + :param dict disk_device_to_blockdevice: maps config disk ids + to maas API block-devices. + """ + for disk in self._entries_of_type(disk_config, "disk"): + if "boot" in disk and disk["boot"]: + logging.warn( + "Setting boot disk only applies to" + " legacy (non-EFI) booting systems!" + ) + self.set_boot_disk( + self.self.maas_user, + self.node_id, + str(disk_device_to_blockdevice[disk["id"]]["id"]), + ) + if "name" in disk: + self.update_blockdevice( + self.self.maas_user, + self.node_id, + str(disk_device_to_blockdevice[disk["id"]]["id"]), + opts={"name": disk["name"]}, + ) + + def apply_formats( + self, disk_config, partition_map, disk_device_to_blockdevice + ): + """Apply formats on the volumes of a specific machine.""" + # Format the partitions we created, or disks! + for _format in self._entries_of_type(disk_config, "format"): + self._logger_info("applying format %s", _format["id"]) + if _format["volume"] in partition_map: + partition_info = partition_map[_format["volume"]] + self.format_partition( + self.user_id, + self.node_id, + str(partition_info["blockdevice_id"]), + str(partition_info["partition_id"]), + _format["fstype"], + _format["label"], + ) + else: + device_info = disk_device_to_blockdevice[_format["volume"]] + self.format_blockdevice( + self.user_id, + self.node_id, + str(device_info["id"]), + _format["fstype"], + _format["label"], + ) + + def create_mounts( + self, disk_config, partition_map, disk_device_to_blockdevice + ): + """Create mounts on a specific machine.""" + # Create mounts for the formatted partitions + # rename to get_config? + for mount in self._entries_of_type(self.disk_config, "mount"): + self._logger_info("applying mount %s", mount["id"]) + volume_name = mount["device"][:-7] # strip _format + if volume_name in partition_map: + partition_info = partition_map[volume_name] + self.mount_partition( + self.user_id, + self.node_id, + str(partition_info["blockdevice_id"]), + str(partition_info["partition_id"]), + mount["path"], + ) + else: + device_info = disk_device_to_blockdevice[volume_name] + self.mount_blockdevice( + self.user_id, + self.node_id, + str(device_info["id"]), + mount["path"], + ) + + def setup_storage(self, config): + """Setup storage on a specific machine.""" + self._logger_info("Clearing previous storage configuration") + self.clear_storage_config() + # config_disk_to_blockdevice = self.map_bucket_disks_to_machine_disks() + disks_by_type = self.parse_disk_types() + disk_device_to_blockdevice = self.map_disk_device_to_blockdevice( + config["disks"], disks_by_type + ) + # apply updates to the disks. + self.update_disks(config["disks"], disk_device_to_blockdevice) + # partition disks and keep map of config partitions + # to partition ids in maas + partition_map = self.partition_disks( + config["disks"], disk_device_to_blockdevice + ) + # format volumes and create mount points + self.apply_formats( + self.node_id, + config["disks"], + partition_map, + disk_device_to_blockdevice, + ) + self.create_mounts( + self.node_id, + # config["disks"], + partition_map, + disk_device_to_blockdevice, + ) From 50ee738f41a315e32321cf33dd3d2b0a97733e94 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 20 Jun 2023 22:32:25 -0500 Subject: [PATCH 495/569] Check job_list.json exists for multi-jobs and return error if missing --- src/snappy_device_agents/devices/multi/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/snappy_device_agents/devices/multi/__init__.py b/src/snappy_device_agents/devices/multi/__init__.py index 2b1fa0d5..8c504437 100644 --- a/src/snappy_device_agents/devices/multi/__init__.py +++ b/src/snappy_device_agents/devices/multi/__init__.py @@ -16,6 +16,7 @@ import json import logging +import os import yaml import snappy_device_agents @@ -89,9 +90,15 @@ def runtest(self, args): snappy_device_agents.logmsg(logging.INFO, "END testrun") return exitcode - def get_job_list_data(self): - """Read job_list.json and return the data""" - with open("job_list.json") as job_list_file: + def get_job_list_data(self, job_list_file: str = "job_list.json") -> list: + """Read job_list.json and return the list data""" + if not os.path.exists(job_list_file): + logmsg( + logging.ERROR, + "Unable to find multi-job data file, job_list.json not found", + ) + return [] + with open(job_list_file) as job_list_file: job_list_data = json.load(job_list_file) return job_list_data From 163e31031b1f57e5500d98c33758839a0ad0d8be Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Sun, 25 Jun 2023 23:41:04 -0700 Subject: [PATCH 496/569] Complete overhaul of maas_storage.py to functional version. Necessary modifications to caller in maas2.py --- .../devices/maas2/maas2.py | 20 +- .../devices/maas2/maas_storage.py | 789 +++++++++--------- 2 files changed, 422 insertions(+), 387 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index d1ff8e74..81f49d7a 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -24,6 +24,8 @@ import yaml from snappy_device_agents.devices import ProvisioningError, RecoveryError +from snappy_device_agents.devices.maas2.maas_storage import MaasStorage + logger = logging.getLogger() @@ -72,7 +74,12 @@ def provision(self): distro = provision_data.get("distro", "xenial") kernel = provision_data.get("kernel") user_data = provision_data.get("user_data") - self.deploy_node(distro, kernel, user_data) + storage_data = provision_data.get("disks") + if storage_data: + maas_storage = MaasStorage( + self.maas_user, self.node_id, storage_data + ) + self.deploy_node(distro, kernel, user_data, maas_storage) def _install_efitools_snap(self): cmd = [ @@ -223,7 +230,13 @@ def _run_tpm_clear_cmd(self): return True return False - def deploy_node(self, distro="bionic", kernel=None, user_data=None): + def deploy_node( + self, + distro=None, + kernel=None, + user_data=None, + maas_storage=None + ): # Deploy the node in maas, default to bionic if nothing is specified self.recover() self._logger_info("Acquiring node") @@ -241,6 +254,9 @@ def deploy_node(self, distro="bionic", kernel=None, user_data=None): if proc.returncode: self._logger_error(f"maas error running: {' '.join(cmd)}") raise ProvisioningError(proc.stdout.decode()) + # provision storage if configured + if maas_storage: + maas_storage.configure_node_storage() self._logger_info( "Starting node {} " "with distro {}".format(self.agent_name, distro) diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index a87770b9..21c7f80e 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -12,172 +12,112 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Ubuntu MaaS 2.x CLI support code.""" +"""Ubuntu MaaS 2.x storage provisioning code.""" import logging import subprocess import collections +import json +import math -from snappy_device_agents.devices import ProvisioningError logger = logging.getLogger() -class ConfigureMaasStorage: - def __init__(self, maas_user, node_id): +class MaasStorageError(Exception): + def __init__(self, message): + super().__init__(message) + + +class MaasStorage: + def __init__(self, maas_user, node_id, storage_data): self.maas_user = maas_user self.node_id = node_id - self.node_info = self._node_read(maas_user, node_id) + self.device_list = storage_data + self.node_info = self._node_read() + self.block_ids = None + self.partition_list = None + + def _logger_debug(self, message): + logger.debug("MAAS: {}".format(message)) def _logger_info(self, message): logger.info("MAAS: {}".format(message)) def _node_read(self): - cmd = ["maas", self.maas_user, "machine", "read", self.node_id] - return self.call_cmd(cmd) + cmd = ["maas", self.maas_user, "block-devices", "read", self.node_id] + return self.call_cmd(cmd, output_json=True) - def _entries_of_type(self, config, entry_type): - """Get all of the config entries of a specific type.""" - return [entry for entry in config if entry["type"] == entry_type] + @staticmethod + def call_cmd(cmd, output_json=False): + logging.i(cmd) + proc = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False + ) + if proc.returncode: + raise MaasStorageError(proc.stdout.decode()) - def parse_disk_types(self, disk_list): - disks_by_type = collections.defaultdict(list) - disk_dict = {disk['id']: disk for disk in disk_list} - for disk in disk_list: - parent_id = disk.get('device') or disk.get('volume') + if proc.stdout: + output = proc.stdout.decode() - # assign tlp to disk - if disk['type'] == 'disk': - disk['top_level_parent'] = disk['id'] # drop in block_id + if output_json: + data = json.loads(output) + else: + data = output + + return data + + @staticmethod + def convert_size_to_bytes(size_str): + """Convert given sizes to bytes; case insensitive.""" + size_str = size_str.upper() + if "T" in size_str: + return round(float(size_str.replace("T", "")) * (1000**4)) + elif "G" in size_str: + return round(float(size_str.replace("G", "")) * (1000**3)) + elif "M" in size_str: + return round(float(size_str.replace("M", "")) * (1000**2)) + elif "K" in size_str: + return round(float(size_str.replace("K", "")) * 1000) + elif "B" in size_str: + return int(size_str.replace("B", "")) + else: + try: + # attempt to convert the size string to an integer + return int(size_str) + except ValueError: + raise MaasStorageError( + "Sizes must end in T, G, M, K, B, or be an integer " + "when no unit is provided." + ) - while parent_id and parent_id in disk_dict: - parent = disk_dict[parent_id] - parent_id = parent.get('device') or parent.get('volume') - if parent['type'] == 'disk': - disk['top_level_parent'] = parent['id'] # drop in block_id + def configure_node_storage(self): + """Configure the node's storage layout, from provisioning data.""" + self._logger_info(self.node_info) # debugging + self.assign_top_level_parent() + self.partition_list = self.gather_partitions() - disks_by_type[disk["type"]].append(disk) + self.block_ids = self.parse_block_devices() - return disks_by_type + self.map_block_ids() - def call_cmd(self, cmd): - """subprocess placeholder""" - self._logger_info("Configuring node storage") - proc = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False - ) - if proc.returncode: - self._logger_error(f"maas error running: {' '.join(cmd)}") - raise ProvisioningError(proc.stdout.decode()) - - # def map_bucket_disks_to_machine_disks(self): - # """Go from abstract bucket disks to concrete machine disks. - - # This maps the indexes of disks in the config to blockdevices on - # this specific machine in MAAS, so we can refer to the blockdevices - # in the API later on. - - # We iterate over the set of disks referenced in the machine's bucket, - # and find a disk from the MAAS machine that matches the disk's - # characteristics. When we find a matching disk, we record that - # in the disks_to_blockdevices map, and add it to the used list - # so we don't try to use the same blockdevice more than once. - - # The mapping we end up with depends on the order of disks from - # the config, which can change, since it's a dictionary in python, - # and the order of the blockdevices listed in MAAS, which may or - # may not be stable. This means we may get different config disk - # to actual disk mappings for the same config applied to the same - # machine when this is run multiple times. That's ok - we don't - # care which physical disk is chosen, as long as it has matching - # characteristics. - # """ - # bucket = self.buckets[self.get_bucket_for_machine(self.node_info["fqdn"])] - # used = [] - # return { - # disk_id: self.find_matching_blockdevice(self.node_info, disk_info, used) - # for disk_id, disk_info in bucket["hardware"]["disks"].items() - # } - - def map_disk_device_to_blockdevice( - self, disk_config, config_disk_to_blockdevice - ): - """Maps the 'id' of each disk entry in the disk config - to a specific blockdevice on this specific machine in MAAS. - That gives us the ID we need to refer to the specific block - device during API calls to MAAS. It's handy to have this map - in addition to the "bucket config disk id" -> blockdevice map, - because the disk config clauses all refer to this disk device - id, not the bucket config disk id. - """ - disk_device_to_blockdevice = {} - for disk in self._entries_of_type(disk_config, "disk"): - blockdevice = config_disk_to_blockdevice[disk["disk"]] - disk_device_to_blockdevice[disk["id"]] = blockdevice - return disk_device_to_blockdevice - - def humanized_size(self, num, system_unit=1024): - for suffix in ["", "K", "M", "G", "T", "P", "E", "Z"]: - if num < system_unit: - return "%d%s" % (round(num), suffix) - num = num / system_unit - return "%dY" % (round(num)) - - def round_disk_size(self, disk_size): - return round(float(disk_size) / (5 * 1000**3)) * (5 * 1000**3) - - def find_matching_blockdevice(self, disk, used): # <------ - """Match MAAS blockdevice and config device.""" - if disk["businfo"] is None: - for blockdevice in self.node_info["blockdevice_set"]: - if blockdevice["type"] != "physical": - continue - size = self.humanized_size( - self.round_disk_size(blockdevice["size"]), system_unit=1000 - ) - if disk["size"] != size: - continue - if ( - disk["tags"] == blockdevice["tags"] - and blockdevice["id"] not in used - ): - used.append(blockdevice["id"]) - return blockdevice - raise KeyError("no blockdevice found for disk %s" % (disk)) - - block_device = self._match_block_device(self.node_info, disk, used) - if block_device: - return block_device - raise KeyError("no blockdevice found for disk %s" % (disk)) - - def _match_block_device(self, disk, used_devices): - # Post MAAS 2.4.0 implementation if matching is performed by using - # using an id_path retrieved via businfo - device_id_paths = self.get_block_id_paths_by_businfo( - str(self.node_info["system_id"]), disk["businfo"] - ) - for blockdevice in self.node_info["blockdevice_set"]: - if blockdevice["type"] != "physical": - continue - if ( - blockdevice["id_path"] in device_id_paths - and blockdevice["id"] not in used_devices - ): - used_devices.append(blockdevice["id"]) - return blockdevice - elif ( - blockdevice["serial"] in device_id_paths - and blockdevice["id"] not in used_devices - ): - used_devices.append(blockdevice["id"]) - return blockdevice + self.create_partition_sizes() + + devs_by_type = self.group_by_type() + + # clear existing storage on node + self.clear_storage_config() + + # apply configured storage to node + self.process_by_type(devs_by_type) def clear_storage_config(self): - blockdevice_set = self.read_blockdevices() - for blockdevice in blockdevice_set: - if blockdevice["type"] == "virtual": + """Clear the node's exisitng storage configuration.""" + self._logger_info('Clearing existing storage configuration:') + for blkdev in self.node_info: + if blkdev["type"] == "virtual": continue - for partition in blockdevice["partitions"]: + for partition in blkdev["partitions"]: self.call_cmd( [ "maas", @@ -185,12 +125,12 @@ def clear_storage_config(self): "partition", "delete", self.node_id, - str(blockdevice["id"]), + str(blkdev["id"]), str(partition["id"]), ] ) - if blockdevice["filesystem"] is not None: - if blockdevice["filesystem"]["mount_point"] is not None: + if blkdev["filesystem"] is not None: + if blkdev["filesystem"]["mount_point"] is not None: self.call_cmd( [ "maas", @@ -198,10 +138,9 @@ def clear_storage_config(self): "block-device", "unmount", self.node_id, - str(blockdevice["id"]), + str(blkdev["id"]), ] ) - self.call_cmd( [ "maas", @@ -209,7 +148,7 @@ def clear_storage_config(self): "block-device", "unformat", self.node_id, - blockdevice["id"], + str(blkdev["id"]), ] ) self.call_cmd( @@ -219,273 +158,353 @@ def clear_storage_config(self): "block-device", "unformat", self.node_id, - str(blockdevice["id"]), + str(blkdev["id"]), ] ) - def mount_blockdevice(self, blockdevice_id, mount_point): - self.call_cmd( - [ - "maas", - self.maas_user, - "block-device", - "mount", - self.node_id, - blockdevice_id, - f"mount_point={mount_point}", - ] - ) + def assign_top_level_parent(self): + """Transverse device hierarchy to determine each device's parent + disk.""" + dev_dict = {dev["id"]: dev for dev in self.device_list} + for dev in self.device_list: + parent_id = dev.get("device") or dev.get("volume") + + if dev["type"] == "disk": + # keep 'top_level_parent' key for consistency + dev["top_level_parent"] = dev["id"] + + while parent_id and parent_id in dev_dict: + parent = dev_dict[parent_id] + parent_id = parent.get("device") or parent.get("volume") + if parent["type"] == "disk": + dev["top_level_parent"] = parent["id"] + + def gather_partitions(self): + """Tally partition size requirements for block-device selection.""" + partitions = collections.defaultdict(list) + + for dev in self.device_list: + if dev["type"] == "partition": + # convert size to bytes before appending to the list + partitions[dev["top_level_parent"]].append( + self.convert_size_to_bytes(dev["size"]) + ) - def mount_partition(self, blockdevice_id, partition_id, mount_point): - self.call_cmd( - [ - "maas", - self.maas_user, - "partition", - "mount", - self.node_id, - blockdevice_id, - partition_id, - f"mount_point={mount_point}", - ] - ) + # summing up sizes of each disk's partitions + partition_sizes = { + devid: sum(partitions) for devid, partitions in partitions.items() + } - def format_partition(self, blockdevice_id, partition_id, fstype, label): - self.call_cmd( - [ - "maas", - self.maas_user, - "partition", - "format", - self.node_id, - blockdevice_id, - partition_id, - f"fstype={fstype}", - f"label={label}", - ] - ) + return partition_sizes - def create_partition(self, blockdevice_id, size=None): - cmd = [ - "maas", - self.maas_user, - "partitions", - "create", - self.node_id, - blockdevice_id, - ] - if size is not None: - cmd.append(f"size={size}") - return self.call_cmd(cmd) + def parse_block_devices(self): + """Find appropriate node block-device for use in layout.""" + block_ids = {} + mapped_block_ids = set() - def set_boot_disk(self, blockdevice_id): - self.call_cmd( - [ - "maas", - self.maas_user, - "block-device", - "set-boot-disk", - self.node_id, - blockdevice_id, - ] - ) + for dev_id, size in self.partition_list.items(): + self._logger_info(f"Comparing size: Partition: {size}") + for blkdev in self.node_info: + if ( + blkdev["type"] != "physical" + or blkdev["id"] in mapped_block_ids + ): + continue - def update_blockdevice(self, blockdevice_id, opts=None): - """Update a block-device. + size_bd = int(blkdev["size"]) + self._logger_info(f"Comparing size: Partition: {size}, " + f"Block Device: {size_bd}") - :param str self.maas_user: The maas cli profile to use. - :param str self.node_id: The self.node_id of the machine. - :param str blockdevice_id: The id of the block-device. - :param dict opts: A dictionary of options to apply. - :returns: The updated MAAS API block-device dictionary. - """ - cmd = [ - "maas", - self.maas_user, - "block-device", - "update", - self.node_id, - blockdevice_id, - ] - if opts is not None: - for k, v in opts.items(): - cmd.append(f"{k}={v}") - return self.call_cmd(cmd) + if size <= size_bd: + # map disk_id to block device id + block_ids[dev_id] = blkdev["id"] + mapped_block_ids.add(blkdev["id"]) + break + else: + raise MaasStorageError( + "No suitable block-device found for partition " + f"{dev_id} with size {size} bytes" + ) + return block_ids + + def map_block_ids(self): + """Map parent disks to actual node block-device.""" + for dev in self.device_list: + block_id = self.block_ids.get(dev["top_level_parent"]) + if block_id is not None: + dev["top_level_parent_block_id"] = str(block_id) + + def validate_alloc_pct_values(self): + """Sanity check partition allocation percentages.""" + alloc_pct_values = collections.defaultdict(int) + + for dev in self.device_list: + if dev["type"] == "partition": + # add pct together (default to 0 if unnused) + alloc_pct_values[dev["top_level_parent"]] += dev.get( + "alloc_pct", 0 + ) + + for dev_id, alloc_pct in alloc_pct_values.items(): + if alloc_pct > 100: + raise MaasStorageError( + "The total percentage of the partitions on disk " + f"'{dev_id}' exceeds 100." + ) - def format_blockdevice(self, blockdevice_id, fstype, label): + def create_partition_sizes(self): + """Calculate actual partition size to write to disk.""" + self.validate_alloc_pct_values() + + for dev in self.device_list: + if dev["type"] == "partition": + # find corresponding block device + for blkdev in self.node_info: + if blkdev["id"] == self.block_ids[dev["top_level_parent"]]: + # get the total size of the block device in bytes + total_size = int(blkdev["size"]) + break + + if "alloc_pct" in dev: + # round pct up if necessary + dev["size"] = str( + math.ceil( + (total_size * dev.get("alloc_pct", 0)) / 100 + ) + ) + else: + if "size" not in dev: + raise ValueError( + f"Partition '{dev['id']}' does not have an " + "alloc_pct or size value." + ) + else: + # default to minimum required partition size + dev["size"] = self.convert_size_to_bytes(dev["size"]) + + def group_by_type(self): + """Group storage device by type for processing.""" + devs_by_type = collections.defaultdict(list) + + for dev in self.device_list: + devs_by_type[dev["type"]].append(dev) + + return devs_by_type + + def process_by_type(self, devs_by_type): + """Process each storage type together in dependancy sequence.""" + # order in which storage types are processed + type_order = ["disk", "partition", "format", "mount"] + # maps the type of a disk (like 'disk', 'partition', etc.) + # to the corresponding function that processes it. + type_to_method = { + "disk": self.process_disk, + "partition": self.process_partition, + "mount": self.process_mount, + "format": self.process_format, + } + part_data = {} + + # batch process storage devices + for type_ in type_order: + devices = devs_by_type.get(type_) + if devices: + self._logger_info(f"Processing type '{type_}':") + for dev in devices: + try: + if type_ == "partition": + part_data[dev["id"]] = type_to_method[type_](dev) + else: + type_to_method[type_](dev) + # do not proceed to subsequent/child types + except MaasStorageError as error: + raise MaasStorageError( + f"Unable to process device: {dev} " + f"of type: {type_}") from error + + def _set_boot_disk(self, block_id): + """Mark node block-device as boot disk.""" + self._logger_info(f"Setting boot disk {block_id}") + # self.call_cmd( self.call_cmd( [ "maas", self.maas_user, "block-device", - "format", + "set-boot-disk", self.node_id, - blockdevice_id, - f"fstype={fstype}", - f"label={label}", + block_id, ] ) - def read_blockdevices(self): + def _get_child_device(self, parent_device): + """Get children devices from parent device.""" + children = [] + for dev in self.device_list: + if dev['top_level_parent'] == parent_device['id']: + children.append(dev) + return children + + def process_disk(self, device): + """Process block level storage (disks).""" + self._logger_info("Disk:") + self._logger_info( + { + "device_id": device['id'], + "name": device['name'], + "number": device.get('number'), + "block-id": device['top_level_parent_block_id'], + } + ) + # find boot mounts on child types + children = self._get_child_device(device) + + for child in children: + if child["type"] == "mount" and "/boot" in child["path"]: + self._logger_info( + f"Disk {device['id']} has a child mount with " + f"'boot' in its path: {child['path']}" + ) + self._set_boot_disk(device['top_level_parent_block_id']) + break + # apply disk name + if "name" in device: + # self.call_cmd( + self.call_cmd( + [ + "maas", + self.maas_user, + "block-device", + "update", + self.node_id, + device['top_level_parent_block_id'], + f"name={device['name']}" + ] + ) + + def _create_partition(self, device): + """Create parition on disk and return the resulting parition-id.""" cmd = [ "maas", self.maas_user, - "block-devices", - "read", + "partitions", + "create", self.node_id, + device['top_level_parent_block_id'], + f"size={device['size']}", ] - return self.call_cmd(cmd) - def get_disksize_real_value(self, value): - """Sizes can use M, G, T suffixes.""" - try: - real_value = str(int(value)) - return real_value - except ValueError as error: - for n, suffix in enumerate(["M", "G", "T"]): - if value[-1].capitalize() == suffix: - return str(int(float(value[:-1]) * 1000 ** (n + 2))) - raise error - - def partition_disks(self, disk_config, disk_device_to_blockdevice): - """Partition the disks on a specific machine.""" - # Find and create the partitions on this disk - partitions = self._entries_of_type(disk_config, "partition") - partitions = sorted(partitions, key=lambda k: k["number"]) - # maps config partition ids to maas partition ids - partition_map = {} - for partition in partitions: - disk_maas_id = disk_device_to_blockdevice[partition["device"]][ - "id" - ] - self._logger_info("Creating partition %s", partition["id"]) - # If size is not specified, all avaiable space is used - if "size" not in partition or not partition["size"]: - disksize_value = None - else: - disksize_value = self.get_disksize_real_value( - partition["size"] - ) + return self.call_cmd(cmd) - partition_id = self.create_partition( - self.self.maas_user, - self.node_id, - str(disk_maas_id), - size=disksize_value, - )["id"] - partition_map[partition["id"]] = { - "partition_id": partition_id, - "blockdevice_id": disk_maas_id, + def process_partition(self, device): + """Process given partitions from the storage layout config.""" + self._logger_info("Partition:") + self._logger_info( + { + "device_id": device['id'], + "size": device['size'], + "number": device.get('number'), + "parent disk": device['top_level_parent'], + "parent block-id": device['top_level_parent_block_id'], } - return partition_map - - def update_disks(self, disk_config, disk_device_to_blockdevice): - """Update the settings for disks on a machine. - - :param str self.node_id: The self.node_id of the machine. - :param list disk_config: The disk config for a machine. - :param dict disk_device_to_blockdevice: maps config disk ids - to maas API block-devices. - """ - for disk in self._entries_of_type(disk_config, "disk"): - if "boot" in disk and disk["boot"]: - logging.warn( - "Setting boot disk only applies to" - " legacy (non-EFI) booting systems!" - ) - self.set_boot_disk( - self.self.maas_user, - self.node_id, - str(disk_device_to_blockdevice[disk["id"]]["id"]), - ) - if "name" in disk: - self.update_blockdevice( - self.self.maas_user, - self.node_id, - str(disk_device_to_blockdevice[disk["id"]]["id"]), - opts={"name": disk["name"]}, - ) - - def apply_formats( - self, disk_config, partition_map, disk_device_to_blockdevice - ): - """Apply formats on the volumes of a specific machine.""" - # Format the partitions we created, or disks! - for _format in self._entries_of_type(disk_config, "format"): - self._logger_info("applying format %s", _format["id"]) - if _format["volume"] in partition_map: - partition_info = partition_map[_format["volume"]] - self.format_partition( - self.user_id, + ) + partition_id = self._create_partition(device) + device['partition_id'] = partition_id + # device['partition_id'] = device['id'] + + def _get_format_partition_id(self, volume): + """Get the partition id from the specified format.""" + for dev in self.device_list: + if volume == dev['id']: + return dev['partition_id'] + + def process_format(self, device): + """Process given parition formats from the storage layout config.""" + self._logger_info("Format:") + self._logger_info( + { + "device_id": device['id'], + "fstype": device['fstype'], + "label": device['label'], + "parent disk": device['top_level_parent'], + "parent block-id": device['top_level_parent_block_id'], + } + ) + # format partition + if "volume" in device: + partition_id = self._get_format_partition_id(device['volume']) + self.call_cmd( + [ + "maas", + self.maas_user, + "partition", + "format", self.node_id, - str(partition_info["blockdevice_id"]), - str(partition_info["partition_id"]), - _format["fstype"], - _format["label"], - ) - else: - device_info = disk_device_to_blockdevice[_format["volume"]] - self.format_blockdevice( - self.user_id, + device['top_level_parent_block_id'], + partition_id, + f"fstype={device['fstype']}", + f"label={device['label']}", + ] + ) + # format blkdev + else: + self.call_cmd( + [ + "maas", + self.maas_user, + "partition", + "format", self.node_id, - str(device_info["id"]), - _format["fstype"], - _format["label"], - ) - - def create_mounts( - self, disk_config, partition_map, disk_device_to_blockdevice - ): - """Create mounts on a specific machine.""" - # Create mounts for the formatted partitions - # rename to get_config? - for mount in self._entries_of_type(self.disk_config, "mount"): - self._logger_info("applying mount %s", mount["id"]) - volume_name = mount["device"][:-7] # strip _format - if volume_name in partition_map: - partition_info = partition_map[volume_name] - self.mount_partition( - self.user_id, + device['top_level_parent_block_id'], + f"fstype={device['fstype']}", + f"label={device['label']}", + ] + ) + + def _get_mount_partition_id(self, device): + """Get the partiton-id of the specified mount path.""" + for dev in self.device_list: + if device == dev['id']: + return self._get_format_partition_id(dev['volume']) + + def process_mount(self, device): + """Process given mounts/paths from the storage layout config.""" + self._logger_info("Mount:") + self._logger_info( + { + "device_id": device['id'], + "path": device['path'], + "parent disk": device['top_level_parent'], + "parent block-id": device['top_level_parent_block_id'], + } + ) + partition_id = self._get_mount_partition_id(device['device']) + # mount on partition + if partition_id: + self._logger_info(f" on partition_id: {partition_id}") + self.call_cmd( + [ + "maas", + self.maas_user, + "partition", + "mount", self.node_id, - str(partition_info["blockdevice_id"]), - str(partition_info["partition_id"]), - mount["path"], - ) - else: - device_info = disk_device_to_blockdevice[volume_name] - self.mount_blockdevice( - self.user_id, + device['top_level_parent_block_id'], + partition_id, + f"mount_point={device['path']}", + ] + ) + # mount on block-device + else: + self._logger_info(f" on disk: {device['top_level_parent']}") + self.call_cmd( + [ + "maas", + self.maas_user, + "block-device", + "mount", self.node_id, - str(device_info["id"]), - mount["path"], - ) - - def setup_storage(self, config): - """Setup storage on a specific machine.""" - self._logger_info("Clearing previous storage configuration") - self.clear_storage_config() - # config_disk_to_blockdevice = self.map_bucket_disks_to_machine_disks() - disks_by_type = self.parse_disk_types() - disk_device_to_blockdevice = self.map_disk_device_to_blockdevice( - config["disks"], disks_by_type - ) - # apply updates to the disks. - self.update_disks(config["disks"], disk_device_to_blockdevice) - # partition disks and keep map of config partitions - # to partition ids in maas - partition_map = self.partition_disks( - config["disks"], disk_device_to_blockdevice - ) - # format volumes and create mount points - self.apply_formats( - self.node_id, - config["disks"], - partition_map, - disk_device_to_blockdevice, - ) - self.create_mounts( - self.node_id, - # config["disks"], - partition_map, - disk_device_to_blockdevice, - ) + device['top_level_parent_block_id'], + f"mount_point={device['path']}", + ] + ) From 4b0e82c3995272e8f8acf567f552c9b6b9081064 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Sun, 25 Jun 2023 23:42:03 -0700 Subject: [PATCH 497/569] Complete overhaul of maas_storage.py to functional version. Necessary modifications to caller in maas2.py --- .../devices/maas2/maas2.py | 6 +- .../devices/maas2/maas_storage.py | 83 ++++++++++--------- 2 files changed, 43 insertions(+), 46 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 81f49d7a..1d371489 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -231,11 +231,7 @@ def _run_tpm_clear_cmd(self): return False def deploy_node( - self, - distro=None, - kernel=None, - user_data=None, - maas_storage=None + self, distro=None, kernel=None, user_data=None, maas_storage=None ): # Deploy the node in maas, default to bionic if nothing is specified self.recover() diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index 21c7f80e..8ff41ca2 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -113,7 +113,7 @@ def configure_node_storage(self): def clear_storage_config(self): """Clear the node's exisitng storage configuration.""" - self._logger_info('Clearing existing storage configuration:') + self._logger_info("Clearing existing storage configuration:") for blkdev in self.node_info: if blkdev["type"] == "virtual": continue @@ -212,8 +212,10 @@ def parse_block_devices(self): continue size_bd = int(blkdev["size"]) - self._logger_info(f"Comparing size: Partition: {size}, " - f"Block Device: {size_bd}") + self._logger_info( + f"Comparing size: Partition: {size}, " + f"Block Device: {size_bd}" + ) if size <= size_bd: # map disk_id to block device id @@ -268,9 +270,7 @@ def create_partition_sizes(self): if "alloc_pct" in dev: # round pct up if necessary dev["size"] = str( - math.ceil( - (total_size * dev.get("alloc_pct", 0)) / 100 - ) + math.ceil((total_size * dev.get("alloc_pct", 0)) / 100) ) else: if "size" not in dev: @@ -320,7 +320,8 @@ def process_by_type(self, devs_by_type): except MaasStorageError as error: raise MaasStorageError( f"Unable to process device: {dev} " - f"of type: {type_}") from error + f"of type: {type_}" + ) from error def _set_boot_disk(self, block_id): """Mark node block-device as boot disk.""" @@ -341,7 +342,7 @@ def _get_child_device(self, parent_device): """Get children devices from parent device.""" children = [] for dev in self.device_list: - if dev['top_level_parent'] == parent_device['id']: + if dev["top_level_parent"] == parent_device["id"]: children.append(dev) return children @@ -350,10 +351,10 @@ def process_disk(self, device): self._logger_info("Disk:") self._logger_info( { - "device_id": device['id'], - "name": device['name'], - "number": device.get('number'), - "block-id": device['top_level_parent_block_id'], + "device_id": device["id"], + "name": device["name"], + "number": device.get("number"), + "block-id": device["top_level_parent_block_id"], } ) # find boot mounts on child types @@ -365,7 +366,7 @@ def process_disk(self, device): f"Disk {device['id']} has a child mount with " f"'boot' in its path: {child['path']}" ) - self._set_boot_disk(device['top_level_parent_block_id']) + self._set_boot_disk(device["top_level_parent_block_id"]) break # apply disk name if "name" in device: @@ -377,8 +378,8 @@ def process_disk(self, device): "block-device", "update", self.node_id, - device['top_level_parent_block_id'], - f"name={device['name']}" + device["top_level_parent_block_id"], + f"name={device['name']}", ] ) @@ -390,7 +391,7 @@ def _create_partition(self, device): "partitions", "create", self.node_id, - device['top_level_parent_block_id'], + device["top_level_parent_block_id"], f"size={device['size']}", ] @@ -401,38 +402,38 @@ def process_partition(self, device): self._logger_info("Partition:") self._logger_info( { - "device_id": device['id'], - "size": device['size'], - "number": device.get('number'), - "parent disk": device['top_level_parent'], - "parent block-id": device['top_level_parent_block_id'], + "device_id": device["id"], + "size": device["size"], + "number": device.get("number"), + "parent disk": device["top_level_parent"], + "parent block-id": device["top_level_parent_block_id"], } ) partition_id = self._create_partition(device) - device['partition_id'] = partition_id + device["partition_id"] = partition_id # device['partition_id'] = device['id'] def _get_format_partition_id(self, volume): """Get the partition id from the specified format.""" for dev in self.device_list: - if volume == dev['id']: - return dev['partition_id'] + if volume == dev["id"]: + return dev["partition_id"] def process_format(self, device): """Process given parition formats from the storage layout config.""" self._logger_info("Format:") self._logger_info( { - "device_id": device['id'], - "fstype": device['fstype'], - "label": device['label'], - "parent disk": device['top_level_parent'], - "parent block-id": device['top_level_parent_block_id'], + "device_id": device["id"], + "fstype": device["fstype"], + "label": device["label"], + "parent disk": device["top_level_parent"], + "parent block-id": device["top_level_parent_block_id"], } ) # format partition if "volume" in device: - partition_id = self._get_format_partition_id(device['volume']) + partition_id = self._get_format_partition_id(device["volume"]) self.call_cmd( [ "maas", @@ -440,7 +441,7 @@ def process_format(self, device): "partition", "format", self.node_id, - device['top_level_parent_block_id'], + device["top_level_parent_block_id"], partition_id, f"fstype={device['fstype']}", f"label={device['label']}", @@ -455,7 +456,7 @@ def process_format(self, device): "partition", "format", self.node_id, - device['top_level_parent_block_id'], + device["top_level_parent_block_id"], f"fstype={device['fstype']}", f"label={device['label']}", ] @@ -464,21 +465,21 @@ def process_format(self, device): def _get_mount_partition_id(self, device): """Get the partiton-id of the specified mount path.""" for dev in self.device_list: - if device == dev['id']: - return self._get_format_partition_id(dev['volume']) + if device == dev["id"]: + return self._get_format_partition_id(dev["volume"]) def process_mount(self, device): """Process given mounts/paths from the storage layout config.""" self._logger_info("Mount:") self._logger_info( { - "device_id": device['id'], - "path": device['path'], - "parent disk": device['top_level_parent'], - "parent block-id": device['top_level_parent_block_id'], + "device_id": device["id"], + "path": device["path"], + "parent disk": device["top_level_parent"], + "parent block-id": device["top_level_parent_block_id"], } ) - partition_id = self._get_mount_partition_id(device['device']) + partition_id = self._get_mount_partition_id(device["device"]) # mount on partition if partition_id: self._logger_info(f" on partition_id: {partition_id}") @@ -489,7 +490,7 @@ def process_mount(self, device): "partition", "mount", self.node_id, - device['top_level_parent_block_id'], + device["top_level_parent_block_id"], partition_id, f"mount_point={device['path']}", ] @@ -504,7 +505,7 @@ def process_mount(self, device): "block-device", "mount", self.node_id, - device['top_level_parent_block_id'], + device["top_level_parent_block_id"], f"mount_point={device['path']}", ] ) From 12f07213abde5282002c045e65c87c0090a7c398 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 26 Jun 2023 11:44:05 -0700 Subject: [PATCH 498/569] Refined how partition data is returned in order to process formats and mounts in subsequent steps. --- .../devices/maas2/maas2.py | 13 +++++++-- .../devices/maas2/maas_storage.py | 28 +++++-------------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 1d371489..c9180ca0 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -25,6 +25,7 @@ from snappy_device_agents.devices import ProvisioningError, RecoveryError from snappy_device_agents.devices.maas2.maas_storage import MaasStorage +from snappy_device_agents.devices.maas2.maas_storage import MaasStorageError logger = logging.getLogger() @@ -231,7 +232,11 @@ def _run_tpm_clear_cmd(self): return False def deploy_node( - self, distro=None, kernel=None, user_data=None, maas_storage=None + self, + distro=None, + kernel=None, + user_data=None, + maas_storage=None ): # Deploy the node in maas, default to bionic if nothing is specified self.recover() @@ -252,7 +257,11 @@ def deploy_node( raise ProvisioningError(proc.stdout.decode()) # provision storage if configured if maas_storage: - maas_storage.configure_node_storage() + try: + maas_storage.configure_node_storage() + except MaasStorageError as error: + self._logger_error(f"Unable to configure node storage {error}") + raise ProvisioningError(error) self._logger_info( "Starting node {} " "with distro {}".format(self.agent_name, distro) diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index 8ff41ca2..428ac9c5 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -50,7 +50,6 @@ def _node_read(self): @staticmethod def call_cmd(cmd, output_json=False): - logging.i(cmd) proc = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False ) @@ -61,11 +60,9 @@ def call_cmd(cmd, output_json=False): output = proc.stdout.decode() if output_json: - data = json.loads(output) - else: - data = output + return json.loads(output) - return data + return output @staticmethod def convert_size_to_bytes(size_str): @@ -151,16 +148,6 @@ def clear_storage_config(self): str(blkdev["id"]), ] ) - self.call_cmd( - [ - "maas", - self.maas_user, - "block-device", - "unformat", - self.node_id, - str(blkdev["id"]), - ] - ) def assign_top_level_parent(self): """Transverse device hierarchy to determine each device's parent @@ -391,11 +378,11 @@ def _create_partition(self, device): "partitions", "create", self.node_id, - device["top_level_parent_block_id"], + device['top_level_parent_block_id'], f"size={device['size']}", ] - return self.call_cmd(cmd) + return self.call_cmd(cmd, output_json=True) def process_partition(self, device): """Process given partitions from the storage layout config.""" @@ -409,9 +396,8 @@ def process_partition(self, device): "parent block-id": device["top_level_parent_block_id"], } ) - partition_id = self._create_partition(device) - device["partition_id"] = partition_id - # device['partition_id'] = device['id'] + partition_data = self._create_partition(device) + device['partition_id'] = str(partition_data['id']) def _get_format_partition_id(self, volume): """Get the partition id from the specified format.""" @@ -447,7 +433,7 @@ def process_format(self, device): f"label={device['label']}", ] ) - # format blkdev + # format block-device else: self.call_cmd( [ From 37f4450f2b508b2e5c0b3f6ac30d6bf24b42a406 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 26 Jun 2023 11:44:59 -0700 Subject: [PATCH 499/569] Refined how partition data is returned in order to process formats and mounts in subsequent steps. --- src/snappy_device_agents/devices/maas2/maas2.py | 6 +----- src/snappy_device_agents/devices/maas2/maas_storage.py | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index c9180ca0..40dd4e15 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -232,11 +232,7 @@ def _run_tpm_clear_cmd(self): return False def deploy_node( - self, - distro=None, - kernel=None, - user_data=None, - maas_storage=None + self, distro=None, kernel=None, user_data=None, maas_storage=None ): # Deploy the node in maas, default to bionic if nothing is specified self.recover() diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index 428ac9c5..eebcfb8a 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -378,7 +378,7 @@ def _create_partition(self, device): "partitions", "create", self.node_id, - device['top_level_parent_block_id'], + device["top_level_parent_block_id"], f"size={device['size']}", ] @@ -397,7 +397,7 @@ def process_partition(self, device): } ) partition_data = self._create_partition(device) - device['partition_id'] = str(partition_data['id']) + device["partition_id"] = str(partition_data["id"]) def _get_format_partition_id(self, volume): """Get the partition id from the specified format.""" From 52ab56720cf95a0c1a1c4907a05ece5005ebdc48 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 26 Jun 2023 12:01:21 -0700 Subject: [PATCH 500/569] Storage configuration must take place prior to allocation, in 'ready' state. --- .../devices/maas2/maas2.py | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 40dd4e15..6a931ebd 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -232,10 +232,25 @@ def _run_tpm_clear_cmd(self): return False def deploy_node( - self, distro=None, kernel=None, user_data=None, maas_storage=None + self, distro="bionic", kernel=None, user_data=None, maas_storage=None ): # Deploy the node in maas, default to bionic if nothing is specified self.recover() + status = self.node_status() + # configuring storage must take place when node is in a ready state + if maas_storage and status == "Ready": + try: + maas_storage.configure_node_storage() + except MaasStorageError as error: + self._logger_error(f"Unable to configure node storage {error}") + raise ProvisioningError(error) + else: + error = ( + f"Node status: {status}; must be Ready to configure storage" + ) + self._logger_error(error) + raise ProvisioningError(error) + self._logger_info("Acquiring node") cmd = [ "maas", @@ -251,13 +266,6 @@ def deploy_node( if proc.returncode: self._logger_error(f"maas error running: {' '.join(cmd)}") raise ProvisioningError(proc.stdout.decode()) - # provision storage if configured - if maas_storage: - try: - maas_storage.configure_node_storage() - except MaasStorageError as error: - self._logger_error(f"Unable to configure node storage {error}") - raise ProvisioningError(error) self._logger_info( "Starting node {} " "with distro {}".format(self.agent_name, distro) From f5d5dc95bc96cdbdd753c044fa37aeabf79e68c9 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 26 Jun 2023 12:08:10 -0700 Subject: [PATCH 501/569] Refine logic to verify that node is in a ready state. --- .../devices/maas2/maas2.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 6a931ebd..3087c377 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -238,18 +238,21 @@ def deploy_node( self.recover() status = self.node_status() # configuring storage must take place when node is in a ready state - if maas_storage and status == "Ready": + if maas_storage: + if not status == "Ready": + error = ( + f"Node status: {status}; must be Ready to config storage" + ) + self._logger_error(error) + raise ProvisioningError(error) + try: maas_storage.configure_node_storage() except MaasStorageError as error: - self._logger_error(f"Unable to configure node storage {error}") + self._logger_error( + f"Unable to configure node storage {error}" + ) raise ProvisioningError(error) - else: - error = ( - f"Node status: {status}; must be Ready to configure storage" - ) - self._logger_error(error) - raise ProvisioningError(error) self._logger_info("Acquiring node") cmd = [ From 1587eaffd4c13d83a530c9f1d0672dd4291397e5 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 26 Jun 2023 16:19:49 -0700 Subject: [PATCH 502/569] Docstring additions and further refinement --- .../devices/maas2/maas2.py | 5 +- .../devices/maas2/maas_storage.py | 249 +++++++++++------- 2 files changed, 154 insertions(+), 100 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 3087c377..5b0fc0b8 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -241,7 +241,8 @@ def deploy_node( if maas_storage: if not status == "Ready": error = ( - f"Node status: {status}; must be Ready to config storage" + f"Node status: {status}; must be Ready to configure " + "storage" ) self._logger_error(error) raise ProvisioningError(error) @@ -250,7 +251,7 @@ def deploy_node( maas_storage.configure_node_storage() except MaasStorageError as error: self._logger_error( - f"Unable to configure node storage {error}" + f"Unable to configure node storage: {error}" ) raise ProvisioningError(error) diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index eebcfb8a..dc818187 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -35,8 +35,8 @@ def __init__(self, maas_user, node_id, storage_data): self.node_id = node_id self.device_list = storage_data self.node_info = self._node_read() - self.block_ids = None - self.partition_list = None + self.block_ids = {} + self.partition_sizes = {} def _logger_debug(self, message): logger.debug("MAAS: {}".format(message)) @@ -45,11 +45,21 @@ def _logger_info(self, message): logger.info("MAAS: {}".format(message)) def _node_read(self): + """Read node block-devices. + + :return: the node's block device information + """ cmd = ["maas", self.maas_user, "block-devices", "read", self.node_id] return self.call_cmd(cmd, output_json=True) @staticmethod def call_cmd(cmd, output_json=False): + """Run a command and return the output. + + :param cmd: command to run + :param output_json: output the result as JSON + :return: subprocess stdout + """ proc = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False ) @@ -66,7 +76,12 @@ def call_cmd(cmd, output_json=False): @staticmethod def convert_size_to_bytes(size_str): - """Convert given sizes to bytes; case insensitive.""" + """Convert given sizes to bytes; case insensitive. + + :param size_str: the size string to convert + :return: the size in bytes + :raises MaasStorageError: on invalid size unit/type + """ size_str = size_str.upper() if "T" in size_str: return round(float(size_str.replace("T", "")) * (1000**4)) @@ -90,31 +105,31 @@ def convert_size_to_bytes(size_str): def configure_node_storage(self): """Configure the node's storage layout, from provisioning data.""" - self._logger_info(self.node_info) # debugging - self.assign_top_level_parent() - self.partition_list = self.gather_partitions() - - self.block_ids = self.parse_block_devices() - + self._logger_info("Configuring node storage") + # map top level parent disk to every device + self.assign_parent_disk() + # tally partition requirements for each disk + self.gather_partitions() + # find appropriate block devices for each partition + self.parse_block_devices() + # map block ids to top level parents self.map_block_ids() - + # calculate partition sizes self.create_partition_sizes() - + # group devices by type devs_by_type = self.group_by_type() - # clear existing storage on node self.clear_storage_config() - # apply configured storage to node self.process_by_type(devs_by_type) def clear_storage_config(self): """Clear the node's exisitng storage configuration.""" self._logger_info("Clearing existing storage configuration:") - for blkdev in self.node_info: - if blkdev["type"] == "virtual": + for block_dev in self.node_info: + if block_dev["type"] == "virtual": continue - for partition in blkdev["partitions"]: + for partition in block_dev["partitions"]: self.call_cmd( [ "maas", @@ -122,12 +137,12 @@ def clear_storage_config(self): "partition", "delete", self.node_id, - str(blkdev["id"]), + str(block_dev["id"]), str(partition["id"]), ] ) - if blkdev["filesystem"] is not None: - if blkdev["filesystem"]["mount_point"] is not None: + if block_dev["filesystem"] is not None: + if block_dev["filesystem"]["mount_point"] is not None: self.call_cmd( [ "maas", @@ -135,7 +150,7 @@ def clear_storage_config(self): "block-device", "unmount", self.node_id, - str(blkdev["id"]), + str(block_dev["id"]), ] ) self.call_cmd( @@ -145,11 +160,11 @@ def clear_storage_config(self): "block-device", "unformat", self.node_id, - str(blkdev["id"]), + str(block_dev["id"]), ] ) - def assign_top_level_parent(self): + def assign_parent_disk(self): """Transverse device hierarchy to determine each device's parent disk.""" dev_dict = {dev["id"]: dev for dev in self.device_list} @@ -157,14 +172,14 @@ def assign_top_level_parent(self): parent_id = dev.get("device") or dev.get("volume") if dev["type"] == "disk": - # keep 'top_level_parent' key for consistency - dev["top_level_parent"] = dev["id"] + # keep 'parent_disk' key for consistency + dev["parent_disk"] = dev["id"] while parent_id and parent_id in dev_dict: parent = dev_dict[parent_id] parent_id = parent.get("device") or parent.get("volume") if parent["type"] == "disk": - dev["top_level_parent"] = parent["id"] + dev["parent_disk"] = parent["id"] def gather_partitions(self): """Tally partition size requirements for block-device selection.""" @@ -173,64 +188,65 @@ def gather_partitions(self): for dev in self.device_list: if dev["type"] == "partition": # convert size to bytes before appending to the list - partitions[dev["top_level_parent"]].append( + partitions[dev["parent_disk"]].append( self.convert_size_to_bytes(dev["size"]) ) # summing up sizes of each disk's partitions - partition_sizes = { + self.partition_sizes = { devid: sum(partitions) for devid, partitions in partitions.items() } - return partition_sizes + def _select_block_dev(self, partition_id, partition_size): + """Find a suitable block device for the given partition. + + :param partition_id: the id of the partition + :param partition_size: the size of the partition + :return: the id of a suitable block device if found + :raises MaasStorageError: if no suitable block device is found + """ + for block_dev in self.node_info: + if ( + block_dev["type"] == "physical" + and block_dev["id"] not in self.block_ids.values() + and partition_size <= int(block_dev["size"]) + ): + return block_dev["id"] + + raise MaasStorageError( + "No suitable block-device found for partition " + f"{partition_id} with size {partition_size} bytes" + ) def parse_block_devices(self): """Find appropriate node block-device for use in layout.""" - block_ids = {} - mapped_block_ids = set() - - for dev_id, size in self.partition_list.items(): - self._logger_info(f"Comparing size: Partition: {size}") - for blkdev in self.node_info: - if ( - blkdev["type"] != "physical" - or blkdev["id"] in mapped_block_ids - ): - continue - - size_bd = int(blkdev["size"]) - self._logger_info( - f"Comparing size: Partition: {size}, " - f"Block Device: {size_bd}" - ) + for partition_id, partition_size in self.partition_sizes.items(): + self._logger_info(f"Comparing size: Partition: {partition_size}") + block_device_id = self._select_block_dev( + partition_id, partition_size + ) - if size <= size_bd: - # map disk_id to block device id - block_ids[dev_id] = blkdev["id"] - mapped_block_ids.add(blkdev["id"]) - break - else: - raise MaasStorageError( - "No suitable block-device found for partition " - f"{dev_id} with size {size} bytes" - ) - return block_ids + # map partition id to block device id + self.block_ids[partition_id] = block_device_id def map_block_ids(self): - """Map parent disks to actual node block-device.""" + """Map parent disks to actual node block-devices. + + Updates self.device_list with "parent_disk_blkid". + """ for dev in self.device_list: - block_id = self.block_ids.get(dev["top_level_parent"]) + block_id = self.block_ids.get(dev["parent_disk"]) if block_id is not None: - dev["top_level_parent_block_id"] = str(block_id) + dev["parent_disk_blkid"] = str(block_id) - def validate_alloc_pct_values(self): + def _validate_alloc_pct_values(self): """Sanity check partition allocation percentages.""" alloc_pct_values = collections.defaultdict(int) for dev in self.device_list: if dev["type"] == "partition": # add pct together (default to 0 if unnused) - alloc_pct_values[dev["top_level_parent"]] += dev.get( + alloc_pct_values[dev["parent_disk"]] += dev.get( "alloc_pct", 0 ) @@ -243,19 +259,19 @@ def validate_alloc_pct_values(self): def create_partition_sizes(self): """Calculate actual partition size to write to disk.""" - self.validate_alloc_pct_values() + self._validate_alloc_pct_values() for dev in self.device_list: if dev["type"] == "partition": # find corresponding block device - for blkdev in self.node_info: - if blkdev["id"] == self.block_ids[dev["top_level_parent"]]: + for block_dev in self.node_info: + if block_dev["id"] == self.block_ids[dev["parent_disk"]]: # get the total size of the block device in bytes - total_size = int(blkdev["size"]) + total_size = int(block_dev["size"]) break if "alloc_pct" in dev: - # round pct up if necessary + # avoid under-allocating space dev["size"] = str( math.ceil((total_size * dev.get("alloc_pct", 0)) / 100) ) @@ -270,7 +286,10 @@ def create_partition_sizes(self): dev["size"] = self.convert_size_to_bytes(dev["size"]) def group_by_type(self): - """Group storage device by type for processing.""" + """Group storage devices by type for processing. + + :return: dict with device types as keys and lists of devices as values + """ devs_by_type = collections.defaultdict(list) for dev in self.device_list: @@ -279,20 +298,23 @@ def group_by_type(self): return devs_by_type def process_by_type(self, devs_by_type): - """Process each storage type together in dependancy sequence.""" + """Process each storage type together in sequence. + + :param devs_by_type: dict with device types as keys and + lists of devices as values + :raises MaasStorageError: if an error occurs during device processing + """ # order in which storage types are processed type_order = ["disk", "partition", "format", "mount"] - # maps the type of a disk (like 'disk', 'partition', etc.) - # to the corresponding function that processes it. + # maps the device type to the method that processes it type_to_method = { "disk": self.process_disk, "partition": self.process_partition, "mount": self.process_mount, "format": self.process_format, } - part_data = {} + partn_data = {} - # batch process storage devices for type_ in type_order: devices = devs_by_type.get(type_) if devices: @@ -300,7 +322,7 @@ def process_by_type(self, devs_by_type): for dev in devices: try: if type_ == "partition": - part_data[dev["id"]] = type_to_method[type_](dev) + partn_data[dev["id"]] = type_to_method[type_](dev) else: type_to_method[type_](dev) # do not proceed to subsequent/child types @@ -311,7 +333,10 @@ def process_by_type(self, devs_by_type): ) from error def _set_boot_disk(self, block_id): - """Mark node block-device as boot disk.""" + """Mark a node block-device as the boot disk. + + :param block_id: ID of the block-device + """ self._logger_info(f"Setting boot disk {block_id}") # self.call_cmd( self.call_cmd( @@ -326,22 +351,29 @@ def _set_boot_disk(self, block_id): ) def _get_child_device(self, parent_device): - """Get children devices from parent device.""" + """Get the children devices from a parent device. + + :param parent_device: the parent device + :return: list of children devices + """ children = [] for dev in self.device_list: - if dev["top_level_parent"] == parent_device["id"]: + if dev["parent_disk"] == parent_device["id"]: children.append(dev) return children def process_disk(self, device): - """Process block level storage (disks).""" + """Process block-level storage (disks). + + :param device: the disk device to process + """ self._logger_info("Disk:") self._logger_info( { "device_id": device["id"], "name": device["name"], "number": device.get("number"), - "block-id": device["top_level_parent_block_id"], + "block-id": device["parent_disk_blkid"], } ) # find boot mounts on child types @@ -353,7 +385,7 @@ def process_disk(self, device): f"Disk {device['id']} has a child mount with " f"'boot' in its path: {child['path']}" ) - self._set_boot_disk(device["top_level_parent_block_id"]) + self._set_boot_disk(device["parent_disk_blkid"]) break # apply disk name if "name" in device: @@ -365,56 +397,70 @@ def process_disk(self, device): "block-device", "update", self.node_id, - device["top_level_parent_block_id"], + device["parent_disk_blkid"], f"name={device['name']}", ] ) def _create_partition(self, device): - """Create parition on disk and return the resulting parition-id.""" + """Create a partition on a disk and return the resulting partition ID. + + :param device: the partition device + :return: the resulting node partition ID + """ cmd = [ "maas", self.maas_user, "partitions", "create", self.node_id, - device["top_level_parent_block_id"], + device["parent_disk_blkid"], f"size={device['size']}", ] return self.call_cmd(cmd, output_json=True) def process_partition(self, device): - """Process given partitions from the storage layout config.""" + """Process a partition from the storage layout config. + + :param device: the partition device to process + """ self._logger_info("Partition:") self._logger_info( { "device_id": device["id"], "size": device["size"], "number": device.get("number"), - "parent disk": device["top_level_parent"], - "parent block-id": device["top_level_parent_block_id"], + "parent disk": device["parent_disk"], + "parent disk block-id": device["parent_disk_blkid"], } ) partition_data = self._create_partition(device) device["partition_id"] = str(partition_data["id"]) def _get_format_partition_id(self, volume): - """Get the partition id from the specified format.""" + """Get the partition ID from the specified format. + + :param volume: the volume ID + :return: the node partition ID + """ for dev in self.device_list: if volume == dev["id"]: return dev["partition_id"] def process_format(self, device): - """Process given parition formats from the storage layout config.""" + """Process a partition format from the storage layout config. + + :param device: the format device to process + """ self._logger_info("Format:") self._logger_info( { "device_id": device["id"], "fstype": device["fstype"], "label": device["label"], - "parent disk": device["top_level_parent"], - "parent block-id": device["top_level_parent_block_id"], + "parent disk": device["parent_disk"], + "parent disk block-id": device["parent_disk_blkid"], } ) # format partition @@ -427,7 +473,7 @@ def process_format(self, device): "partition", "format", self.node_id, - device["top_level_parent_block_id"], + device["parent_disk_blkid"], partition_id, f"fstype={device['fstype']}", f"label={device['label']}", @@ -442,27 +488,34 @@ def process_format(self, device): "partition", "format", self.node_id, - device["top_level_parent_block_id"], + device["parent_disk_blkid"], f"fstype={device['fstype']}", f"label={device['label']}", ] ) def _get_mount_partition_id(self, device): - """Get the partiton-id of the specified mount path.""" + """Get the partition ID from the specified mount path. + + :param device: the mount device + :return: the partition ID + """ for dev in self.device_list: if device == dev["id"]: return self._get_format_partition_id(dev["volume"]) def process_mount(self, device): - """Process given mounts/paths from the storage layout config.""" + """Process a mount path from the storage layout config. + + :param device: the mount device to process + """ self._logger_info("Mount:") self._logger_info( { "device_id": device["id"], "path": device["path"], - "parent disk": device["top_level_parent"], - "parent block-id": device["top_level_parent_block_id"], + "parent disk": device["parent_disk"], + "parent disk block-id": device["parent_disk_blkid"], } ) partition_id = self._get_mount_partition_id(device["device"]) @@ -476,14 +529,14 @@ def process_mount(self, device): "partition", "mount", self.node_id, - device["top_level_parent_block_id"], + device["parent_disk_blkid"], partition_id, f"mount_point={device['path']}", ] ) # mount on block-device else: - self._logger_info(f" on disk: {device['top_level_parent']}") + self._logger_info(f" on disk: {device['parent_disk']}") self.call_cmd( [ "maas", @@ -491,7 +544,7 @@ def process_mount(self, device): "block-device", "mount", self.node_id, - device["top_level_parent_block_id"], + device["parent_disk_blkid"], f"mount_point={device['path']}", ] ) From 3e963a5e26ac4579d7479e6cc9fba3e1206c9443 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 26 Jun 2023 23:55:53 -0700 Subject: [PATCH 503/569] Move verbose logging to debug level --- .../devices/maas2/maas2.py | 8 ------ .../devices/maas2/maas_storage.py | 28 +++++++++---------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 5b0fc0b8..ff3fb82a 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -239,14 +239,6 @@ def deploy_node( status = self.node_status() # configuring storage must take place when node is in a ready state if maas_storage: - if not status == "Ready": - error = ( - f"Node status: {status}; must be Ready to configure " - "storage" - ) - self._logger_error(error) - raise ProvisioningError(error) - try: maas_storage.configure_node_storage() except MaasStorageError as error: diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index dc818187..d0850fdc 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -118,14 +118,16 @@ def configure_node_storage(self): self.create_partition_sizes() # group devices by type devs_by_type = self.group_by_type() + # clear existing storage on node + self._logger_info("Clearing existing storage configuration") self.clear_storage_config() # apply configured storage to node + self._logger_info("Applying storage layout") self.process_by_type(devs_by_type) def clear_storage_config(self): """Clear the node's exisitng storage configuration.""" - self._logger_info("Clearing existing storage configuration:") for block_dev in self.node_info: if block_dev["type"] == "virtual": continue @@ -221,7 +223,7 @@ def _select_block_dev(self, partition_id, partition_size): def parse_block_devices(self): """Find appropriate node block-device for use in layout.""" for partition_id, partition_size in self.partition_sizes.items(): - self._logger_info(f"Comparing size: Partition: {partition_size}") + self._logger_debug(f"Comparing size: Partition: {partition_size}") block_device_id = self._select_block_dev( partition_id, partition_size ) @@ -318,7 +320,7 @@ def process_by_type(self, devs_by_type): for type_ in type_order: devices = devs_by_type.get(type_) if devices: - self._logger_info(f"Processing type '{type_}':") + self._logger_debug(f"Processing type '{type_}':") for dev in devices: try: if type_ == "partition": @@ -337,7 +339,7 @@ def _set_boot_disk(self, block_id): :param block_id: ID of the block-device """ - self._logger_info(f"Setting boot disk {block_id}") + self._logger_debug(f"Setting boot disk {block_id}") # self.call_cmd( self.call_cmd( [ @@ -367,8 +369,7 @@ def process_disk(self, device): :param device: the disk device to process """ - self._logger_info("Disk:") - self._logger_info( + self._logger_debug( { "device_id": device["id"], "name": device["name"], @@ -381,7 +382,7 @@ def process_disk(self, device): for child in children: if child["type"] == "mount" and "/boot" in child["path"]: - self._logger_info( + self._logger_debug( f"Disk {device['id']} has a child mount with " f"'boot' in its path: {child['path']}" ) @@ -425,8 +426,7 @@ def process_partition(self, device): :param device: the partition device to process """ - self._logger_info("Partition:") - self._logger_info( + self._logger_debug( { "device_id": device["id"], "size": device["size"], @@ -453,8 +453,7 @@ def process_format(self, device): :param device: the format device to process """ - self._logger_info("Format:") - self._logger_info( + self._logger_debug( { "device_id": device["id"], "fstype": device["fstype"], @@ -509,8 +508,7 @@ def process_mount(self, device): :param device: the mount device to process """ - self._logger_info("Mount:") - self._logger_info( + self._logger_debug( { "device_id": device["id"], "path": device["path"], @@ -521,7 +519,7 @@ def process_mount(self, device): partition_id = self._get_mount_partition_id(device["device"]) # mount on partition if partition_id: - self._logger_info(f" on partition_id: {partition_id}") + self._logger_debug(f" on partition_id: {partition_id}") self.call_cmd( [ "maas", @@ -536,7 +534,7 @@ def process_mount(self, device): ) # mount on block-device else: - self._logger_info(f" on disk: {device['parent_disk']}") + self._logger_debug(f" on disk: {device['parent_disk']}") self.call_cmd( [ "maas", From 86355ea4c38713b53bf37e2d5454c4ac89bb6466 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 27 Jun 2023 09:22:03 -0700 Subject: [PATCH 504/569] Cleanup --- src/snappy_device_agents/devices/maas2/maas_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index d0850fdc..65c27ff2 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -383,7 +383,7 @@ def process_disk(self, device): for child in children: if child["type"] == "mount" and "/boot" in child["path"]: self._logger_debug( - f"Disk {device['id']} has a child mount with " + f"Disk {device['id']} has a mount with " f"'boot' in its path: {child['path']}" ) self._set_boot_disk(device["parent_disk_blkid"]) From ec846702f80162ddc51eee2260374e7004799181 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 27 Jun 2023 09:26:52 -0700 Subject: [PATCH 505/569] Black reformatting --- src/snappy_device_agents/devices/maas2/maas_storage.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index 65c27ff2..00af2209 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -248,9 +248,7 @@ def _validate_alloc_pct_values(self): for dev in self.device_list: if dev["type"] == "partition": # add pct together (default to 0 if unnused) - alloc_pct_values[dev["parent_disk"]] += dev.get( - "alloc_pct", 0 - ) + alloc_pct_values[dev["parent_disk"]] += dev.get("alloc_pct", 0) for dev_id, alloc_pct in alloc_pct_values.items(): if alloc_pct > 100: From 281254bc16bec5f77ed2cdd727fdb192eb631a32 Mon Sep 17 00:00:00 2001 From: Remy MARTIN Date: Thu, 29 Jun 2023 14:43:38 +0200 Subject: [PATCH 506/569] Add support for tegra devices through muxpi agent --- src/snappy_device_agents/devices/muxpi/muxpi.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index f6c1769e..40a3a3fd 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -121,6 +121,7 @@ def provision(self): self.flash_test_image(url) with self.remote_mount(): image_type = self.get_image_type() + logger.info("Image type detected: {}".format(image_type)) logger.info("Creating Test User") self.create_user(image_type) self.run_post_provision_script() @@ -146,7 +147,7 @@ def flash_test_image(self, url): test_device = self.config["test_device"] cmd = ( - f"(set -o pipefail; curl -sf {url} | xzcat| " + f"(set -o pipefail; curl -sf {url} | zstdcat| " f"sudo dd of={test_device} bs=16M)" ) logger.info("Running: %s", cmd) @@ -244,11 +245,20 @@ def check_path(dir): # Not a limerick image pass + try: + disk_info_path = ( + self.mount_point / "writable/lib/firmware/*-tegra/" + ) + self._run_control(f"ls {disk_info_path} &>/dev/null") + return "tegra" + except ProvisioningError: + # Not a tegra image + pass + for path, img_type in self.IMAGE_PATH_IDS.items(): try: path = self.mount_point / path check_path(path) - logger.info("Image type detected: {}".format(img_type)) return img_type except Exception: # Path was not found, continue trying others @@ -282,6 +292,8 @@ def create_user(self, image_type): cmd = f"sudo cp {remote_tmp}/user-data {base}/system-boot/" self._run_control(cmd) self._configure_sudo() + if image_type == "tegra": + self._configure_sudo() if image_type == "pi-desktop": # make a spot to scp files to self._run_control("mkdir -p {}".format(remote_tmp)) From d1782e5454519a8bb636d03323ab17fee863f526 Mon Sep 17 00:00:00 2001 From: Remy MARTIN Date: Thu, 29 Jun 2023 23:18:40 +0200 Subject: [PATCH 507/569] Add return statement to make sure we don't execute other control commands for tegra devices --- src/snappy_device_agents/devices/muxpi/muxpi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 40a3a3fd..8e45f6fd 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -294,6 +294,7 @@ def create_user(self, image_type): self._configure_sudo() if image_type == "tegra": self._configure_sudo() + return if image_type == "pi-desktop": # make a spot to scp files to self._run_control("mkdir -p {}".format(remote_tmp)) From 4e02418dcb323bc94548bd139d0064aa9d2ae925 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 5 Jul 2023 16:03:38 -0500 Subject: [PATCH 508/569] Log errors instead of exceptions in tfclient --- src/snappy_device_agents/devices/multi/tfclient.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/snappy_device_agents/devices/multi/tfclient.py b/src/snappy_device_agents/devices/multi/tfclient.py index ca7e849d..79c19bf5 100644 --- a/src/snappy_device_agents/devices/multi/tfclient.py +++ b/src/snappy_device_agents/devices/multi/tfclient.py @@ -109,7 +109,7 @@ def get_status(self, job_id): data = json.loads(self.get(endpoint)) state = data.get("job_state") except OSError: - logger.exception("Unable to get status for job %s", job_id) + logger.error("Unable to get status for job %s", job_id) state = "unknown" return state @@ -125,7 +125,7 @@ def get_results(self, job_id): endpoint = f"/v1/result/{job_id}" data = json.loads(self.get(endpoint)) except OSError: - logger.exception("Unable to get results for job %s", job_id) + logger.error("Unable to get results for job %s", job_id) data = {} return data From 0d085db918b2d6c4edcbbe53d592f4311a0d7344 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 10 Jul 2023 02:41:44 -0700 Subject: [PATCH 509/569] Updated maas2.py, maas_storage.py to incorporate feedback and additional cleanup. Created unit tests for storage module. --- .../devices/maas2/maas2.py | 35 +- .../devices/maas2/maas_storage.py | 99 ++-- .../devices/maas2/tests/test_maas_storage.py | 508 ++++++++++++++++++ 3 files changed, 591 insertions(+), 51 deletions(-) create mode 100644 src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index ff3fb82a..9e441cf3 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -44,6 +44,7 @@ def __init__(self, config, job_data): self.node_id = self.config.get("node_id") self.agent_name = self.config.get("agent_name") self.timeout_min = int(self.config.get("timeout_min", 60)) + self.maas_storage = MaasStorage(self.maas_user, self.node_id) def _logger_debug(self, message): logger.debug("MAAS: {}".format(message)) @@ -76,11 +77,8 @@ def provision(self): kernel = provision_data.get("kernel") user_data = provision_data.get("user_data") storage_data = provision_data.get("disks") - if storage_data: - maas_storage = MaasStorage( - self.maas_user, self.node_id, storage_data - ) - self.deploy_node(distro, kernel, user_data, maas_storage) + + self.deploy_node(distro, kernel, user_data, storage_data) def _install_efitools_snap(self): cmd = [ @@ -232,20 +230,39 @@ def _run_tpm_clear_cmd(self): return False def deploy_node( - self, distro="bionic", kernel=None, user_data=None, maas_storage=None + self, distro="bionic", kernel=None, user_data=None, storage_data=None ): # Deploy the node in maas, default to bionic if nothing is specified self.recover() status = self.node_status() # configuring storage must take place when node is in a ready state - if maas_storage: + if storage_data: try: - maas_storage.configure_node_storage() + self.maas_storage.configure_node_storage(storage_data) except MaasStorageError as error: self._logger_error( f"Unable to configure node storage: {error}" ) - raise ProvisioningError(error) + raise ProvisioningError from error + else: + def_storage_data = self.config.get("default_disks") + if not def_storage_data: + self._logger_warn( + "'default_disks' and/or 'disks' unspecified; \ + skipping storage layout configuration" + ) + else: + # reset to the default layout + try: + self.maas_storage.configure_node_storage( + def_storage_data, reset=True + ) + except MaasStorageError as error: + self._logger_error( + f"Unable to reset node storage to \ + default_disk layout: {error}" + ) + raise ProvisioningError from error self._logger_info("Acquiring node") cmd = [ diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index 00af2209..5b621384 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -30,10 +30,13 @@ def __init__(self, message): class MaasStorage: - def __init__(self, maas_user, node_id, storage_data): + """Maas device agent storage module.""" + + def __init__(self, maas_user, node_id): self.maas_user = maas_user self.node_id = node_id - self.device_list = storage_data + self.device_list = None + self.init_data = None self.node_info = self._node_read() self.block_ids = {} self.partition_sizes = {} @@ -59,11 +62,16 @@ def call_cmd(cmd, output_json=False): :param cmd: command to run :param output_json: output the result as JSON :return: subprocess stdout + :raises MaasStorageError: on subprocess non-zero return code """ - proc = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False - ) - if proc.returncode: + try: + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + except subprocess.CalledProcessError: raise MaasStorageError(proc.stdout.decode()) if proc.stdout: @@ -83,17 +91,18 @@ def convert_size_to_bytes(size_str): :raises MaasStorageError: on invalid size unit/type """ size_str = size_str.upper() - if "T" in size_str: - return round(float(size_str.replace("T", "")) * (1000**4)) - elif "G" in size_str: - return round(float(size_str.replace("G", "")) * (1000**3)) - elif "M" in size_str: - return round(float(size_str.replace("M", "")) * (1000**2)) - elif "K" in size_str: - return round(float(size_str.replace("K", "")) * 1000) - elif "B" in size_str: - return int(size_str.replace("B", "")) - else: + size_pow = {"T": 4, "G": 3, "M": 2, "K": 1, "B": 0} + + try: + return round( + float("".join(char for char in size_str if char.isdigit())) + ) * ( + 1000 + ** size_pow[ + "".join(char for char in size_str if not char.isdigit()) + ] + ) + except KeyError: try: # attempt to convert the size string to an integer return int(size_str) @@ -103,19 +112,23 @@ def convert_size_to_bytes(size_str): "when no unit is provided." ) - def configure_node_storage(self): + def configure_node_storage(self, storage_data, reset=False): """Configure the node's storage layout, from provisioning data.""" - self._logger_info("Configuring node storage") - # map top level parent disk to every device - self.assign_parent_disk() - # tally partition requirements for each disk - self.gather_partitions() - # find appropriate block devices for each partition - self.parse_block_devices() - # map block ids to top level parents - self.map_block_ids() - # calculate partition sizes - self.create_partition_sizes() + self.device_list = storage_data + + if not reset: + self._logger_info("Configuring node storage") + # map top level parent disk to every device + self.assign_parent_disk() + # tally partition requirements for each disk + self.gather_partitions() + # find appropriate block devices for each partition + self.parse_block_devices() + # map block ids to top level parents + self.map_block_ids() + # calculate partition sizes + self.create_partition_sizes() + # group devices by type devs_by_type = self.group_by_type() @@ -124,7 +137,7 @@ def configure_node_storage(self): self.clear_storage_config() # apply configured storage to node self._logger_info("Applying storage layout") - self.process_by_type(devs_by_type) + self.process_by_dev_type(devs_by_type) def clear_storage_config(self): """Clear the node's exisitng storage configuration.""" @@ -140,7 +153,7 @@ def clear_storage_config(self): "delete", self.node_id, str(block_dev["id"]), - str(partition["id"]), + str(str(partition["id"])), ] ) if block_dev["filesystem"] is not None: @@ -278,7 +291,7 @@ def create_partition_sizes(self): else: if "size" not in dev: raise ValueError( - f"Partition '{dev['id']}' does not have an " + f"Partition '{str(dev['id'])}' does not have an " "alloc_pct or size value." ) else: @@ -297,7 +310,7 @@ def group_by_type(self): return devs_by_type - def process_by_type(self, devs_by_type): + def process_by_dev_type(self, devs_by_type): """Process each storage type together in sequence. :param devs_by_type: dict with device types as keys and @@ -305,9 +318,9 @@ def process_by_type(self, devs_by_type): :raises MaasStorageError: if an error occurs during device processing """ # order in which storage types are processed - type_order = ["disk", "partition", "format", "mount"] + dev_type_order = ["disk", "partition", "format", "mount"] # maps the device type to the method that processes it - type_to_method = { + dev_type_to_method = { "disk": self.process_disk, "partition": self.process_partition, "mount": self.process_mount, @@ -315,21 +328,23 @@ def process_by_type(self, devs_by_type): } partn_data = {} - for type_ in type_order: - devices = devs_by_type.get(type_) + for dev_type in dev_type_order: + devices = devs_by_type.get(dev_type) if devices: - self._logger_debug(f"Processing type '{type_}':") + self._logger_debug(f"Processing type '{dev_type}':") for dev in devices: try: - if type_ == "partition": - partn_data[dev["id"]] = type_to_method[type_](dev) + if dev_type == "partition": + partn_data[dev["id"]] = dev_type_to_method[ + dev_type + ](dev) else: - type_to_method[type_](dev) + dev_type_to_method[dev_type](dev) # do not proceed to subsequent/child types except MaasStorageError as error: raise MaasStorageError( f"Unable to process device: {dev} " - f"of type: {type_}" + f"of type: {dev_type}" ) from error def _set_boot_disk(self, block_id): diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py new file mode 100644 index 00000000..7d4d4f9e --- /dev/null +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -0,0 +1,508 @@ +import pytest +import json +from maas_storage import MaasStorage, MaasStorageError +from unittest.mock import Mock, MagicMock, call + + +class MockMaasStorage(MaasStorage): + """Enable mock subprocess calls.""" + + def __init__(self, maas_user, node_id): + super().__init__(maas_user, node_id) + self.call_cmd_mock = Mock() + + def call_cmd(self, cmd, output_json=False): + """Mock method to simulate the call_cmd method in the + parent MaasStorage class. + """ + + if output_json: + return json.loads(TestMaasStorage.node_info) + else: + # monkeypatch + return self.call_cmd_mock(cmd) + + +class TestMaasStorage: + """Test maas device agent storage module.""" + + node_info = json.dumps( + [ + { + "id": 1, + "type": "disk", + "name": "sda", + "type": "physical", + "size": "300000000000", + "path": "/dev/disk/by-dname/sda", + "filesystem": None, + "partitions": [ + { + "id": 10, + "type": "partition", + "size": "1000000000", + "parent_disk": 1, + "bootable": "true", + "filesystem": { + "mount_point": "/boot", + "fstype": "ext4", + }, + } + ], + }, + { + "id": 2, + "type": "disk", + "name": "sdb", + "type": "physical", + "size": "400000000000", + "path": "/dev/disk/by-dname/sdb", + "filesystem": None, + "partitions": [ + { + "id": 20, + "type": "partition", + "size": "20000000000", + "parent_disk": 2, + "filesystem": { + "mount_point": "/data", + "fstype": "ext4", + }, + } + ], + }, + { + "id": 3, + "type": "disk", + "name": "sdc", + "type": "physical", + "size": "900000000000", + "path": "/dev/disk/by-dname/sdc", + "filesystem": {"mount_point": "/backup", "fstype": "ext4"}, + "partitions": [], + }, + { + "id": 4, + "type": "virtual", + }, + ] + ) + + @pytest.fixture + def maas_storage(self): + """Provides a MockMaasStorage instance for testing.""" + maas_user = "maas_user" + node_id = "node_id" + yield MockMaasStorage(maas_user, node_id) + + def test_node_read(self, maas_storage): + """Checks if 'node_read' correctly returns node block-devices.""" + node_info = maas_storage._node_read() + assert node_info == json.loads(self.node_info) + + def test_call_cmd_output_json(self, maas_storage): + """Test 'call_cmd' when output_json is True. + Checks if the method correctly returns json data. + """ + result = maas_storage.call_cmd( + ["maas", "maas_user", "block-devices", "read", "node_id"], + output_json=True, + ) + assert result == json.loads(self.node_info) + + def test_convert_size_to_bytes(self, maas_storage): + """Check if 'convert_size_to_bytes' correctly + converts sizes from string format to byte values. + """ + assert maas_storage.convert_size_to_bytes("1G") == 1000000000 + assert maas_storage.convert_size_to_bytes("500M") == 500000000 + assert maas_storage.convert_size_to_bytes("10K") == 10000 + assert maas_storage.convert_size_to_bytes("1000") == 1000 + + with pytest.raises(MaasStorageError): + maas_storage.convert_size_to_bytes("1Tb") + maas_storage.convert_size_to_bytes("abc") + + def test_clear_storage_config(self, maas_storage): + """Checks if 'clear_storage_config' correctly clears the + storage configuration. + """ + maas_storage.clear_storage_config() + + maas_storage.call_cmd_mock.assert_has_calls( + [ + call( + [ + "maas", + maas_storage.maas_user, + "partition", + "delete", + maas_storage.node_id, + "1", # parent_block_id + "10", # partition_id + ] + ), + call( + [ + "maas", + maas_storage.maas_user, + "partition", + "delete", + maas_storage.node_id, + "2", + "20", + ] + ), + call( + [ + "maas", + maas_storage.maas_user, + "block-device", + "unmount", + maas_storage.node_id, + "3", # parent_block_id + ] + ), + call( + [ + "maas", + maas_storage.maas_user, + "block-device", + "unformat", + maas_storage.node_id, + "3", # parent_block_id + ] + ), + ] + ) + + def test_assign_parent_disk(self, maas_storage): + """Checks if 'assign_parent_disk' correctly assigns a parent disk + to a storage device. + """ + maas_storage.device_list = [ + {"id": 1, "type": "disk"}, + {"id": 10, "type": "partition", "device": 1}, + ] + + maas_storage.assign_parent_disk() + + assert maas_storage.device_list == [ + {"id": 1, "type": "disk", "parent_disk": 1}, + {"id": 10, "type": "partition", "device": 1, "parent_disk": 1}, + ] + + def test_gather_partitions(self, maas_storage): + """Checks if 'gather_partitions' correctly gathers partition sizes.""" + maas_storage.device_list = [ + {"id": 10, "type": "partition", "parent_disk": 1, "size": "500M"}, + {"id": 20, "type": "partition", "parent_disk": 1, "size": "1G"}, + {"id": 30, "type": "partition", "parent_disk": 2, "size": "2G"}, + ] + + maas_storage.gather_partitions() + + assert maas_storage.partition_sizes == {1: 1500000000, 2: 2000000000} + + def test_select_block_dev(self, maas_storage): + """Checks if 'select_block_dev' correctly selects a block + device based on id and size. + """ + maas_storage.device_list = [ + {"id": 10, "type": "partition", "parent_disk": 1, "size": "500M"}, + {"id": 20, "type": "partition", "parent_disk": 1, "size": "1G"}, + {"id": 30, "type": "partition", "parent_disk": 2, "size": "2G"}, + ] + + block_device_id = maas_storage._select_block_dev(10, 1500000000) + assert block_device_id == 1 + + block_device_id = maas_storage._select_block_dev(20, 2000000000) + assert block_device_id == 1 + + with pytest.raises(MaasStorageError): + maas_storage._select_block_dev(30, 50000000000000) + + def test_parse_block_devices(self, maas_storage): + """Checks if 'parse_block_devices' correctly choses the most + appropriate node block-id for the per-disk summed partition size. + """ + maas_storage.partition_sizes = { + 1: 1500000000, + 2: 2000000000, + } + maas_storage.device_list = [ + {"id": 10, "type": "partition", "parent_disk": 1, "size": "500M"}, + {"id": 20, "type": "partition", "parent_disk": 1, "size": "1G"}, + {"id": 30, "type": "partition", "parent_disk": 2, "size": "2G"}, + ] + + maas_storage.block_ids = {} + + maas_storage.parse_block_devices() + + assert maas_storage.block_ids == {1: 1, 2: 2} + + def test_map_block_ids(self, maas_storage): + """Checks if 'map_block_ids' correctly maps each partition to + the appropriate block-device id. + """ + maas_storage.block_ids = {1: 1, 2: 2, 3: 3} + + maas_storage.device_list = [ + {"id": 10, "type": "partition", "parent_disk": 1}, + {"id": 20, "type": "partition", "parent_disk": 2}, + {"id": 30, "type": "partition", "parent_disk": 3}, + ] + + maas_storage.map_block_ids() + + assert maas_storage.device_list == [ + { + "id": 10, + "type": "partition", + "parent_disk": 1, + "parent_disk_blkid": "1", + }, + { + "id": 20, + "type": "partition", + "parent_disk": 2, + "parent_disk_blkid": "2", + }, + { + "id": 30, + "type": "partition", + "parent_disk": 3, + "parent_disk_blkid": "3", + }, + ] + + def test_validate_alloc_pct_values(self, maas_storage): + """Checks if 'validate_alloc_pct_values' correctly validates total + per-disk allocation percentages do not exceed 100. + """ + maas_storage.device_list = [ + {"id": 10, "type": "partition", "parent_disk": 1, "alloc_pct": 50}, + {"id": 20, "type": "partition", "parent_disk": 1, "alloc_pct": 60}, + {"id": 30, "type": "partition", "parent_disk": 2, "alloc_pct": 90}, + ] + + with pytest.raises(MaasStorageError): + maas_storage._validate_alloc_pct_values() + + def test_create_partition_sizes(self, maas_storage): + """Checks if 'create_partition_sizes' correctly creates each partition + based on the given parameters. + """ + maas_storage.device_list = [ + {"id": 10, "type": "partition", "parent_disk": 1, "size": "1G"}, + {"id": 20, "type": "partition", "parent_disk": 2, "alloc_pct": 40}, + {"id": 30, "type": "partition", "parent_disk": 2, "alloc_pct": 60}, + ] + maas_storage.block_ids = {1: 1, 2: 2} + maas_storage.create_partition_sizes() + + assert maas_storage.device_list == [ + { + "id": 10, + "type": "partition", + "parent_disk": 1, + "size": 1000000000, + }, + { + "id": 20, + "type": "partition", + "parent_disk": 2, + "alloc_pct": 40, + "size": "160000000000", + }, + { + "id": 30, + "type": "partition", + "parent_disk": 2, + "alloc_pct": 60, + "size": "240000000000", + }, + ] + + def test_group_by_type(self, maas_storage): + """Checks if 'group_by_type' correctly groups each device by their + storage device type, into a list per that type. + """ + maas_storage.device_list = [ + {"id": 1, "type": "disk"}, + {"id": 10, "type": "partition"}, + {"id": 20, "type": "partition"}, + {"id": 40, "type": "format"}, + {"id": 50, "type": "format"}, + {"id": 60, "type": "mount"}, + ] + + result = maas_storage.group_by_type() + + assert result == { + "disk": [{"id": 1, "type": "disk"}], + "partition": [ + {"id": 10, "type": "partition"}, + {"id": 20, "type": "partition"}, + ], + "format": [ + {"id": 40, "type": "format"}, + {"id": 50, "type": "format"}, + ], + "mount": [{"id": 60, "type": "mount"}], + } + + def test_process_by_dev_type(self, maas_storage): + """Checks if 'process_by_dev_type' correctly batch-processes devices + based on their type-grouping list. + """ + devs_by_type = { + "disk": [{"id": 1, "type": "disk"}], + "partition": [{"id": 20, "type": "partition"}], + "format": [{"id": 40, "type": "format"}], + "mount": [{"id": 60, "type": "mount"}], + } + + mock_methods = { + "disk": "process_disk", + "partition": "process_partition", + "format": "process_format", + "mount": "process_mount", + } + + for dev_type, devices in devs_by_type.items(): + for device in devices: + setattr(maas_storage, mock_methods[dev_type], MagicMock()) + + setattr( + maas_storage, + f"_get_child_device_{dev_type}", + MagicMock(return_value=devices), + ) + + maas_storage.process_by_dev_type(devs_by_type) + + for dev_type, devices in devs_by_type.items(): + for device in devices: + mock_method = getattr(maas_storage, mock_methods[dev_type]) + mock_method.assert_called_once_with(device) + + def test_process_disk(self, maas_storage): + """Checks if 'process_disk' correctly processes a 'disk' + device type. + """ + maas_storage.device_list = [ + {"id": 1, "type": "disk", "parent_disk": 1} + ] + device = { + "id": 1, + "type": "disk", + "name": "sda", + "parent_disk_blkid": 1, + } + + maas_storage.process_disk(device) + + maas_storage.call_cmd_mock.assert_called_with( + [ + "maas", + maas_storage.maas_user, + "block-device", + "update", + maas_storage.node_id, + device["parent_disk_blkid"], + f"name={device['name']}", + ] + ) + + def test_process_partition(self, maas_storage): + """Checks if 'process_partition' correctly processes a 'partition' + device type. + """ + device = { + "id": 10, + "type": "partition", + "parent_disk": "sda", + "parent_disk_blkid": 1, + "size": "1G", + } + + maas_storage._create_partition = MagicMock(return_value={"id": 3}) + + maas_storage.process_partition(device) + + assert maas_storage._create_partition.call_args_list == [call(device)] + + assert device["partition_id"] == "3" + + def test_process_format(self, maas_storage): + """Checks if 'process_format' correctly processes a 'format' + device type. + """ + device = { + "id": 4, + "type": "format", + "fstype": "ext4", + "label": "root", + "parent_disk": 1, + "parent_disk_blkid": "sda", + "volume": "volume", + } + + maas_storage._get_format_partition_id = MagicMock(return_value=2) + + maas_storage.process_format(device) + + maas_storage._get_format_partition_id.assert_called_with( + device["volume"] + ) + + maas_storage.call_cmd_mock.assert_called_with( + [ + "maas", + maas_storage.maas_user, + "partition", + "format", + maas_storage.node_id, + device["parent_disk_blkid"], + 2, + f"fstype={device['fstype']}", + f"label={device['label']}", + ] + ) + + def test_process_mount(self, maas_storage): + """Checks if 'process_mount' correctly processes a 'mount' + device type. + """ + device = { + "id": 6, + "type": "mount", + "path": "/mnt/data", + "parent_disk": 1, + "parent_disk_blkid": "sda", + "device": "device", + } + + maas_storage._get_mount_partition_id = MagicMock(return_value=2) + + maas_storage.process_mount(device) + + maas_storage._get_mount_partition_id.assert_called_with( + device["device"] + ) + + maas_storage.call_cmd_mock.assert_called_with( + [ + "maas", + maas_storage.maas_user, + "partition", + "mount", + maas_storage.node_id, + device["parent_disk_blkid"], + 2, + f"mount_point={device['path']}", + ] + ) From dd645e57c383a6c24cc186217a9a2074aa2a5443 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 10 Jul 2023 02:54:38 -0700 Subject: [PATCH 510/569] Update test_maas_storage.py remove dup type in mock data --- .../devices/maas2/tests/test_maas_storage.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index 7d4d4f9e..52a2f4c1 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -30,7 +30,6 @@ class TestMaasStorage: [ { "id": 1, - "type": "disk", "name": "sda", "type": "physical", "size": "300000000000", @@ -52,7 +51,6 @@ class TestMaasStorage: }, { "id": 2, - "type": "disk", "name": "sdb", "type": "physical", "size": "400000000000", @@ -73,7 +71,6 @@ class TestMaasStorage: }, { "id": 3, - "type": "disk", "name": "sdc", "type": "physical", "size": "900000000000", From 70f732bd824f5e115672eebbcb55b48c3a4fcdbf Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 10 Jul 2023 03:08:14 -0700 Subject: [PATCH 511/569] Added preamble to tests --- .../devices/maas2/tests/test_maas_storage.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index 52a2f4c1..45bb13bc 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -1,3 +1,20 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Maas2 agent storage module unit tests.""" + + import pytest import json from maas_storage import MaasStorage, MaasStorageError From 7b61240ee019ae8dc581ae6e18fe618799290677 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 10 Jul 2023 10:50:54 -0700 Subject: [PATCH 512/569] Updated test path in import --- .../devices/maas2/tests/test_maas_storage.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index 45bb13bc..29e98f64 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -17,8 +17,11 @@ import pytest import json -from maas_storage import MaasStorage, MaasStorageError from unittest.mock import Mock, MagicMock, call +from snappy_device_agents.devices.maas2.maas_storage import ( + MaasStorage, + MaasStorageError, +) class MockMaasStorage(MaasStorage): From e65b0ba537d67577371ae0bf09c6db8aa126ee79 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 11 Jul 2023 00:56:33 -0700 Subject: [PATCH 513/569] added module documentation and how to implement in a job definition --- .../devices/maas2/doc/maas_storage.rst | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/snappy_device_agents/devices/maas2/doc/maas_storage.rst diff --git a/src/snappy_device_agents/devices/maas2/doc/maas_storage.rst b/src/snappy_device_agents/devices/maas2/doc/maas_storage.rst new file mode 100644 index 00000000..ca1363d6 --- /dev/null +++ b/src/snappy_device_agents/devices/maas2/doc/maas_storage.rst @@ -0,0 +1,140 @@ +================= +Preamble +================= +This extension of the Testflinger Maas Snappy Device Agent to handle a variety of node storage layout configurations. This configuration will be passed to Testflinger via the node job configuration yaml file, as part of the SUT provision data (example below). This functionality is containted in the discreet Python module (‘maas-storage.py’) that sits alongside the Maas Snappy Device Agent, to be imported and called when this device agent is instantiated, if a storage layout configuration is supplied. + +These storage layout configurations are to be passed along to MAAS, via the CLI API, when the device agent is created as part of its provision phase. While initial scope and use of this module will be limited to SQA’s testing requirements, the availability of this module implies additional consumers can specify disk layout configurations as part of their Testflinger job definitions. + +Of note: the initial scope of storage to be supported will be limited to flat layouts and simple partitions; RAID, LVM or bcache configurations are currently unnsupported by this module. This functionality will be added in the future as the need arises. + +================= +Job Configuration +================= +The storage configuration is traditionally supplied as a node bucket config, so we can duplicate how this is laid out in the SUT job configuration at the end of this document. + +As below, the storage configuration is defined under ‘disks’ key in the yaml file. It is composed of a list of storage configuration entries, which are dictionaries with at least these two fields, ‘type’ and ‘id’: +- **type**: the type of configuration entry. Currently this is one of: + - *disk* - a physical disk on the system + - *partition* - a partition on the system + - *format* - instructions to format a volume + - *mount* - instructions to mount a formatted partition +- id: a label used to refer to this storage configuration entry. Other configuration entries will use this id to refer to this configuration item, or the component it configured. + +================= +Storage Types +================= +Each type of storage configuration entry has additional fields of: +- **Disk**: all storage configuration is ultimately applied to disks. These are referenced by ‘disk’ storage configuration entries. + - *disk* - The key of a disk in the machine's hardware configuration. By default, this is an integer value like ‘0’ or ‘1.’ + - *ptable* - Type of partition table to use for the disk (‘gpt’ or ‘msdos’). + - *name* - Optional. If specified, the name to use for the disk, as opposed to the default one which is usually something like 'sda'. This will be used in ‘/dev/disk/by-dname/’ for the disk and its partitions. So if you make the disk name 'rootdisk', it will show up at . This can be used to give predictable, meaningful names to disks, which can be referenced in juju config, etc. + - *boot* - Optional. If specified, this disk will be set as boot disk in MAAS. + - The disk's ‘id’ will be used to refer to this disk in other entries, such as ‘partition.’ +- **Partition**: A partition of a disk is represented by a ‘partition’ entry. + - *device* - The ‘id’ of the ‘disk’ entry this partition should be created on. + - *number* - Partition number. This determines the order of the partitions on the disk. + - *size* - The minimum required size of the partition, in bytes, or in larger units, given by suffixes (K, M, G, T) + - The partition's ‘id’ will be used to refer to this partition in other entries, such as ‘format.’ + - *alloc_pct* - Percent (as an int) of the parent disk this partition should consume. This is optional, if this is not given, then the ‘size’ value will be the created partition size. If multiple partitions exist on a parent disk, the total alloc_pct between them cannot exceed 100. +- **Format**: Instructions to format a volume are represented by a ‘format’ entry. + - *volume* - the ‘id’ of the entry to be formatted. This can be a disk or partition entry. + - *fstype* - The file system type to format the volume with. See MAAS docs for options. + - *label* - The label to use for the file system. + - The format's ‘id’ will be used to refer to this formatted volume in ‘mount’ entries. +- **Mount:** Instructions to mount a formatted volume are represented by a ‘mount’ entry. + - *device* - The ‘id’ of the ‘format’ entry this mount refers to. + - *path* - The path to mount the formatted volume at, e.g. ‘/boot.’ + +================= +Storage Configuration Instantiation +================= +- The existing storage configuration on the SUT is first cleared in order to start with a clean slate. +- We will then fetch the SUT block device config via the Maas API in order to verify and choose the appropriate physical disks which exist on the system. These disks must align with the configuration parameters (size, number of disks) presented in the config to proceed. +- Disk selection should be performed with the following criteria: + - In instances where all disks meet the space requirements, we can numerically assign the lowest physical disk ID (in Maas block-devices) to the first config disk. Subsequent disks will be assigned in numerical order. + - In instances where the total of a config disk’s partition map (determined by adding all configuration partitions on that disk) will only fit on certain node disks, these disks will only be selected for the parent configuration disk of said partition map. + - Disk selection will be done in numerical order as above within any smaller pool of disks that meet configuration partitioning criteria. + - Node provisioning will fail if configuration partition maps exist that will not adequately fit on any disk, or if the pool of appropriate disks is exhausted prior to accommodating all configuration partition maps. + - However, dynamic allocation of partition sizes using the alloc_pct field will enable a much more flexible allocation of partitions to parent disks, and one only needs to be able to provide the minimum partition size in order to select the most appropriate disk. +- After disk selection takes place, all configuration elements of each storage type will be grouped together for batch processing. This order is determined by the dependency each type has on the other. The types and the order in which they will be processed will be: [‘disk’, ‘partition’, ‘format’, ‘mount’]. + - As additional storage types are supported in the future, this order will need to remain consistent with any parent-child relationship that exists between storage types. +- The storage configuration will then be written to the node disks in this order. + - If a boot partition exists in the configuration, the parent disk will be flagged as a boot disk via the Maas API. The boot partition will then be created on this disk, including an EFI mount if desired. +- After the storage configuration is completed and written to the node’s physical disks, node provisioning will proceed to OS installation, in addition to any other provisioning steps outside of the node’s storage subsystem. + +================= +Job Definition Reference +================= +.. code-block:: yaml + :caption: job.yaml + :linenos: + + disks: + - id: disk0 + disk: 0 + type: disk + ptable: gpt + - id: disk0-part1 + device: disk0 + type: partition + number: 1 + size: 2G + alloc_pct: 80 + - id: disk0-part1-format + type: format + volume: disk0-part1 + fstype: ext4 + label: nova-ephemeral + - id: disk1-part1-mount + device: disk1-part1-format + path: / + type: mount + - id: disk1 + disk: 1 + type: disk + ptable: gpt + - id: disk1-part1 + device: disk1 + type: partition + number: 1 + size: 500M + alloc_pct: 10 + - id: disk1-part1-format + type: format + volume: disk1-part1 + fstype: fat32 + label: efi + - id: disk1-part1-mount + device: disk1-part1-format + path: /boot/efi + type: mount + - id: disk1-part2 + device: disk1 + type: partition + number: 2 + size: 1G + alloc_pct: 20 + - id: disk1-part2-format + volume: disk1-part2 + type: format + fstype: ext4 + label: boot + - id: disk1-part2-mount + device: disk1-part2-format + path: /boot + type: mount + - id: disk1-part3 + device: disk1 + type: partition + number: 3 + size: 10G + alloc_pct: 60 + - id: disk1-part3-format + volume: disk1-part3 + type: format + fstype: ext4 + label: ceph + - id: disk1-part3-mount + device: disk1-part3-format + path: /data + type: mount From 60a9aefadc46c141fff92228c34427d1df08fc1a Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 11 Jul 2023 13:25:04 -0700 Subject: [PATCH 514/569] move test_process_partition assertion to 'assert_called_with' --- .../devices/maas2/tests/test_maas_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index 29e98f64..0f40c47a 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -450,7 +450,7 @@ def test_process_partition(self, maas_storage): maas_storage.process_partition(device) - assert maas_storage._create_partition.call_args_list == [call(device)] + maas_storage._create_partition.assert_called_with(device) assert device["partition_id"] == "3" From 9aca5e833bc3d32201d35056a50400efadb88ca9 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 11 Jul 2023 23:00:03 -0700 Subject: [PATCH 515/569] cleanup based on PR feedback --- src/snappy_device_agents/devices/maas2/maas2.py | 7 ++++--- .../devices/maas2/tests/test_maas_storage.py | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 9e441cf3..eff2e3bc 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -24,8 +24,9 @@ import yaml from snappy_device_agents.devices import ProvisioningError, RecoveryError -from snappy_device_agents.devices.maas2.maas_storage import MaasStorage -from snappy_device_agents.devices.maas2.maas_storage import MaasStorageError +from snappy_device_agents.devices.maas2.maas_storage import ( + MaasStorage, MaasStorageError, +) logger = logging.getLogger() @@ -235,7 +236,7 @@ def deploy_node( # Deploy the node in maas, default to bionic if nothing is specified self.recover() status = self.node_status() - # configuring storage must take place when node is in a ready state + # do not process an empty dataset if storage_data: try: self.maas_storage.configure_node_storage(storage_data) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index 0f40c47a..b5ce3a8e 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -19,8 +19,7 @@ import json from unittest.mock import Mock, MagicMock, call from snappy_device_agents.devices.maas2.maas_storage import ( - MaasStorage, - MaasStorageError, + MaasStorage, MaasStorageError, ) From a504e42b001b3c52d10ad62589acd51666cb1eb7 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 12 Jul 2023 00:38:45 -0700 Subject: [PATCH 516/569] cleanup based on PR feedback --- src/snappy_device_agents/devices/maas2/maas2.py | 3 ++- .../devices/maas2/tests/test_maas_storage.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index eff2e3bc..42fd1ee8 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -25,7 +25,8 @@ from snappy_device_agents.devices import ProvisioningError, RecoveryError from snappy_device_agents.devices.maas2.maas_storage import ( - MaasStorage, MaasStorageError, + MaasStorage, + MaasStorageError, ) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index b5ce3a8e..0f40c47a 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -19,7 +19,8 @@ import json from unittest.mock import Mock, MagicMock, call from snappy_device_agents.devices.maas2.maas_storage import ( - MaasStorage, MaasStorageError, + MaasStorage, + MaasStorageError, ) From 6f286ee90cd7ddc5c4bef8ce4a4b0e0078636882 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Wed, 12 Jul 2023 00:39:28 -0700 Subject: [PATCH 517/569] cleanup based on PR feedback --- src/snappy_device_agents/devices/maas2/maas_storage.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index 5b621384..f9203ba6 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -25,8 +25,7 @@ class MaasStorageError(Exception): - def __init__(self, message): - super().__init__(message) + pass class MaasStorage: From 52058418e4c0d083fc4fb2f5008ff09b0fd2605b Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 13 Jul 2023 23:13:19 -0700 Subject: [PATCH 518/569] cleanup and logic cleanup based on PR feedback --- .../devices/maas2/maas2.py | 10 ++++----- .../devices/maas2/maas_storage.py | 22 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 42fd1ee8..9cb62524 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -249,9 +249,9 @@ def deploy_node( else: def_storage_data = self.config.get("default_disks") if not def_storage_data: - self._logger_warn( - "'default_disks' and/or 'disks' unspecified; \ - skipping storage layout configuration" + self._logger_warning( + "'default_disks' and/or 'disks' unspecified; " + "skipping storage layout configuration" ) else: # reset to the default layout @@ -261,8 +261,8 @@ def deploy_node( ) except MaasStorageError as error: self._logger_error( - f"Unable to reset node storage to \ - default_disk layout: {error}" + "Unable to reset node storage to " + f"default_disk layout: {error}" ) raise ProvisioningError from error diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index f9203ba6..34337962 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -63,14 +63,14 @@ def call_cmd(cmd, output_json=False): :return: subprocess stdout :raises MaasStorageError: on subprocess non-zero return code """ - try: - proc = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - check=False, - ) - except subprocess.CalledProcessError: + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + if proc.returncode != 0: raise MaasStorageError(proc.stdout.decode()) if proc.stdout: @@ -293,9 +293,9 @@ def create_partition_sizes(self): f"Partition '{str(dev['id'])}' does not have an " "alloc_pct or size value." ) - else: - # default to minimum required partition size - dev["size"] = self.convert_size_to_bytes(dev["size"]) + + # default to minimum required partition size + dev["size"] = self.convert_size_to_bytes(dev["size"]) def group_by_type(self): """Group storage devices by type for processing. From e9e5171906c47e1fbf9dbf649f96dbe5104ffbfd Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 3 Aug 2023 14:14:43 -0500 Subject: [PATCH 519/569] The correct name of a complete job should be "complete" --- src/snappy_device_agents/devices/multi/multi.py | 2 +- src/snappy_device_agents/devices/multi/tests/test_multi.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py index d0e4ce5f..c9648fcc 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -93,7 +93,7 @@ def this_job_complete(self): job_id = self.job_data.get("job_id") status = self.client.get_status(job_id) - if status in ("cancelled", "completed"): + if status in ("cancelled", "complete"): return True return False diff --git a/src/snappy_device_agents/devices/multi/tests/test_multi.py b/src/snappy_device_agents/devices/multi/tests/test_multi.py index 4bbf48b4..55906c97 100644 --- a/src/snappy_device_agents/devices/multi/tests/test_multi.py +++ b/src/snappy_device_agents/devices/multi/tests/test_multi.py @@ -80,13 +80,13 @@ def test_this_job_complete(): "job_id": "11111111-1111-1111-1111-111111111111", } - # completed state is complete + # complete state is detected as complete complete_client = MockTFClient("http://localhost") - complete_client.get_status = lambda job_id: "completed" + complete_client.get_status = lambda job_id: "complete" test_agent = Multi(test_config, job_data, complete_client) assert test_agent.this_job_complete() is True - # cancelled state is complete + # cancelled state is detected as complete cancelled_client = MockTFClient("http://localhost") cancelled_client.get_status = lambda job_id: "cancelled" test_agent = Multi(test_config, job_data, cancelled_client) From 7fc77ca21089b68526a0cdc0d0ce64d2caca8e7c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 3 Aug 2023 14:21:46 -0500 Subject: [PATCH 520/569] Fix state to be complete rather than completed for consistency --- testflinger_agent/job.py | 9 +++++---- testflinger_agent/tests/test_job.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index 69178925..f09af07c 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -145,18 +145,19 @@ def allocate_phase(self, rundir): def wait_for_completion(self): """Monitor the parent job and exit when it completes""" - # For now, we don't have to monitor our own job state, since the - # another thread monitors it. But if this changes, we'll need to watch - # for changes to our own job state while True: try: parent_job_state = self.client.check_job_state( self.job_data.get("parent_job_id") ) - if parent_job_state in ("completed", "cancelled"): + if parent_job_state in ("complete", "cancelled"): logger.info("Parent job completed, exiting...") break + this_job_state = self.client.check_job_state(self.job_id) + if this_job_state in ("complete", "cancelled"): + logger.info("This job completed, exiting...") + break except TFServerError: logger.warning("Failed to get parent job, retrying...") time.sleep(60) diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index 627b3c96..c9f11d7b 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -156,8 +156,8 @@ def test_set_truncate(self, client): def test_wait_for_completion(self, client): """Test that wait_for_completion works""" - # Make sure we return "completed" for the parent job state - client.check_job_state = lambda _: "completed" + # Make sure we return "complete" for the parent job state + client.check_job_state = lambda _: "complete" job = _TestflingerJob({"parent_job_id": "999"}, client) job.wait_for_completion() From d0553a4969b250b0b40c163e753f7246fc6e5dfa Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 7 Aug 2023 00:45:32 -0700 Subject: [PATCH 521/569] refine how we post agent data to influxdb for state timeline, status history views --- testflinger_agent/agent.py | 12 ++---------- testflinger_agent/client.py | 20 +++++++++++--------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 32aa486c..2888450f 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -16,7 +16,6 @@ import logging import os import shutil -import time from testflinger_agent.job import TestflingerJob from testflinger_agent.errors import TFServerError @@ -64,6 +63,7 @@ def _post_advertised_images(self): def set_agent_state(self, state): """Send the agent state to the server""" self.client.post_agent_data({"state": state}) + self.client.post_influx(state) def get_offline_files(self): # Return possible restart filenames with and without dashes @@ -157,17 +157,9 @@ def process_jobs(self): self.client.post_job_state(job.job_id, phase) self.set_agent_state(phase) - start_time = time.time() exitcode = job.run_test_phase(phase, rundir) - end_time = time.time() - duration = end_time - start_time - self.client.post_influx( - job.job_id, - phase, - duration, - exitcode, - ) + self.client.post_influx(phase, exitcode) # exit code 46 is our indication that recovery failed! # In this case, we need to mark the device offline diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index cabd898f..4a34c685 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -303,7 +303,7 @@ def post_agent_data(self, data): except RequestException as exc: logger.error(exc) - def post_influx(self, job_id, phase, duration, result): + def post_influx(self, phase, result=None): """Post the relevant data points to testflinger server :param data: @@ -312,26 +312,28 @@ def post_influx(self, job_id, phase, duration, result): if not self.influx_client: return + fields = { + "phase": phase + } + + if result is not None: + fields["result"] = result + data = [ { "measurement": "phase result", "tags": { "agent": self.config.get("agent_id"), - "phase": phase, - "result": result, - }, - "fields": { - "duration": duration, }, - "time": int(time.time()), - }, + "fields": fields, + "time": time.time_ns(), + } ] try: self.influx_client.write_points( data, database=self.influx_agent_db, - time_precision="s", protocol="json", ) except InfluxDBClientError as exc: From fe2c1f1c3da92ab0dc6e7ef95911e041ac8e3a73 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 7 Aug 2023 00:46:09 -0700 Subject: [PATCH 522/569] refine how we post agent data to influxdb for state timeline, status history views --- testflinger_agent/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 4a34c685..8c8fd283 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -312,9 +312,7 @@ def post_influx(self, phase, result=None): if not self.influx_client: return - fields = { - "phase": phase - } + fields = {"phase": phase} if result is not None: fields["result"] = result From 50675b0810c56f83568cf6b0cc287b3ac74c2384 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 7 Aug 2023 12:09:13 -0500 Subject: [PATCH 523/569] Update config items in the README.rst --- README.rst | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f806a8d1..39bcc051 100644 --- a/README.rst +++ b/README.rst @@ -49,7 +49,7 @@ The following configuration options are supported: - Time to sleep between polling for new tests (default: 10s) -- **server address**: +- **server_address**: - Host/IP and port of the testflinger server @@ -61,6 +61,10 @@ The following configuration options are supported: - Base directory to use for agent logging (default: /tmp/testflinger/logs) +- **results_basedir**: + + - Base directory to use for temporary storage of test results to be transmitted to the server (default: /tmp/testflinger/results) + - **logging_level**: - Python loglevel name to use for logging (default: INFO) @@ -81,6 +85,14 @@ The following configuration options are supported: - List of images to associate with a queue name so that they can be referenced by name when using testflinger reserve +- **global_timeout**: + + - Maximum global timeout (in seconds) a job is allowed to specify for this device agent. The job will timeout during the provision or test phase if it takes longer than the requested global_timeout to run. (Default 4 hours) + +- **output_timeout**: + + - Maximum output timeout (in seconds) a job is allowed to specify for this device agent. The job will timeout if there has been no output in the test phase for longer than the requested output_timeout. (Default 15 min.) + - **setup_command**: - Command to run for the setup phase @@ -89,10 +101,18 @@ The following configuration options are supported: - Command to run for the provision phase +- **allocate_command**: + + - Command to run for the allocate phase + - **test_command**: - Command to run for the testing phase +- **reserve_command**: + + - Command to run for the reserve phase + - **cleanup_command**: - Command to run for the cleanup phase From 614c8cfe07045d578f660ceb533fa180adfe8145 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 8 Aug 2023 08:52:23 -0500 Subject: [PATCH 524/569] Support both "complete" and "completed" as a state while we migrate to "completed" --- testflinger_agent/job.py | 4 ++-- testflinger_agent/tests/test_job.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index f09af07c..e49f52b0 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -151,11 +151,11 @@ def wait_for_completion(self): parent_job_state = self.client.check_job_state( self.job_data.get("parent_job_id") ) - if parent_job_state in ("complete", "cancelled"): + if parent_job_state in ("complete", "completed", "cancelled"): logger.info("Parent job completed, exiting...") break this_job_state = self.client.check_job_state(self.job_id) - if this_job_state in ("complete", "cancelled"): + if this_job_state in ("complete", "completed", "cancelled"): logger.info("This job completed, exiting...") break except TFServerError: diff --git a/testflinger_agent/tests/test_job.py b/testflinger_agent/tests/test_job.py index c9f11d7b..627b3c96 100644 --- a/testflinger_agent/tests/test_job.py +++ b/testflinger_agent/tests/test_job.py @@ -156,8 +156,8 @@ def test_set_truncate(self, client): def test_wait_for_completion(self, client): """Test that wait_for_completion works""" - # Make sure we return "complete" for the parent job state - client.check_job_state = lambda _: "complete" + # Make sure we return "completed" for the parent job state + client.check_job_state = lambda _: "completed" job = _TestflingerJob({"parent_job_id": "999"}, client) job.wait_for_completion() From d452e9ba50b1a305f33d2a7c9283ed78235c346e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 8 Aug 2023 08:53:16 -0500 Subject: [PATCH 525/569] Support both "complete" and "completed" as valid while we migrate to "completed" --- .../devices/multi/multi.py | 16 +++++++------- .../devices/multi/tests/test_multi.py | 22 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/snappy_device_agents/devices/multi/multi.py index c9648fcc..e7a5a8fb 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/snappy_device_agents/devices/multi/multi.py @@ -57,13 +57,13 @@ def provision(self): while unallocated: time.sleep(10) - self.terminate_if_parent_complete() + self.terminate_if_parent_completed() for job in unallocated: state = self.client.get_status(job) if state == "allocated": unallocated.remove(job) break - if state in ("cancelled", "complete"): + if state in ("cancelled", "complete", "completed"): logger.error( "Job %s failed to allocate, cancelling remaining jobs", job, @@ -79,21 +79,21 @@ def provision(self): self.save_job_list_file() - def terminate_if_parent_complete(self): - """If parent job is complete or cancelled, cancel sub jobs""" - if self.this_job_complete(): + def terminate_if_parent_completed(self): + """If parent job is completed or cancelled, cancel sub jobs""" + if self.this_job_completed(): self.cancel_jobs(self.jobs) raise ProvisioningError("Job cancelled or completed") - def this_job_complete(self): + def this_job_completed(self): """ - If the job is complete, or cancelled, then we need to exit the + If the job is completed, or cancelled, then we need to exit the provision phase, and cleanup the subordinate jobs """ job_id = self.job_data.get("job_id") status = self.client.get_status(job_id) - if status in ("cancelled", "complete"): + if status in ("cancelled", "completed"): return True return False diff --git a/src/snappy_device_agents/devices/multi/tests/test_multi.py b/src/snappy_device_agents/devices/multi/tests/test_multi.py index 55906c97..e37b6861 100644 --- a/src/snappy_device_agents/devices/multi/tests/test_multi.py +++ b/src/snappy_device_agents/devices/multi/tests/test_multi.py @@ -73,27 +73,27 @@ def test_inject_parent_jobid(): assert job["parent_job_id"] == parent_job_id -def test_this_job_complete(): - """Test this_job_complete() returns True only when the job is complete""" +def test_this_job_completed(): + """Test this_job_completed() returns True only when the job is completed""" test_config = {"agent_name": "test_agent"} job_data = { "job_id": "11111111-1111-1111-1111-111111111111", } - # complete state is detected as complete - complete_client = MockTFClient("http://localhost") - complete_client.get_status = lambda job_id: "complete" - test_agent = Multi(test_config, job_data, complete_client) - assert test_agent.this_job_complete() is True + # completed state is detected as completed + completed_client = MockTFClient("http://localhost") + completed_client.get_status = lambda job_id: "completed" + test_agent = Multi(test_config, job_data, completed_client) + assert test_agent.this_job_completed() is True - # cancelled state is detected as complete + # cancelled state is detected as completed cancelled_client = MockTFClient("http://localhost") cancelled_client.get_status = lambda job_id: "cancelled" test_agent = Multi(test_config, job_data, cancelled_client) - assert test_agent.this_job_complete() is True + assert test_agent.this_job_completed() is True - # anything else is not complete + # anything else is not completed incomplete_client = MockTFClient("http://localhost") incomplete_client.get_status = lambda job_id: "something else" test_agent = Multi(test_config, job_data, incomplete_client) - assert test_agent.this_job_complete() is False + assert test_agent.this_job_completed() is False From c72c1c2b46538f56c6e410ea6a5959e794c614b8 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 8 Aug 2023 09:59:32 -0500 Subject: [PATCH 526/569] Transitional patch to accept both complete and completed states as finished --- README.rst | 2 +- testflinger_cli/__init__.py | 8 ++++---- testflinger_cli/client.py | 2 +- testflinger_cli/tests/test_cli.py | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index b207f451..520cd4f9 100644 --- a/README.rst +++ b/README.rst @@ -54,7 +54,7 @@ You can check on the status of that job by running: To watch the output from the job as it runs, you can use the 'poll' subcommand. This will display output in 10s chunks and exit when the -job is complete. +job is completed. .. code-block:: console $ testflinger-cli poll diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 464d58a7..22bc7fa0 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -191,7 +191,7 @@ def get_args(self): ) arg_list_queues.set_defaults(func=self.list_queues) arg_poll = sub.add_parser( - "poll", help="Poll for output from a job until it is complete" + "poll", help="Poll for output from a job until it is completed" ) arg_poll.set_defaults(func=self.poll) arg_poll.add_argument( @@ -418,7 +418,7 @@ def artifacts(self): print("Artifacts downloaded to {}".format(self.args.filename)) def poll(self): - """Poll for output from a job until it is complete""" + """Poll for output from a job until it is completed""" if self.args.oneshot: # This could get an IOError for connection errors or timeouts # Raise it since it's not running continuously in this mode @@ -442,7 +442,7 @@ def do_poll(self, job_id): try: job_state = self.get_job_state(job_id) self.history.update(job_id, job_state) - if job_state in ("cancelled", "complete"): + if job_state in ("cancelled", "complete", "completed"): break if job_state == "waiting": queue_pos = self.client.get_job_position(job_id) @@ -490,7 +490,7 @@ def jobs(self): for job_id, jobdata in self.history.history.items(): if self.args.status: job_state = jobdata.get("job_state") - if job_state not in ("cancelled", "complete"): + if job_state not in ("cancelled", "complete", "completed"): job_state = self.get_job_state(job_id) self.history.update(job_id, job_state) else: diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index def3d52a..4c6e4bbe 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -94,7 +94,7 @@ def get_status(self, job_id): :return: String containing the job_state for the specified ID (waiting, setup, provision, test, reserved, released, - cancelled, complete) + cancelled, completed) """ endpoint = "/v1/result/{}".format(job_id) data = json.loads(self.get(endpoint)) diff --git a/testflinger_cli/tests/test_cli.py b/testflinger_cli/tests/test_cli.py index 576e7665..a82a805f 100644 --- a/testflinger_cli/tests/test_cli.py +++ b/testflinger_cli/tests/test_cli.py @@ -32,13 +32,13 @@ def test_status(capsys, requests_mock): """Status should report job_state data""" jobid = str(uuid.uuid1()) - fake_return = {"job_state": "complete"} + fake_return = {"job_state": "completed"} requests_mock.get(URL + "/v1/result/" + jobid, json=fake_return) sys.argv = ["", "status", jobid] tfcli = testflinger_cli.TestflingerCli() tfcli.status() std = capsys.readouterr() - assert std.out == "complete\n" + assert std.out == "completed\n" def test_cancel(requests_mock): @@ -73,22 +73,22 @@ def test_submit(capsys, tmp_path, requests_mock): def test_show(capsys, requests_mock): """Exercise show command""" jobid = str(uuid.uuid1()) - fake_return = {"job_state": "complete"} + fake_return = {"job_state": "completed"} requests_mock.get(URL + "/v1/job/" + jobid, json=fake_return) sys.argv = ["", "show", jobid] tfcli = testflinger_cli.TestflingerCli() tfcli.show() std = capsys.readouterr() - assert "complete" in std.out + assert "completed" in std.out def test_results(capsys, requests_mock): """results should report job_state data""" jobid = str(uuid.uuid1()) - fake_return = {"job_state": "complete"} + fake_return = {"job_state": "completed"} requests_mock.get(URL + "/v1/result/" + jobid, json=fake_return) sys.argv = ["", "results", jobid] tfcli = testflinger_cli.TestflingerCli() tfcli.results() std = capsys.readouterr() - assert "complete" in std.out + assert "completed" in std.out From c57da15d84c1ee5df99f2e5f533ffc643da21e03 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 16 Aug 2023 14:20:53 -0500 Subject: [PATCH 527/569] Update README.rst with information about job phases --- README.rst | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/README.rst b/README.rst index 39bcc051..b2d6ac93 100644 --- a/README.rst +++ b/README.rst @@ -117,6 +117,66 @@ The following configuration options are supported: - Command to run for the cleanup phase +Test Phases +----------- +The test will go through several phases depending on the configuration of the +test job and the configuration testflinger agent itself. If a _command +is not set in the testflinger-agent.conf (see above), then that phase will +be skipped. Even if the phase_command is configured, there are some phases +that are not mandatory, and will be skipped if the job does not contain data +for it, such as the provision, test, allocate, and reserve phases. + +The following test phases are currently supported: + +- **setup**: + + - This phase is run first, and is used to setup the environment for the + test. The test job has no input for this phase and it is completely up to + the device owner to include commands that may need to run here. + +- **provision**: + + - This phase is run after the setup phase, and is used to provision the + device by installing (if possible) the image requested in the test job. + If the provision_data section is missing from the job, this phase will + not run. + +- **test**: + + - This phase is run after the provision phase, and is used to run the + test_cmds defined in the test_data section of the job. If the test_data + section is missing from the job, this will not run. + +- **allocate**: + + - This phase is normally only used by multi-device jobs and is used to + lock the agent into an allocated state to be externally controlled by + another job. During this phase, it will gather device_ip information + and push that information to the results data on the testflinger server + under the running job's job_id. Once that data is pushed successfully + to the server, it will transition the job to a **allocated** state, which + is just a signal that the parent job can make use of that data. The + **allocated** state is just a *job* state though, and not a phase that + needs a separate command configured on the agent. + Normally, the allocate_data section will be missing from the test job, + and this phase will be skipped. + +- **reserve**: + + - This phase is used for reserving a system for manual control. This + will push the requested ssh key specified in the job data to the + device once it's provisioned and ready for use, then publish output + to the polling log with information on how to reach the device over + ssh. If the reserve_data section is missing from the job, then this + phase will be skipped. + +- **cleanup**: + + - This phase is run after the reserve phase, and is used to cleanup the + device after the test. The test job has no input for this phase and + it is completely up to the device owner to include commands + that may need to run here. + Usage ----- From c5c2182b0a8e71155eba09ec1e29a2812b719ac4 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 18 Aug 2023 12:42:34 -0500 Subject: [PATCH 528/569] Tell the server not to decode gzipped artifact downloads --- testflinger_cli/client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/testflinger_cli/client.py b/testflinger_cli/client.py index 4c6e4bbe..8096f4ef 100644 --- a/testflinger_cli/client.py +++ b/testflinger_cli/client.py @@ -157,12 +157,13 @@ def get_artifact(self, job_id, path): """ endpoint = "/v1/result/{}/artifact".format(job_id) uri = urllib.parse.urljoin(self.server, endpoint) - req = requests.get(uri, timeout=15) + req = requests.get(uri, timeout=15, stream=True) if req.status_code != 200: raise HTTPError(req.status_code) with open(path, "wb") as artifact: - for chunk in req.iter_content(chunk_size=4096): - artifact.write(chunk) + for chunk in req.raw.stream(4096, decode_content=False): + if chunk: + artifact.write(chunk) def get_output(self, job_id): """Get the latest output for a specified test job From 5d8a0b7f7758b4b3a89710831c536a6ef57861a8 Mon Sep 17 00:00:00 2001 From: Vic Liu Date: Mon, 21 Aug 2023 22:31:32 +0800 Subject: [PATCH 529/569] Add: Support for provisioning erlangen devices to muxpi agent - Change the limerick specific image type to oem - Add regex in the grep command to check both limerick and erlangen image --- .../data/muxpi/{limerick => oem}/user-data | 0 src/snappy_device_agents/devices/muxpi/muxpi.py | 12 ++++++------ 2 files changed, 6 insertions(+), 6 deletions(-) rename src/snappy_device_agents/data/muxpi/{limerick => oem}/user-data (100%) diff --git a/src/snappy_device_agents/data/muxpi/limerick/user-data b/src/snappy_device_agents/data/muxpi/oem/user-data similarity index 100% rename from src/snappy_device_agents/data/muxpi/limerick/user-data rename to src/snappy_device_agents/data/muxpi/oem/user-data diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 8e45f6fd..151e917f 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -236,13 +236,13 @@ def get_image_type(self): def check_path(dir): self._run_control("test -e {}".format(dir)) - # First check if this is a limerick image + # First check if this is an oem image try: disk_info_path = self.mount_point / "writable/.disk/info" - self._run_control(f"grep limerick {disk_info_path}") - return "limerick" + self._run_control(f"grep -E 'limerick|erlangen' {disk_info_path}") + return "oem" except ProvisioningError: - # Not a limerick image + # Not an oem image pass try: @@ -284,10 +284,10 @@ def create_user(self, image_type): remote_tmp = Path("/tmp") / self.agent_name try: data_path = Path(__file__).parent / "../../data/muxpi" - if image_type == "limerick": + if image_type == "oem": self._run_control("mkdir -p {}".format(remote_tmp)) self._copy_to_control( - data_path / "limerick/user-data", remote_tmp + data_path / "oem/user-data", remote_tmp ) cmd = f"sudo cp {remote_tmp}/user-data {base}/system-boot/" self._run_control(cmd) From a2a3ee4f7a64021ee5ee4136a9abad2f12a8942c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 21 Aug 2023 10:29:40 -0500 Subject: [PATCH 530/569] Fix black formatting issues --- src/snappy_device_agents/devices/muxpi/muxpi.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 151e917f..e640172b 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -286,9 +286,7 @@ def create_user(self, image_type): data_path = Path(__file__).parent / "../../data/muxpi" if image_type == "oem": self._run_control("mkdir -p {}".format(remote_tmp)) - self._copy_to_control( - data_path / "oem/user-data", remote_tmp - ) + self._copy_to_control(data_path / "oem/user-data", remote_tmp) cmd = f"sudo cp {remote_tmp}/user-data {base}/system-boot/" self._run_control(cmd) self._configure_sudo() From 8f327874c6e2e2413fd77b33df5d5ed7f211b26f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 22 Aug 2023 16:13:01 -0500 Subject: [PATCH 531/569] A few updates to the readme --- README.rst | 100 +++++++++-------------------------------------------- 1 file changed, 16 insertions(+), 84 deletions(-) diff --git a/README.rst b/README.rst index 59521bb4..a7411aa9 100644 --- a/README.rst +++ b/README.rst @@ -7,90 +7,22 @@ devices Supported Devices ================= -BeagleBone Black ----------------- - -To use snappy-device-agent with a BeagleBone, you will need to first -replace the image on emmc with a customized one. The installer image -can be downloaded from:: - - http://people.canonical.com/~plars/snappy/ - -Next, create a config file to give it some hints about your environment. -The config file for BeagleBone needs to have the address (host or ip) of -you test system once it boots, a list of commands to force it to boot the -master (emmc) image, a list of commands to force it to boot the test (snappy) -image, and a list of commands to force a hard poweroff/poweron. - -If you have a very simple setup, these command scripts could be as -simple as a script to run over ssh. In a production environment, it -could be calling a REST API to trigger a relay and force these things. -Different devices can even use different config files. The config file -gives you the flexibility to define what works for this particular device. - -Example:: - - device_ip: 192.168.1.147 - select_master_script: - - ssh pi@192.168.1.136 bin/setboot master - select_test_script: - - ssh pi@192.168.1.136 bin/setboot test - reboot_script: - - ssh pi@192.168.1.136 bin/hardreset - -Raspberry Pi 2 --------------- -Using the provisioning kit with Raspberry Pi 2 is similar to the Beaglebone. -The rpi2 will need to have a USB stick inserted for the test image, and the -SD card should boot the raspberry pi image from:: - - http://people.canonical.com/~plars/snappy/ - -The snappy-device-agent script should just be called with the rpi2 -subcommand instead of bbb. - -Also, the default.yaml file passed to snappy-device-agent should include:: - - test_device: /dev/sda - -x86-64 Baremetal ----------------- - -The x86 baremetal device is currently supported using a process called inception. We boot from an ubuntu-server install running on a usb stick by default, then modify the grub entry on the host to add a boot entry for snappy on the hard drive. - -The boot entry looks like this:: - - # LAAS Inception Marker (do not remove) - menuentry "LAAS Inception Test Boot (one time)" { - insmod chain - set root=hd1 - chainloader +1 - } - # Boot into LAAS Inception OS if requested - if [ "${laas_inception}" ] ; then - set fallback="${default}" - set default="LAAS Inception Test Boot (one time)" - set laas_inception= - save_env laas_inception - set boot_once=true - fi - -To boot into this instance, you simply set the laas_inception grub variable to 1, and it will boot once into the install from the primary hard drive:: - - $ sudo grub-editenv /boot/grub/grubenv set laas_inception=1 - -Because we install to the hard drive, and not a mmc with a known location, you should also specify the test device. Here is a complete example of a config yaml file:: - - device_ip: 10.101.48.47 - test_device: /dev/sda - select_master_script: [] - select_test_script: - - ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 10.101.48.47 sudo grub-editenv /boot/grub/grubenv set laas_inception=1 - reboot_script: - - snmpset -c private -v1 pdu11.cert-maas.taipei .1.3.6.1.4.1.318.1.1.12.3.3.1.1.4.6 i 2 - - sleep 10 - - snmpset -c private -v1 pdu11.cert-maas.taipei .1.3.6.1.4.1.318.1.1.12.3.3.1.1.4.6 i 1 - +The following device agent types are currently supported, however most of them +require a very specific environment in order to work properly. That's part of +the reason why they are broken out into a separate project. Nothing here is +really required to run testflinger, only to support these devices in our +environment. Alternative device agents could be written in order to support +testing on other types of devices. + +- cm3 - Raspberry PI CM3 with a sidecar device and tools to support putting it in otg mode to flash an image +- dragonboard - dragonboard with a stable image on usb and test images are flashed to a wiped sd with a dual boot process +- maas2 - Metal as a Service (MaaS) systems, which support additional features such as disk layouts. Images provisioned must be imported first! +- multi - multi-device agent used for provisioning jobs that span multiple devices at once +- muxpi - muxpi/sdwire provisioned devices that utilize a device that can write to an sd the boot it on the DUT +- netboot - minimal netboot initramfs process for a specific device that couldn't be provisioned with MaaS +- noprovision - devices which need to run tests, but can't be provisioned (yet) +- oemrecovery - anything (such as core fde images) that can't be provisioned but can run a set of commands to recover back to the initial state +- oemscript - uses a script that supports some oem images and allows injection of an iso to the recovery partition to install that image Exit Status From ad1716b7f9b31d78f2dfe6713b24b50da5b3c0bd Mon Sep 17 00:00:00 2001 From: Vic Liu Date: Wed, 23 Aug 2023 23:21:07 +0800 Subject: [PATCH 532/569] Fix: Change image_type "oem" to "ce-oem-iot" to better reflect the image type --- .../data/muxpi/{oem => ce-oem-iot}/user-data | 0 src/snappy_device_agents/devices/muxpi/muxpi.py | 10 +++++----- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/snappy_device_agents/data/muxpi/{oem => ce-oem-iot}/user-data (100%) diff --git a/src/snappy_device_agents/data/muxpi/oem/user-data b/src/snappy_device_agents/data/muxpi/ce-oem-iot/user-data similarity index 100% rename from src/snappy_device_agents/data/muxpi/oem/user-data rename to src/snappy_device_agents/data/muxpi/ce-oem-iot/user-data diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 151e917f..b6e6d8b4 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -236,13 +236,13 @@ def get_image_type(self): def check_path(dir): self._run_control("test -e {}".format(dir)) - # First check if this is an oem image + # First check if this is a ce-oem-iot image try: disk_info_path = self.mount_point / "writable/.disk/info" self._run_control(f"grep -E 'limerick|erlangen' {disk_info_path}") - return "oem" + return "ce-oem-iot" except ProvisioningError: - # Not an oem image + # Not a ce-oem-iot image pass try: @@ -284,10 +284,10 @@ def create_user(self, image_type): remote_tmp = Path("/tmp") / self.agent_name try: data_path = Path(__file__).parent / "../../data/muxpi" - if image_type == "oem": + if image_type == "ce-oem-iot": self._run_control("mkdir -p {}".format(remote_tmp)) self._copy_to_control( - data_path / "oem/user-data", remote_tmp + data_path / "ce-oem-iot/user-data", remote_tmp ) cmd = f"sudo cp {remote_tmp}/user-data {base}/system-boot/" self._run_control(cmd) From 6b0c1ec4ef0e2542b6823712e717130b712ea934 Mon Sep 17 00:00:00 2001 From: Vic Liu Date: Thu, 24 Aug 2023 00:18:08 +0800 Subject: [PATCH 533/569] Change: Using regex to check the image buildstamp format instead of only checking the project codename --- src/snappy_device_agents/devices/muxpi/muxpi.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index b6e6d8b4..9ff0a8f7 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -239,7 +239,8 @@ def check_path(dir): # First check if this is a ce-oem-iot image try: disk_info_path = self.mount_point / "writable/.disk/info" - self._run_control(f"grep -E 'limerick|erlangen' {disk_info_path}") + buildstamp = '"iot-[a-z]+-[a-z-]*(classic-(server|desktop)-[0-9]+|core-[0-9]+)"' + self._run_control(f"grep -E {buildstamp} {disk_info_path}") return "ce-oem-iot" except ProvisioningError: # Not a ce-oem-iot image From 47f711feae0befc2f6fb24bd27a33de4728a4156 Mon Sep 17 00:00:00 2001 From: Vic Liu Date: Thu, 24 Aug 2023 00:55:12 +0800 Subject: [PATCH 534/569] Fix tox testing issue --- src/snappy_device_agents/devices/muxpi/muxpi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index c5c1bdcc..09a7ea11 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -239,7 +239,8 @@ def check_path(dir): # First check if this is a ce-oem-iot image try: disk_info_path = self.mount_point / "writable/.disk/info" - buildstamp = '"iot-[a-z]+-[a-z-]*(classic-(server|desktop)-[0-9]+|core-[0-9]+)"' + buildstamp = '"iot-[a-z]+-[a-z-]*(classic-(server|desktop)-[0-9]+' + buildstamp += '|core-[0-9]+)"' self._run_control(f"grep -E {buildstamp} {disk_info_path}") return "ce-oem-iot" except ProvisioningError: @@ -287,7 +288,9 @@ def create_user(self, image_type): data_path = Path(__file__).parent / "../../data/muxpi" if image_type == "ce-oem-iot": self._run_control("mkdir -p {}".format(remote_tmp)) - self._copy_to_control(data_path / "ce-oem-iot/user-data", remote_tmp) + self._copy_to_control( + data_path / "ce-oem-iot/user-data", remote_tmp + ) cmd = f"sudo cp {remote_tmp}/user-data {base}/system-boot/" self._run_control(cmd) self._configure_sudo() From 1c8cc882a7d1e579a06357ed5a09889f54c3b2fd Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 24 Aug 2023 13:47:10 -0500 Subject: [PATCH 535/569] Don't automatically resubmit jobs when they fail to provision --- testflinger_agent/agent.py | 4 ---- testflinger_agent/tests/test_agent.py | 5 ----- 2 files changed, 9 deletions(-) diff --git a/testflinger_agent/agent.py b/testflinger_agent/agent.py index 2888450f..a9f049b6 100644 --- a/testflinger_agent/agent.py +++ b/testflinger_agent/agent.py @@ -165,10 +165,6 @@ def process_jobs(self): # In this case, we need to mark the device offline if exitcode == 46: self.mark_device_offline() - self.client.repost_job(job_data) - shutil.rmtree(rundir) - # Return NOW so we don't keep trying to process jobs - return if phase != "test" and exitcode: logger.debug("Phase %s failed, aborting job" % phase) break diff --git a/testflinger_agent/tests/test_agent.py b/testflinger_agent/tests/test_agent.py index eade3645..591f9a38 100644 --- a/testflinger_agent/tests/test_agent.py +++ b/testflinger_agent/tests/test_agent.py @@ -198,13 +198,8 @@ def test_recovery_failed(self, agent, requests_mock): + self.config.get("agent_id"), text="OK", ) - mpost_job_json = m.post( - "http://127.0.0.1:8000/v1/job", json={"job_id": job_id} - ) agent.process_jobs() assert agent.check_offline() - # These are the args we would expect when it reposts the job - assert mpost_job_json.last_request.json() == fake_job_data if os.path.exists(OFFLINE_FILE): os.unlink(OFFLINE_FILE) From 9a97eb1125a5d3dd07c3dc979684e1bf25e7e33c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Sat, 26 Aug 2023 19:35:28 -0500 Subject: [PATCH 536/569] multi-device agents should ignore error 400 if job is already cancelled --- src/snappy_device_agents/devices/multi/tfclient.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/multi/tfclient.py b/src/snappy_device_agents/devices/multi/tfclient.py index 79c19bf5..f21fe6d7 100644 --- a/src/snappy_device_agents/devices/multi/tfclient.py +++ b/src/snappy_device_agents/devices/multi/tfclient.py @@ -78,7 +78,9 @@ def post(self, uri_frag, data, timeout=15): try: req = requests.post(uri, json=data, timeout=timeout) except requests.exceptions.ConnectTimeout: - logger.error("Timout while trying to communicate with the server.") + logger.error( + "Timeout while trying to communicate with the server." + ) raise except requests.exceptions.ConnectionError: logger.error("Unable to communicate with specified server.") @@ -145,6 +147,10 @@ def cancel_job(self, job_id): """Tell the server to cancel a specified job_id""" try: self.post(f"/v1/job/{job_id}/action", {"action": "cancel"}) + except requests.exceptions.HTTPError as exc: + # Ignore it if the job is already cancelled or completed + if exc.response.status_code != 400: + raise except OSError: logger.error("Unable to cancel job %s", job_id) raise From 6a8e81233c734278b02091b589cdfa26677bc67e Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 28 Aug 2023 11:02:39 -0500 Subject: [PATCH 537/569] Reuse sessions more when talking to the server --- testflinger_agent/client.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/testflinger_agent/client.py b/testflinger_agent/client.py index 8c8fd283..df01db0c 100644 --- a/testflinger_agent/client.py +++ b/testflinger_agent/client.py @@ -93,8 +93,8 @@ def check_jobs(self): job_uri = urljoin(self.server, "/v1/job") queue_list = self.config.get("job_queues") logger.debug("Requesting a job") - job_request = requests.get( - job_uri, params={"queue": queue_list}, timeout=10 + job_request = self.session.get( + job_uri, params={"queue": queue_list}, timeout=30 ) if job_request.content: return job_request.json() @@ -156,7 +156,7 @@ def post_result(self, job_id, data): result_uri = urljoin(self.server, "/v1/result/") result_uri = urljoin(result_uri, job_id) try: - job_request = requests.post(result_uri, json=data, timeout=30) + job_request = self.session.post(result_uri, json=data, timeout=30) except RequestException as exc: logger.error(exc) raise TFServerError("other exception") from exc @@ -179,7 +179,7 @@ def get_result(self, job_id): result_uri = urljoin(self.server, "/v1/result/") result_uri = urljoin(result_uri, job_id) try: - job_request = requests.get(result_uri, timeout=30) + job_request = self.session.get(result_uri, timeout=30) except RequestException as exc: logger.error(exc) return {} @@ -223,7 +223,7 @@ def transmit_job_outcome(self, rundir): file_upload = { "file": ("file", tarball, "application/x-gzip") } - artifact_request = requests.post( + artifact_request = self.session.post( artifact_uri, files=file_upload, timeout=600 ) if not artifact_request: @@ -258,7 +258,7 @@ def post_live_output(self, job_id, data): self.server, "/v1/result/{}/output".format(job_id) ) try: - job_request = requests.post( + job_request = self.session.post( output_uri, data=data.encode("utf-8"), timeout=60 ) except RequestException as exc: @@ -274,7 +274,7 @@ def post_queues(self, data): """ queues_uri = urljoin(self.server, "/v1/agents/queues") try: - requests.post(queues_uri, json=data, timeout=30) + self.session.post(queues_uri, json=data, timeout=30) except RequestException as exc: logger.error(exc) @@ -286,7 +286,7 @@ def post_images(self, data): """ images_uri = urljoin(self.server, "/v1/agents/images") try: - requests.post(images_uri, json=data, timeout=30) + self.session.post(images_uri, json=data, timeout=30) except RequestException as exc: logger.error(exc) From 1a1b17ce5ff88ebaf56237bbc41057439c8cede0 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 31 Aug 2023 11:22:15 -0500 Subject: [PATCH 538/569] If parent job doesn't exist, don't poll the state during allocate --- testflinger_agent/job.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/testflinger_agent/job.py b/testflinger_agent/job.py index e49f52b0..77bdc4f6 100644 --- a/testflinger_agent/job.py +++ b/testflinger_agent/job.py @@ -148,18 +148,23 @@ def wait_for_completion(self): while True: try: + this_job_state = self.client.check_job_state(self.job_id) + if this_job_state in ("complete", "completed", "cancelled"): + logger.info("This job completed, exiting...") + break + + parent_job_id = self.job_data.get("parent_job_id") + if not parent_job_id: + logger.warning("No parent job ID found while allocated") + continue parent_job_state = self.client.check_job_state( self.job_data.get("parent_job_id") ) if parent_job_state in ("complete", "completed", "cancelled"): logger.info("Parent job completed, exiting...") break - this_job_state = self.client.check_job_state(self.job_id) - if this_job_state in ("complete", "completed", "cancelled"): - logger.info("This job completed, exiting...") - break except TFServerError: - logger.warning("Failed to get parent job, retrying...") + logger.warning("Failed to get allocated job status, retrying") time.sleep(60) def _set_truncate(self, f, size=1024 * 1024): From 6ca542b3b62e52b10fea8934b0f4a812c3fc79aa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 13:43:30 +0000 Subject: [PATCH 539/569] Update actions/checkout action to v4 --- .github/workflows/tox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index c9011a8b..1d501837 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -13,7 +13,7 @@ jobs: matrix: python: ["3.8", "3.10"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 - name: Set up Python uses: actions/setup-python@v4 with: From 020cf51118a95a29d3dbb47ece108c9ca2a0eed5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 14:58:15 +0000 Subject: [PATCH 540/569] Update actions/checkout action to v4 --- .github/workflows/tox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index c9011a8b..329db888 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -13,7 +13,7 @@ jobs: matrix: python: ["3.8", "3.10"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: From 12d5ff6aa71e47d3c0f147ebd5e2a520551f7671 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 6 Sep 2023 13:09:41 -0500 Subject: [PATCH 541/569] Make noise if there's an unknown error during cancel --- testflinger_cli/__init__.py | 1 + testflinger_cli/tests/test_cli.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/testflinger_cli/__init__.py b/testflinger_cli/__init__.py index 22bc7fa0..241c8983 100644 --- a/testflinger_cli/__init__.py +++ b/testflinger_cli/__init__.py @@ -274,6 +274,7 @@ def cancel(self, job_id=None): "Received 404 error from server. Are you " "sure this is a testflinger server?" ) from exc + raise def configure(self): """Print or set configuration values""" diff --git a/testflinger_cli/tests/test_cli.py b/testflinger_cli/tests/test_cli.py index a82a805f..137f876c 100644 --- a/testflinger_cli/tests/test_cli.py +++ b/testflinger_cli/tests/test_cli.py @@ -24,6 +24,7 @@ import pytest import testflinger_cli +from testflinger_cli.client import HTTPError URL = "https://testflinger.canonical.com" @@ -41,6 +42,20 @@ def test_status(capsys, requests_mock): assert std.out == "completed\n" +def test_cancel_503(requests_mock): + """Cancel should fail loudly if cancel action returns 503""" + jobid = str(uuid.uuid1()) + requests_mock.post( + URL + "/v1/job/" + jobid + "/action", + status_code=503, + ) + sys.argv = ["", "cancel", jobid] + tfcli = testflinger_cli.TestflingerCli() + with pytest.raises(HTTPError) as err: + tfcli.cancel() + assert err.value.status == 503 + + def test_cancel(requests_mock): """Cancel should fail if /v1/job//action URL returns 400 code""" jobid = str(uuid.uuid1()) From d3e78517b55abee73028fc164199824631f30367 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Thu, 7 Sep 2023 00:41:19 -0700 Subject: [PATCH 542/569] fixes the following issues: 74, 76, 77. Now successfully applies captured default configs. --- .../devices/maas2/maas2.py | 3 +- .../devices/maas2/maas_storage.py | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/snappy_device_agents/devices/maas2/maas2.py index 9cb62524..bcc12d31 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/snappy_device_agents/devices/maas2/maas2.py @@ -238,7 +238,7 @@ def deploy_node( self.recover() status = self.node_status() # do not process an empty dataset - if storage_data: + if storage_data is not None: try: self.maas_storage.configure_node_storage(storage_data) except MaasStorageError as error: @@ -248,6 +248,7 @@ def deploy_node( raise ProvisioningError from error else: def_storage_data = self.config.get("default_disks") + self._logger_debug(f"Using def storage data: {def_storage_data}") if not def_storage_data: self._logger_warning( "'default_disks' and/or 'disks' unspecified; " diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index 34337962..ec0d6397 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -372,7 +372,10 @@ def _get_child_device(self, parent_device): """ children = [] for dev in self.device_list: - if dev["parent_disk"] == parent_device["id"]: + if ( + "parent_disk" in dev + and dev["parent_disk"] == parent_device["id"] + ): children.append(dev) return children @@ -384,11 +387,11 @@ def process_disk(self, device): self._logger_debug( { "device_id": device["id"], - "name": device["name"], "number": device.get("number"), "block-id": device["parent_disk_blkid"], } ) + # find boot mounts on child types children = self._get_child_device(device) @@ -401,8 +404,8 @@ def process_disk(self, device): self._set_boot_disk(device["parent_disk_blkid"]) break # apply disk name - if "name" in device: - # self.call_cmd( + if device.get("name"): + self._logger_debug({"name": device["name"]}) self.call_cmd( [ "maas", @@ -457,7 +460,12 @@ def _get_format_partition_id(self, volume): :return: the node partition ID """ for dev in self.device_list: - if volume == dev["id"]: + # sanitize comparison to accomidate user defined types + if dev["type"] == "partition" and str(volume) in [ + str(dev["id"]), + str(dev["device"]), + str(dev["number"]), + ]: return dev["partition_id"] def process_format(self, device): @@ -474,9 +482,14 @@ def process_format(self, device): "parent disk block-id": device["parent_disk_blkid"], } ) - # format partition - if "volume" in device: + if device.get("volume"): partition_id = self._get_format_partition_id(device["volume"]) + # make sure we can fetch the newly created parent partition_id + if partition_id is None: + raise MaasStorageError( + "Unable to find partition ID for volume" + f" {device['volume']}" + ) self.call_cmd( [ "maas", @@ -490,7 +503,7 @@ def process_format(self, device): f"label={device['label']}", ] ) - # format block-device + # if the device does not have a 'volume' key, it's a block device else: self.call_cmd( [ From 7b139a292a6048486868bcdc465304c36cea3118 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Mon, 11 Sep 2023 15:33:31 -0500 Subject: [PATCH 543/569] Break out check_ce_oem_iot_image() and add unit tests --- .../devices/muxpi/muxpi.py | 41 +++++++++++++------ .../devices/muxpi/tests/test_muxpi.py | 33 +++++++++++++++ tox.ini | 1 + 3 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/snappy_device_agents/devices/muxpi/muxpi.py index 09a7ea11..72d8d4da 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/muxpi.py @@ -40,11 +40,16 @@ class MuxPi: "cloudimg-rootfs/etc/cloud/cloud.cfg": "ubuntu-cpc", } - def __init__(self, config, job_data): - with open(config) as configfile: - self.config = yaml.safe_load(configfile) - with open(job_data) as j: - self.job_data = json.load(j) + def __init__(self, config=None, job_data=None): + if config and job_data: + with open(config) as configfile: + self.config = yaml.safe_load(configfile) + with open(job_data) as j: + self.job_data = json.load(j) + else: + # For testing + self.config = {"agent_name": "test"} + self.job_data = {} self.agent_name = self.config.get("agent_name") self.mount_point = Path("/mnt") / self.agent_name @@ -237,15 +242,8 @@ def check_path(dir): self._run_control("test -e {}".format(dir)) # First check if this is a ce-oem-iot image - try: - disk_info_path = self.mount_point / "writable/.disk/info" - buildstamp = '"iot-[a-z]+-[a-z-]*(classic-(server|desktop)-[0-9]+' - buildstamp += '|core-[0-9]+)"' - self._run_control(f"grep -E {buildstamp} {disk_info_path}") + if self.check_ce_oem_iot_image(): return "ce-oem-iot" - except ProvisioningError: - # Not a ce-oem-iot image - pass try: disk_info_path = ( @@ -268,6 +266,23 @@ def check_path(dir): # We have no idea what kind of image this is return "unknown" + def check_ce_oem_iot_image(self) -> bool: + """ + Determine if this is a ce-oem-iot image + + These images will have a .disk/info file with a buildstamp in it + that looks like: + iot-$project-$series-classic-(server|desktop)-$buildId + """ + try: + disk_info_path = self.mount_point / "writable/.disk/info" + buildstamp = '"iot-[a-z]+-[a-z-]*(classic-(server|desktop)-[0-9]+' + buildstamp += '|core-[0-9]+)"' + self._run_control(f"grep -E {buildstamp} {disk_info_path}") + return True + except ProvisioningError: + return False + def unmount_writable_partition(self): try: self._run_control( diff --git a/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py b/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py new file mode 100644 index 00000000..ae540cce --- /dev/null +++ b/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py @@ -0,0 +1,33 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Unit tests for muxpi device agent""" + +from subprocess import CalledProcessError +from snappy_device_agents.devices.muxpi.muxpi import MuxPi + + +def test_check_ce_oem_iot_image(mocker): + """Test check_ce_oem_iot_image.""" + mocker.patch( + "subprocess.check_output", + return_value="iot-limerick-kria-classic-desktop-2204".encode(), + ) + muxpi = MuxPi() + assert muxpi.check_ce_oem_iot_image() is True + + mocker.patch( + "subprocess.check_output", + side_effect=CalledProcessError(1, "cmd"), + ) + assert muxpi.check_ce_oem_iot_image() is False diff --git a/tox.ini b/tox.ini index 54c75049..ac28e296 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ deps = pytest pylint pytest-cov + pytest-mock commands = {envbindir}/pip3 install . {envbindir}/python -m black --check src From 807181dc5237760f487ebe9847a80c00bae9a2ad Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 11 Sep 2023 23:30:20 -0700 Subject: [PATCH 544/569] Incorporate suggested changes in PR82, and incorporated valid volume/partition id in the process_format unit tests --- .../devices/maas2/maas_storage.py | 32 ++++++----- .../devices/maas2/tests/test_maas_storage.py | 54 +++++++++++-------- 2 files changed, 48 insertions(+), 38 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/snappy_device_agents/devices/maas2/maas_storage.py index ec0d6397..76803a47 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/maas_storage.py @@ -372,10 +372,7 @@ def _get_child_device(self, parent_device): """ children = [] for dev in self.device_list: - if ( - "parent_disk" in dev - and dev["parent_disk"] == parent_device["id"] - ): + if dev.get("parent_disk") == parent_device["id"]: children.append(dev) return children @@ -503,20 +500,21 @@ def process_format(self, device): f"label={device['label']}", ] ) + return + # if the device does not have a 'volume' key, it's a block device - else: - self.call_cmd( - [ - "maas", - self.maas_user, - "partition", - "format", - self.node_id, - device["parent_disk_blkid"], - f"fstype={device['fstype']}", - f"label={device['label']}", - ] - ) + self.call_cmd( + [ + "maas", + self.maas_user, + "partition", + "format", + self.node_id, + device["parent_disk_blkid"], + f"fstype={device['fstype']}", + f"label={device['label']}", + ] + ) def _get_mount_partition_id(self, device): """Get the partition ID from the specified mount path. diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index 0f40c47a..e1c2e372 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -454,9 +454,16 @@ def test_process_partition(self, maas_storage): assert device["partition_id"] == "3" - def test_process_format(self, maas_storage): + @pytest.mark.parametrize( + "partition_id, expected_error", + [ + (2, None), # Valid partition ID + (None, MaasStorageError) # Invalid partition ID + ] + ) + def test_process_format(self, maas_storage, partition_id, expected_error): """Checks if 'process_format' correctly processes a 'format' - device type. + device type, with and without a valid 'volume' attribute. """ device = { "id": 4, @@ -468,27 +475,32 @@ def test_process_format(self, maas_storage): "volume": "volume", } - maas_storage._get_format_partition_id = MagicMock(return_value=2) - - maas_storage.process_format(device) - - maas_storage._get_format_partition_id.assert_called_with( - device["volume"] + maas_storage._get_format_partition_id = MagicMock( + return_value=partition_id ) - maas_storage.call_cmd_mock.assert_called_with( - [ - "maas", - maas_storage.maas_user, - "partition", - "format", - maas_storage.node_id, - device["parent_disk_blkid"], - 2, - f"fstype={device['fstype']}", - f"label={device['label']}", - ] - ) + if expected_error: + with pytest.raises( + expected_error, match=r"Unable to find partition ID for volume" + ): + maas_storage.process_format(device) + else: + maas_storage.process_format(device) + + # Add other assertions for the valid case here... + maas_storage.call_cmd_mock.assert_called_with( + [ + "maas", + maas_storage.maas_user, + "partition", + "format", + maas_storage.node_id, + device["parent_disk_blkid"], + partition_id, + f"fstype={device['fstype']}", + f"label={device['label']}", + ] + ) def test_process_mount(self, maas_storage): """Checks if 'process_mount' correctly processes a 'mount' From a759d09271559da442ab49fa407d706564dc0c55 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 11 Sep 2023 23:31:47 -0700 Subject: [PATCH 545/569] Incorporate suggested changes in PR82, and incorporated valid volume/partition id in the process_format unit tests --- .../devices/maas2/tests/test_maas_storage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index e1c2e372..bb91fc95 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -487,7 +487,6 @@ def test_process_format(self, maas_storage, partition_id, expected_error): else: maas_storage.process_format(device) - # Add other assertions for the valid case here... maas_storage.call_cmd_mock.assert_called_with( [ "maas", From 5e339c69ae136718d5e5cd90768a1e8e53f41653 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 11 Sep 2023 23:39:10 -0700 Subject: [PATCH 546/569] Cleanup test_process_format logic --- .../devices/maas2/tests/test_maas_storage.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index bb91fc95..958a5b0d 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -484,22 +484,22 @@ def test_process_format(self, maas_storage, partition_id, expected_error): expected_error, match=r"Unable to find partition ID for volume" ): maas_storage.process_format(device) - else: - maas_storage.process_format(device) - - maas_storage.call_cmd_mock.assert_called_with( - [ - "maas", - maas_storage.maas_user, - "partition", - "format", - maas_storage.node_id, - device["parent_disk_blkid"], - partition_id, - f"fstype={device['fstype']}", - f"label={device['label']}", - ] - ) + + maas_storage.process_format(device) + + maas_storage.call_cmd_mock.assert_called_with( + [ + "maas", + maas_storage.maas_user, + "partition", + "format", + maas_storage.node_id, + device["parent_disk_blkid"], + partition_id, + f"fstype={device['fstype']}", + f"label={device['label']}", + ] + ) def test_process_mount(self, maas_storage): """Checks if 'process_mount' correctly processes a 'mount' From cbaae32475b6ab6a1eb9f3b701fccd0604ff0a67 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Mon, 11 Sep 2023 23:53:45 -0700 Subject: [PATCH 547/569] black reformating of test_process_format --- .../devices/maas2/tests/test_maas_storage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index 958a5b0d..b6a985e7 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -458,8 +458,8 @@ def test_process_partition(self, maas_storage): "partition_id, expected_error", [ (2, None), # Valid partition ID - (None, MaasStorageError) # Invalid partition ID - ] + (None, MaasStorageError), # Invalid partition ID + ], ) def test_process_format(self, maas_storage, partition_id, expected_error): """Checks if 'process_format' correctly processes a 'format' From 8975f5987efb272302373d5c7ea3b249e2d1aa55 Mon Sep 17 00:00:00 2001 From: Adrian Lane Date: Tue, 12 Sep 2023 00:08:40 -0700 Subject: [PATCH 548/569] black reformating of test_process_format --- .../devices/maas2/tests/test_maas_storage.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py index b6a985e7..63472865 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py @@ -484,22 +484,21 @@ def test_process_format(self, maas_storage, partition_id, expected_error): expected_error, match=r"Unable to find partition ID for volume" ): maas_storage.process_format(device) - - maas_storage.process_format(device) - - maas_storage.call_cmd_mock.assert_called_with( - [ - "maas", - maas_storage.maas_user, - "partition", - "format", - maas_storage.node_id, - device["parent_disk_blkid"], - partition_id, - f"fstype={device['fstype']}", - f"label={device['label']}", - ] - ) + else: + maas_storage.process_format(device) + maas_storage.call_cmd_mock.assert_called_with( + [ + "maas", + maas_storage.maas_user, + "partition", + "format", + maas_storage.node_id, + device["parent_disk_blkid"], + partition_id, + f"fstype={device['fstype']}", + f"label={device['label']}", + ] + ) def test_process_mount(self, maas_storage): """Checks if 'process_mount' correctly processes a 'mount' From 7429d84cb98d218f25435ccfbbf472d1813bf5a2 Mon Sep 17 00:00:00 2001 From: Vic Liu Date: Wed, 13 Sep 2023 22:52:45 +0800 Subject: [PATCH 549/569] Add more ce-oem-iot image build stamps to cover different type of images in unit test --- .../devices/muxpi/tests/test_muxpi.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py b/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py index ae540cce..96026e73 100644 --- a/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py +++ b/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py @@ -19,9 +19,26 @@ def test_check_ce_oem_iot_image(mocker): """Test check_ce_oem_iot_image.""" + buildstamp = "iot-limerick-kria-classic-desktop-2204-x07-20230302-63" mocker.patch( "subprocess.check_output", - return_value="iot-limerick-kria-classic-desktop-2204".encode(), + return_value=buildstamp.encode(), + ) + muxpi = MuxPi() + assert muxpi.check_ce_oem_iot_image() is True + + buildstamp = "iot-baoshan-classic-server-2204-x04-20230807-149" + mocker.patch( + "subprocess.check_output", + return_value=buildstamp.encode(), + ) + muxpi = MuxPi() + assert muxpi.check_ce_oem_iot_image() is True + + buildstamp = "iot-havana-core-20-ptz-gm3-uc20-20230911-2" + mocker.patch( + "subprocess.check_output", + return_value=buildstamp.encode(), ) muxpi = MuxPi() assert muxpi.check_ce_oem_iot_image() is True From b3d0c0588627b99e9526d6bc864a79a5872be746 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 28 Sep 2023 15:59:50 +0800 Subject: [PATCH 550/569] Rename as much as we can of snappy-device-agents to testflinger-device-connectors --- README.rst | 14 +++---- pyproject.toml | 8 ++-- .../__init__.py | 8 ++-- .../cmd.py | 6 +-- .../data/muxpi/ce-oem-iot/user-data | 0 .../data/muxpi/classic/meta-data | 0 .../data/muxpi/classic/user-data | 0 .../data/muxpi/oemscript/README | 0 .../data/muxpi/oemscript/recovery-from-iso.sh | 0 .../data/muxpi/pi-desktop/oem-config.service | 0 .../data/muxpi/pi-desktop/preseed.cfg | 0 .../data/muxpi/uc20/99_nocloud.cfg | 0 .../data/pi-desktop/oem-config.service | 0 .../data/pi-desktop/preseed.cfg | 0 .../devices/__init__.py | 38 +++++++++++-------- .../devices/cm3/__init__.py | 12 +++--- .../devices/cm3/cm3.py | 7 +++- .../devices/dragonboard/__init__.py | 14 ++++--- .../devices/dragonboard/dragonboard.py | 21 +++++----- .../devices/maas2/__init__.py | 12 +++--- .../devices/maas2/doc/maas_storage.rst | 4 +- .../devices/maas2/maas2.py | 9 +++-- .../devices/maas2/maas_storage.py | 2 +- .../devices/maas2/tests/test_maas_storage.py | 4 +- .../devices/multi/__init__.py | 24 ++++++------ .../devices/multi/multi.py | 10 ++--- .../devices/multi/tests/test_multi.py | 4 +- .../devices/multi/tfclient.py | 5 ++- .../devices/muxpi/__init__.py | 12 +++--- .../devices/muxpi/muxpi.py | 7 +++- .../devices/muxpi/tests/test_muxpi.py | 4 +- .../devices/netboot/__init__.py | 22 +++++------ .../devices/netboot/netboot.py | 15 +++++--- .../devices/noprovision/__init__.py | 22 +++++++---- .../devices/noprovision/noprovision.py | 7 +++- .../devices/oemrecovery/__init__.py | 18 ++++++--- .../devices/oemrecovery/oemrecovery.py | 7 +++- .../devices/oemscript/__init__.py | 16 +++++--- .../devices/oemscript/oemscript.py | 9 +++-- src/tests/test_snappy_device_agents.py | 14 +++++-- 40 files changed, 206 insertions(+), 149 deletions(-) rename src/{snappy_device_agents => testflinger_device_connectors}/__init__.py (98%) rename src/{snappy_device_agents => testflinger_device_connectors}/cmd.py (91%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/muxpi/ce-oem-iot/user-data (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/muxpi/classic/meta-data (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/muxpi/classic/user-data (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/muxpi/oemscript/README (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/muxpi/oemscript/recovery-from-iso.sh (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/muxpi/pi-desktop/oem-config.service (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/muxpi/pi-desktop/preseed.cfg (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/muxpi/uc20/99_nocloud.cfg (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/pi-desktop/oem-config.service (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/data/pi-desktop/preseed.cfg (100%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/__init__.py (88%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/cm3/__init__.py (84%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/cm3/cm3.py (98%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/dragonboard/__init__.py (82%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/dragonboard/dragonboard.py (95%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/maas2/__init__.py (85%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/maas2/doc/maas_storage.rst (90%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/maas2/maas2.py (98%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/maas2/maas_storage.py (99%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/maas2/tests/test_maas_storage.py (99%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/multi/__init__.py (84%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/multi/multi.py (95%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/multi/tests/test_multi.py (95%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/multi/tfclient.py (97%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/muxpi/__init__.py (84%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/muxpi/muxpi.py (99%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/muxpi/tests/test_muxpi.py (93%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/netboot/__init__.py (82%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/netboot/netboot.py (95%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/noprovision/__init__.py (68%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/noprovision/noprovision.py (95%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/oemrecovery/__init__.py (76%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/oemrecovery/oemrecovery.py (97%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/oemscript/__init__.py (76%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/oemscript/oemscript.py (97%) diff --git a/README.rst b/README.rst index a7411aa9..728307c2 100644 --- a/README.rst +++ b/README.rst @@ -1,23 +1,23 @@ -Snappy Device Agents +Testflinger Device Connectors #################### -Device agents scripts for provisioning and running tests on Snappy +Device connectors scripts for provisioning and running tests on Testflinger devices Supported Devices ================= -The following device agent types are currently supported, however most of them +The following device connector types are currently supported, however most of them require a very specific environment in order to work properly. That's part of the reason why they are broken out into a separate project. Nothing here is really required to run testflinger, only to support these devices in our -environment. Alternative device agents could be written in order to support +environment. Alternative device connectors could be written in order to support testing on other types of devices. - cm3 - Raspberry PI CM3 with a sidecar device and tools to support putting it in otg mode to flash an image - dragonboard - dragonboard with a stable image on usb and test images are flashed to a wiped sd with a dual boot process - maas2 - Metal as a Service (MaaS) systems, which support additional features such as disk layouts. Images provisioned must be imported first! -- multi - multi-device agent used for provisioning jobs that span multiple devices at once +- multi - multi-device connector used for provisioning jobs that span multiple devices at once - muxpi - muxpi/sdwire provisioned devices that utilize a device that can write to an sd the boot it on the DUT - netboot - minimal netboot initramfs process for a specific device that couldn't be provisioned with MaaS - noprovision - devices which need to run tests, but can't be provisioned (yet) @@ -28,9 +28,9 @@ testing on other types of devices. Exit Status =========== -Device agents will exit with a value of ''46'' if something goes wrong during +Device connectors will exit with a value of ''46'' if something goes wrong during device recovery. This can be used as an indication that the device is unusable for some reason, and can't be recovere using automated recovery mechanisms. -The system calling the device agent may want to take further action, such +The system calling the device connector may want to take further action, such as alerting someone that it needs manual recovery, or to stop attempting to run tests on it until it's fixed. diff --git a/pyproject.toml b/pyproject.toml index 3fb556c7..553c6123 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,9 @@ requires = [ build-backend = "setuptools.build_meta" [project] -name = "snappy-device-agents" +name = "testflinger-device-connectors" version = "0.0.2" -description = "Testflinger device agents" +description = "Testflinger device connectors" license = {text = "GPLv3"} readme = "README.rst" requires-python = ">=3.8" @@ -19,10 +19,10 @@ dependencies = [ ] [project.scripts] -snappy-device-agent = "snappy_device_agents.cmd:main" +testflinger-device-connector = "testflinger_device_connectors.cmd:main" [tool.setuptools.package-data] -snappy_device_agents = ["data/**"] +testflinger_device_connectors = ["data/**"] [tool.black] line-length = 79 diff --git a/src/snappy_device_agents/__init__.py b/src/testflinger_device_connectors/__init__.py similarity index 98% rename from src/snappy_device_agents/__init__.py rename to src/testflinger_device_connectors/__init__.py index a0b65010..a57ca4af 100644 --- a/src/snappy_device_agents/__init__.py +++ b/src/testflinger_device_connectors/__init__.py @@ -11,7 +11,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""General functions used by snappy device agents""" +"""General functions used by device connectors""" import bz2 import gzip @@ -27,7 +27,7 @@ import time import urllib.request -IMAGEFILE = "snappy.img" +IMAGEFILE = "install.img" logger = logging.getLogger() @@ -77,7 +77,7 @@ def download(url, filename=None): :param filename: Filename to save the file as, defaults to the basename from the url :return filename: - Filename of the downloaded snappy core image + Filename of the downloaded core image """ logger.info("Downloading file from %s", url) if filename is None: @@ -279,7 +279,7 @@ def filter(self, record): logging.basicConfig( level=logging.INFO, format="%(asctime)s %(agent_name)s %(levelname)s: " - "DEVICE AGENT: " + "DEVICE CONNECTOR: " "%(message)s", ) agent_name = config.get("agent_name", "") diff --git a/src/snappy_device_agents/cmd.py b/src/testflinger_device_connectors/cmd.py similarity index 91% rename from src/snappy_device_agents/cmd.py rename to src/testflinger_device_connectors/cmd.py index 7c58a45a..f3614203 100755 --- a/src/snappy_device_agents/cmd.py +++ b/src/testflinger_device_connectors/cmd.py @@ -13,20 +13,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ -Main snappy-device-agents command module +Main testflinger-device-connectors command module """ import argparse import logging -from snappy_device_agents.devices import load_devices +from testflinger_device_connectors.devices import load_devices logger = logging.getLogger() def main(): - """main command function for snappy-device-agents""" + """main command function for testflinger-device-connectors""" devices = load_devices() parser = argparse.ArgumentParser() diff --git a/src/snappy_device_agents/data/muxpi/ce-oem-iot/user-data b/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data similarity index 100% rename from src/snappy_device_agents/data/muxpi/ce-oem-iot/user-data rename to src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data diff --git a/src/snappy_device_agents/data/muxpi/classic/meta-data b/src/testflinger_device_connectors/data/muxpi/classic/meta-data similarity index 100% rename from src/snappy_device_agents/data/muxpi/classic/meta-data rename to src/testflinger_device_connectors/data/muxpi/classic/meta-data diff --git a/src/snappy_device_agents/data/muxpi/classic/user-data b/src/testflinger_device_connectors/data/muxpi/classic/user-data similarity index 100% rename from src/snappy_device_agents/data/muxpi/classic/user-data rename to src/testflinger_device_connectors/data/muxpi/classic/user-data diff --git a/src/snappy_device_agents/data/muxpi/oemscript/README b/src/testflinger_device_connectors/data/muxpi/oemscript/README similarity index 100% rename from src/snappy_device_agents/data/muxpi/oemscript/README rename to src/testflinger_device_connectors/data/muxpi/oemscript/README diff --git a/src/snappy_device_agents/data/muxpi/oemscript/recovery-from-iso.sh b/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh similarity index 100% rename from src/snappy_device_agents/data/muxpi/oemscript/recovery-from-iso.sh rename to src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh diff --git a/src/snappy_device_agents/data/muxpi/pi-desktop/oem-config.service b/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service similarity index 100% rename from src/snappy_device_agents/data/muxpi/pi-desktop/oem-config.service rename to src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service diff --git a/src/snappy_device_agents/data/muxpi/pi-desktop/preseed.cfg b/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg similarity index 100% rename from src/snappy_device_agents/data/muxpi/pi-desktop/preseed.cfg rename to src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg diff --git a/src/snappy_device_agents/data/muxpi/uc20/99_nocloud.cfg b/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg similarity index 100% rename from src/snappy_device_agents/data/muxpi/uc20/99_nocloud.cfg rename to src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg diff --git a/src/snappy_device_agents/data/pi-desktop/oem-config.service b/src/testflinger_device_connectors/data/pi-desktop/oem-config.service similarity index 100% rename from src/snappy_device_agents/data/pi-desktop/oem-config.service rename to src/testflinger_device_connectors/data/pi-desktop/oem-config.service diff --git a/src/snappy_device_agents/data/pi-desktop/preseed.cfg b/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg similarity index 100% rename from src/snappy_device_agents/data/pi-desktop/preseed.cfg rename to src/testflinger_device_connectors/data/pi-desktop/preseed.cfg diff --git a/src/snappy_device_agents/devices/__init__.py b/src/testflinger_device_connectors/devices/__init__.py similarity index 88% rename from src/snappy_device_agents/devices/__init__.py rename to src/testflinger_device_connectors/devices/__init__.py index 7fe1b11e..4d799c46 100644 --- a/src/snappy_device_agents/devices/__init__.py +++ b/src/testflinger_device_connectors/devices/__init__.py @@ -25,7 +25,7 @@ import yaml -import snappy_device_agents +import testflinger_device_connectors class ProvisioningError(Exception): @@ -81,7 +81,7 @@ def reconnector(): except Exception: pass # Keep trying if we can't connect, but sleep between attempts - snappy_device_agents.logmsg( + testflinger_device_connectors.logmsg( logging.ERROR, "Error connecting to serial logging server" ) time.sleep(30) @@ -104,7 +104,7 @@ def _log_serial(self): ) f.flush() else: - snappy_device_agents.logmsg( + testflinger_device_connectors.logmsg( logging.ERROR, "Serial Log connection closed" ) return @@ -119,10 +119,10 @@ def runtest(self, args): """Default method for processing test commands""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) - snappy_device_agents.logmsg(logging.INFO, "BEGIN testrun") + testflinger_device_connectors.configure_logging(config) + testflinger_device_connectors.logmsg(logging.INFO, "BEGIN testrun") - test_opportunity = snappy_device_agents.get_test_opportunity( + test_opportunity = testflinger_device_connectors.get_test_opportunity( args.job_data ) test_cmds = test_opportunity.get("test_data").get("test_cmds") @@ -131,12 +131,14 @@ def runtest(self, args): serial_proc = SerialLogger(serial_host, serial_port, "test-serial.log") serial_proc.start() try: - exitcode = snappy_device_agents.run_test_cmds(test_cmds, config) + exitcode = testflinger_device_connectors.run_test_cmds( + test_cmds, config + ) except Exception as e: raise e finally: serial_proc.stop() - snappy_device_agents.logmsg(logging.INFO, "END testrun") + testflinger_device_connectors.logmsg(logging.INFO, "END testrun") return exitcode def allocate(self, args): @@ -153,9 +155,11 @@ def reserve(self, args): """Default method for reserving systems""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) - snappy_device_agents.logmsg(logging.INFO, "BEGIN reservation") - job_data = snappy_device_agents.get_test_opportunity(args.job_data) + testflinger_device_connectors.configure_logging(config) + testflinger_device_connectors.logmsg(logging.INFO, "BEGIN reservation") + job_data = testflinger_device_connectors.get_test_opportunity( + args.job_data + ) try: test_username = job_data["test_data"]["test_username"] except KeyError: @@ -171,7 +175,7 @@ def reserve(self, args): cmd = ["ssh-import-id", "-o", "key.pub", key] proc = subprocess.run(cmd) if proc.returncode != 0: - snappy_device_agents.logmsg( + testflinger_device_connectors.logmsg( logging.ERROR, "Unable to import ssh key from: {}".format(key), ) @@ -196,15 +200,17 @@ def reserve(self, args): except subprocess.TimeoutExpired: # Log an error for timeout or any other problem pass - snappy_device_agents.logmsg( + testflinger_device_connectors.logmsg( logging.ERROR, "Error copying ssh key to device for: {}".format(key), ) if retry != 9: - snappy_device_agents.logmsg(logging.INFO, "Retrying...") + testflinger_device_connectors.logmsg( + logging.INFO, "Retrying..." + ) time.sleep(60) else: - snappy_device_agents.logmsg( + testflinger_device_connectors.logmsg( logging.ERROR, "Failed to copy ssh key: {}".format(key) ) # default reservation timeout is 1 hour @@ -279,7 +285,7 @@ def load_devices(): if "__pycache__" in device: continue module = imp.load_source("module", os.path.join(device, "__init__.py")) - devices.append((module.device_name, module.DeviceAgent)) + devices.append((module.device_name, module.DeviceConnector)) return tuple(devices) diff --git a/src/snappy_device_agents/devices/cm3/__init__.py b/src/testflinger_device_connectors/devices/cm3/__init__.py similarity index 84% rename from src/snappy_device_agents/devices/cm3/__init__.py rename to src/testflinger_device_connectors/devices/cm3/__init__.py index bbf0eb64..d8bad8f1 100644 --- a/src/snappy_device_agents/devices/cm3/__init__.py +++ b/src/testflinger_device_connectors/devices/cm3/__init__.py @@ -18,20 +18,20 @@ import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import ( +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( DefaultDevice, RecoveryError, SerialLogger, catch, ) -from snappy_device_agents.devices.cm3.cm3 import CM3 +from testflinger_device_connectors.devices.cm3.cm3 import CM3 device_name = "cm3" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @@ -40,7 +40,7 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = CM3(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") diff --git a/src/snappy_device_agents/devices/cm3/cm3.py b/src/testflinger_device_connectors/devices/cm3/cm3.py similarity index 98% rename from src/snappy_device_agents/devices/cm3/cm3.py rename to src/testflinger_device_connectors/devices/cm3/cm3.py index 35d65fab..d24fcab6 100644 --- a/src/snappy_device_agents/devices/cm3/cm3.py +++ b/src/testflinger_device_connectors/devices/cm3/cm3.py @@ -23,14 +23,17 @@ import yaml -from snappy_device_agents.devices import ProvisioningError, RecoveryError +from testflinger_device_connectors.devices import ( + ProvisioningError, + RecoveryError, +) logger = logging.getLogger() class CM3: - """Device Agent for CM3.""" + """Device Connector for CM3.""" IMAGE_PATH_IDS = { "etc": "ubuntu", diff --git a/src/snappy_device_agents/devices/dragonboard/__init__.py b/src/testflinger_device_connectors/devices/dragonboard/__init__.py similarity index 82% rename from src/snappy_device_agents/devices/dragonboard/__init__.py rename to src/testflinger_device_connectors/devices/dragonboard/__init__.py index 56553b0d..05a24d1a 100644 --- a/src/snappy_device_agents/devices/dragonboard/__init__.py +++ b/src/testflinger_device_connectors/devices/dragonboard/__init__.py @@ -18,20 +18,22 @@ import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import ( +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( DefaultDevice, RecoveryError, SerialLogger, catch, ) -from snappy_device_agents.devices.dragonboard.dragonboard import Dragonboard +from testflinger_device_connectors.devices.dragonboard.dragonboard import ( + Dragonboard, +) device_name = "dragonboard" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @@ -40,7 +42,7 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = Dragonboard(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Booting Master Image") diff --git a/src/snappy_device_agents/devices/dragonboard/dragonboard.py b/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py similarity index 95% rename from src/snappy_device_agents/devices/dragonboard/dragonboard.py rename to src/testflinger_device_connectors/devices/dragonboard/dragonboard.py index 2f181e90..899630d2 100644 --- a/src/snappy_device_agents/devices/dragonboard/dragonboard.py +++ b/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py @@ -23,15 +23,18 @@ import yaml -import snappy_device_agents -from snappy_device_agents.devices import ProvisioningError, RecoveryError +import testflinger_device_connectors +from testflinger_device_connectors.devices import ( + ProvisioningError, + RecoveryError, +) logger = logging.getLogger() class Dragonboard: - """Snappy Device Agent for Dragonboard.""" + """Testflinger Device Connector for Dragonboard.""" def __init__(self, config, job_data): with open(config) as configfile: @@ -81,7 +84,7 @@ def setboot(self, mode): :raises ProvisioningError: If the command times out or anything else fails. - This method sets the snappy boot method to the specified value. + This method sets the boot method to the specified value. """ if mode == "master": setboot_script = self.config["select_master_script"] @@ -183,7 +186,7 @@ def is_test_image_booted(self): subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) except subprocess.SubprocessError: return False - # If we get here, then the above command proved we are in snappy + # If we get here, the above command proved we are in the test image return True def is_master_image_booted(self): @@ -388,12 +391,12 @@ def provision(self): url = self.job_data["provision_data"].get("url") self.copy_ssh_id() self.ensure_master_image() - snappy_device_agents.download(url, "snappy.img") - image_file = snappy_device_agents.compress_file("snappy.img") - server_ip = snappy_device_agents.get_local_ip_addr() + testflinger_device_connectors.download(url, "install.img") + image_file = testflinger_device_connectors.compress_file("install.img") + server_ip = testflinger_device_connectors.get_local_ip_addr() serve_q = multiprocessing.Queue() file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, + target=testflinger_device_connectors.serve_file, args=( serve_q, image_file, diff --git a/src/snappy_device_agents/devices/maas2/__init__.py b/src/testflinger_device_connectors/devices/maas2/__init__.py similarity index 85% rename from src/snappy_device_agents/devices/maas2/__init__.py rename to src/testflinger_device_connectors/devices/maas2/__init__.py index 7130ba3c..4cf6bd17 100644 --- a/src/snappy_device_agents/devices/maas2/__init__.py +++ b/src/testflinger_device_connectors/devices/maas2/__init__.py @@ -18,21 +18,21 @@ import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import ( +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( DefaultDevice, ProvisioningError, RecoveryError, SerialLogger, catch, ) -from snappy_device_agents.devices.maas2.maas2 import Maas2 +from testflinger_device_connectors.devices.maas2.maas2 import Maas2 device_name = "maas2" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @@ -41,7 +41,7 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = Maas2(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") diff --git a/src/snappy_device_agents/devices/maas2/doc/maas_storage.rst b/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst similarity index 90% rename from src/snappy_device_agents/devices/maas2/doc/maas_storage.rst rename to src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst index ca1363d6..d398c2c5 100644 --- a/src/snappy_device_agents/devices/maas2/doc/maas_storage.rst +++ b/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst @@ -1,9 +1,9 @@ ================= Preamble ================= -This extension of the Testflinger Maas Snappy Device Agent to handle a variety of node storage layout configurations. This configuration will be passed to Testflinger via the node job configuration yaml file, as part of the SUT provision data (example below). This functionality is containted in the discreet Python module (‘maas-storage.py’) that sits alongside the Maas Snappy Device Agent, to be imported and called when this device agent is instantiated, if a storage layout configuration is supplied. +This extension of the Testflinger Maas Testflinger Device Connector to handle a variety of node storage layout configurations. This configuration will be passed to Testflinger via the node job configuration yaml file, as part of the SUT provision data (example below). This functionality is containted in the discreet Python module (‘maas-storage.py’) that sits alongside the Maas Testflinger Device Connector, to be imported and called when this device connector is instantiated, if a storage layout configuration is supplied. -These storage layout configurations are to be passed along to MAAS, via the CLI API, when the device agent is created as part of its provision phase. While initial scope and use of this module will be limited to SQA’s testing requirements, the availability of this module implies additional consumers can specify disk layout configurations as part of their Testflinger job definitions. +These storage layout configurations are to be passed along to MAAS, via the CLI API, when the device connector is created as part of its provision phase. While initial scope and use of this module will be limited to SQA’s testing requirements, the availability of this module implies additional consumers can specify disk layout configurations as part of their Testflinger job definitions. Of note: the initial scope of storage to be supported will be limited to flat layouts and simple partitions; RAID, LVM or bcache configurations are currently unnsupported by this module. This functionality will be added in the future as the need arises. diff --git a/src/snappy_device_agents/devices/maas2/maas2.py b/src/testflinger_device_connectors/devices/maas2/maas2.py similarity index 98% rename from src/snappy_device_agents/devices/maas2/maas2.py rename to src/testflinger_device_connectors/devices/maas2/maas2.py index bcc12d31..06944c1c 100644 --- a/src/snappy_device_agents/devices/maas2/maas2.py +++ b/src/testflinger_device_connectors/devices/maas2/maas2.py @@ -23,8 +23,11 @@ import yaml -from snappy_device_agents.devices import ProvisioningError, RecoveryError -from snappy_device_agents.devices.maas2.maas_storage import ( +from testflinger_device_connectors.devices import ( + ProvisioningError, + RecoveryError, +) +from testflinger_device_connectors.devices.maas2.maas_storage import ( MaasStorage, MaasStorageError, ) @@ -35,7 +38,7 @@ class Maas2: - """Device Agent for Maas2.""" + """Device Connector for Maas2.""" def __init__(self, config, job_data): with open(config) as configfile: diff --git a/src/snappy_device_agents/devices/maas2/maas_storage.py b/src/testflinger_device_connectors/devices/maas2/maas_storage.py similarity index 99% rename from src/snappy_device_agents/devices/maas2/maas_storage.py rename to src/testflinger_device_connectors/devices/maas2/maas_storage.py index 76803a47..072ace22 100644 --- a/src/snappy_device_agents/devices/maas2/maas_storage.py +++ b/src/testflinger_device_connectors/devices/maas2/maas_storage.py @@ -29,7 +29,7 @@ class MaasStorageError(Exception): class MaasStorage: - """Maas device agent storage module.""" + """Maas device connector storage module.""" def __init__(self, maas_user, node_id): self.maas_user = maas_user diff --git a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py b/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py similarity index 99% rename from src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py rename to src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py index 63472865..24a0138d 100644 --- a/src/snappy_device_agents/devices/maas2/tests/test_maas_storage.py +++ b/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py @@ -18,7 +18,7 @@ import pytest import json from unittest.mock import Mock, MagicMock, call -from snappy_device_agents.devices.maas2.maas_storage import ( +from testflinger_device_connectors.devices.maas2.maas_storage import ( MaasStorage, MaasStorageError, ) @@ -44,7 +44,7 @@ def call_cmd(self, cmd, output_json=False): class TestMaasStorage: - """Test maas device agent storage module.""" + """Test maas device connector storage module.""" node_info = json.dumps( [ diff --git a/src/snappy_device_agents/devices/multi/__init__.py b/src/testflinger_device_connectors/devices/multi/__init__.py similarity index 84% rename from src/snappy_device_agents/devices/multi/__init__.py rename to src/testflinger_device_connectors/devices/multi/__init__.py index 8c504437..3ed76d70 100644 --- a/src/snappy_device_agents/devices/multi/__init__.py +++ b/src/testflinger_device_connectors/devices/multi/__init__.py @@ -19,30 +19,30 @@ import os import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import ( +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( DefaultDevice, SerialLogger, ) -from snappy_device_agents.devices.multi.multi import Multi -from snappy_device_agents.devices.multi.tfclient import TFClient +from testflinger_device_connectors.devices.multi.multi import Multi +from testflinger_device_connectors.devices.multi.tfclient import TFClient device_name = "multi" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): - """Device Agent for provisioning multiple devices at the same time""" + """Device Connector for provisioning multiple devices at the same time""" def init_device(self, args): """Read config data and initialize the device object.""" with open(args.config, encoding="utf-8") as configfile: self.config = yaml.safe_load(configfile) - self.job_data = snappy_device_agents.get_test_opportunity( + self.job_data = testflinger_device_connectors.get_test_opportunity( args.job_data ) - snappy_device_agents.configure_logging(self.config) + testflinger_device_connectors.configure_logging(self.config) testflinger_server = self.config.get("testflinger_server") tfclient = TFClient(testflinger_server) self.device = Multi(self.config, self.job_data, tfclient) @@ -57,7 +57,7 @@ def provision(self, args): def runtest(self, args): """ - The runtest method for multi-device agents + The runtest method for multi-device connectors This is slightly different from the generic one because we also need to import the job_list.json data and inject the device_ip for each @@ -80,14 +80,14 @@ def runtest(self, args): self.config["env"].update(extra_env) try: - exitcode = snappy_device_agents.run_test_cmds( + exitcode = testflinger_device_connectors.run_test_cmds( test_cmds, self.config ) except Exception as e: raise e finally: serial_proc.stop() - snappy_device_agents.logmsg(logging.INFO, "END testrun") + testflinger_device_connectors.logmsg(logging.INFO, "END testrun") return exitcode def get_job_list_data(self, job_list_file: str = "job_list.json") -> list: diff --git a/src/snappy_device_agents/devices/multi/multi.py b/src/testflinger_device_connectors/devices/multi/multi.py similarity index 95% rename from src/snappy_device_agents/devices/multi/multi.py rename to src/testflinger_device_connectors/devices/multi/multi.py index e7a5a8fb..6d65012f 100644 --- a/src/snappy_device_agents/devices/multi/multi.py +++ b/src/testflinger_device_connectors/devices/multi/multi.py @@ -19,17 +19,17 @@ import os import time -from snappy_device_agents.devices import ProvisioningError +from testflinger_device_connectors.devices import ProvisioningError logger = logging.getLogger() class Multi: - """Device Agent for multi-device""" + """Device Connector for multi-device""" def __init__(self, config, job_data, client): - """Initialize the multi-device agent. + """Initialize the multi-device connector. :param config: path to the config file :param job_data: path to the job data file @@ -43,7 +43,7 @@ def __init__(self, config, job_data, client): self.jobs = [] def provision(self): - """Provision the multi-device agent by creating the specified jobs""" + """Provision multi-device connector by creating the specified jobs""" self.create_jobs() # Wait for all jobs to reach the "allocated" state @@ -126,7 +126,7 @@ def save_job_list_file(self): json.dump(job_list, json_file) def create_jobs(self): - """Create the jobs for the multi-device agent""" + """Create the jobs for the multi-device connector""" jobs_list = self.job_data.get("provision_data", {}).get("jobs") if not jobs_list: raise ProvisioningError( diff --git a/src/snappy_device_agents/devices/multi/tests/test_multi.py b/src/testflinger_device_connectors/devices/multi/tests/test_multi.py similarity index 95% rename from src/snappy_device_agents/devices/multi/tests/test_multi.py rename to src/testflinger_device_connectors/devices/multi/tests/test_multi.py index e37b6861..b67aa960 100644 --- a/src/snappy_device_agents/devices/multi/tests/test_multi.py +++ b/src/testflinger_device_connectors/devices/multi/tests/test_multi.py @@ -17,8 +17,8 @@ from uuid import uuid4 import pytest -from snappy_device_agents.devices.multi.multi import Multi -from snappy_device_agents.devices.multi.tfclient import TFClient +from testflinger_device_connectors.devices.multi.multi import Multi +from testflinger_device_connectors.devices.multi.tfclient import TFClient class MockTFClient(TFClient): diff --git a/src/snappy_device_agents/devices/multi/tfclient.py b/src/testflinger_device_connectors/devices/multi/tfclient.py similarity index 97% rename from src/snappy_device_agents/devices/multi/tfclient.py rename to src/testflinger_device_connectors/devices/multi/tfclient.py index f21fe6d7..afa43f31 100644 --- a/src/snappy_device_agents/devices/multi/tfclient.py +++ b/src/testflinger_device_connectors/devices/multi/tfclient.py @@ -32,8 +32,9 @@ def __init__(self, url): """ if not url or not url.startswith("http"): raise ValueError( - "Config item testflinger_server URL for multi-device agents" - " must be specified and must start with http or https!" + "Config item testflinger_server URL for multi-device " + "connectors must be specified and must start with http or " + "https!" ) self.server = url diff --git a/src/snappy_device_agents/devices/muxpi/__init__.py b/src/testflinger_device_connectors/devices/muxpi/__init__.py similarity index 84% rename from src/snappy_device_agents/devices/muxpi/__init__.py rename to src/testflinger_device_connectors/devices/muxpi/__init__.py index 1cf18b93..fff51a48 100644 --- a/src/snappy_device_agents/devices/muxpi/__init__.py +++ b/src/testflinger_device_connectors/devices/muxpi/__init__.py @@ -18,20 +18,20 @@ import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import ( +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( DefaultDevice, RecoveryError, SerialLogger, catch, ) -from snappy_device_agents.devices.muxpi.muxpi import MuxPi +from testflinger_device_connectors.devices.muxpi.muxpi import MuxPi device_name = "muxpi" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @@ -40,7 +40,7 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = MuxPi(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") diff --git a/src/snappy_device_agents/devices/muxpi/muxpi.py b/src/testflinger_device_connectors/devices/muxpi/muxpi.py similarity index 99% rename from src/snappy_device_agents/devices/muxpi/muxpi.py rename to src/testflinger_device_connectors/devices/muxpi/muxpi.py index 72d8d4da..55c03a43 100644 --- a/src/snappy_device_agents/devices/muxpi/muxpi.py +++ b/src/testflinger_device_connectors/devices/muxpi/muxpi.py @@ -23,14 +23,17 @@ import yaml -from snappy_device_agents.devices import ProvisioningError, RecoveryError +from testflinger_device_connectors.devices import ( + ProvisioningError, + RecoveryError, +) logger = logging.getLogger() class MuxPi: - """Device Agent for MuxPi.""" + """Device Connector for MuxPi.""" IMAGE_PATH_IDS = { "writable/usr/bin/firefox": "pi-desktop", diff --git a/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py b/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py similarity index 93% rename from src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py rename to src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py index 96026e73..f4a17acc 100644 --- a/src/snappy_device_agents/devices/muxpi/tests/test_muxpi.py +++ b/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py @@ -11,10 +11,10 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Unit tests for muxpi device agent""" +"""Unit tests for muxpi device connector""" from subprocess import CalledProcessError -from snappy_device_agents.devices.muxpi.muxpi import MuxPi +from testflinger_device_connectors.devices.muxpi.muxpi import MuxPi def test_check_ce_oem_iot_image(mocker): diff --git a/src/snappy_device_agents/devices/netboot/__init__.py b/src/testflinger_device_connectors/devices/netboot/__init__.py similarity index 82% rename from src/snappy_device_agents/devices/netboot/__init__.py rename to src/testflinger_device_connectors/devices/netboot/__init__.py index 27bf4e10..df52a3c9 100644 --- a/src/snappy_device_agents/devices/netboot/__init__.py +++ b/src/testflinger_device_connectors/devices/netboot/__init__.py @@ -18,7 +18,7 @@ import multiprocessing import yaml -from snappy_device_agents.devices import ( +from testflinger_device_connectors.devices import ( DefaultDevice, ProvisioningError, RecoveryError, @@ -26,14 +26,14 @@ catch, ) -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices.netboot.netboot import Netboot +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices.netboot.netboot import Netboot device_name = "netboot" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @@ -42,19 +42,19 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = Netboot(args.config) - image = snappy_device_agents.get_image(args.job_data) + image = testflinger_device_connectors.get_image(args.job_data) if not image: raise ProvisioningError("Error downloading image") - server_ip = snappy_device_agents.get_local_ip_addr() + server_ip = testflinger_device_connectors.get_local_ip_addr() # Ideally the default user/pass should be metadata about an image, # but we don't currently have any concept of that stored. For now, # we can give a reasonable guess based on the provisioning method. - test_username = snappy_device_agents.get_test_username( + test_username = testflinger_device_connectors.get_test_username( job_data=args.job_data, default="admin" ) - test_password = snappy_device_agents.get_test_password( + test_password = testflinger_device_connectors.get_test_password( job_data=args.job_data, default="admin" ) logmsg(logging.INFO, "BEGIN provision") @@ -73,7 +73,7 @@ def provision(self, args): raise RecoveryError("Unable to put system in a usable state!") q = multiprocessing.Queue() file_server = multiprocessing.Process( - target=snappy_device_agents.serve_file, + target=testflinger_device_connectors.serve_file, args=( q, image, diff --git a/src/snappy_device_agents/devices/netboot/netboot.py b/src/testflinger_device_connectors/devices/netboot/netboot.py similarity index 95% rename from src/snappy_device_agents/devices/netboot/netboot.py rename to src/testflinger_device_connectors/devices/netboot/netboot.py index 3bbe519c..e70f41b0 100644 --- a/src/snappy_device_agents/devices/netboot/netboot.py +++ b/src/testflinger_device_connectors/devices/netboot/netboot.py @@ -21,15 +21,18 @@ import yaml -from snappy_device_agents import CmdTimeoutError, runcmd -from snappy_device_agents.devices import ProvisioningError, RecoveryError +from testflinger_device_connectors import CmdTimeoutError, runcmd +from testflinger_device_connectors.devices import ( + ProvisioningError, + RecoveryError, +) logger = logging.getLogger() class Netboot: - """Snappy Device Agent for Netboot.""" + """Testflinger Device Connector for Netboot.""" def __init__(self, config): with open(config) as configfile: @@ -44,7 +47,7 @@ def setboot(self, mode): :raises ProvisioningError: If the command times out or anything else fails. - This method sets the snappy boot method to the specified value. + This method sets the boot method to the specified value. """ if mode == "master": setboot_script = self.config.get("select_master_script") @@ -164,7 +167,7 @@ def is_test_image_booted(self, test_username, test_password): subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=60) except Exception: return False - # If we get here, then the above command proved we are in snappy + # If we get here, the above command proved we are in the test image return True def is_master_image_booted(self): @@ -186,7 +189,7 @@ def is_master_image_booted(self): except Exception: # Any connection error will fail through the normal path pass - if "Snappy Test Device Imager" in str(data): + if "Testflinger Test Device Imager" in str(data): return True else: return False diff --git a/src/snappy_device_agents/devices/noprovision/__init__.py b/src/testflinger_device_connectors/devices/noprovision/__init__.py similarity index 68% rename from src/snappy_device_agents/devices/noprovision/__init__.py rename to src/testflinger_device_connectors/devices/noprovision/__init__.py index dc92d3eb..465c8d06 100644 --- a/src/snappy_device_agents/devices/noprovision/__init__.py +++ b/src/testflinger_device_connectors/devices/noprovision/__init__.py @@ -17,23 +17,31 @@ import logging import yaml -from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch +from testflinger_device_connectors.devices import ( + DefaultDevice, + RecoveryError, + catch, +) -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices.noprovision.noprovision import Noprovision +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices.noprovision.noprovision import ( + Noprovision, +) device_name = "noprovision" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): @catch(RecoveryError, 46) def provision(self, args): with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = Noprovision(args.config) - test_username = snappy_device_agents.get_test_username(args.job_data) + test_username = testflinger_device_connectors.get_test_username( + args.job_data + ) logmsg(logging.INFO, "BEGIN provision") device.ensure_test_image(test_username) logmsg(logging.INFO, "END provision") diff --git a/src/snappy_device_agents/devices/noprovision/noprovision.py b/src/testflinger_device_connectors/devices/noprovision/noprovision.py similarity index 95% rename from src/snappy_device_agents/devices/noprovision/noprovision.py rename to src/testflinger_device_connectors/devices/noprovision/noprovision.py index 904179c1..463cd1d6 100644 --- a/src/snappy_device_agents/devices/noprovision/noprovision.py +++ b/src/testflinger_device_connectors/devices/noprovision/noprovision.py @@ -20,14 +20,17 @@ import yaml -from snappy_device_agents.devices import ProvisioningError, RecoveryError +from testflinger_device_connectors.devices import ( + ProvisioningError, + RecoveryError, +) logger = logging.getLogger() class Noprovision: - """Snappy Device Agent for Noprovision.""" + """Testflinger Device Connector for Noprovision.""" def __init__(self, config): with open(config) as configfile: diff --git a/src/snappy_device_agents/devices/oemrecovery/__init__.py b/src/testflinger_device_connectors/devices/oemrecovery/__init__.py similarity index 76% rename from src/snappy_device_agents/devices/oemrecovery/__init__.py rename to src/testflinger_device_connectors/devices/oemrecovery/__init__.py index 4f7fbd5b..331106a5 100644 --- a/src/snappy_device_agents/devices/oemrecovery/__init__.py +++ b/src/testflinger_device_connectors/devices/oemrecovery/__init__.py @@ -18,15 +18,21 @@ import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch -from snappy_device_agents.devices.oemrecovery.oemrecovery import OemRecovery +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( + DefaultDevice, + RecoveryError, + catch, +) +from testflinger_device_connectors.devices.oemrecovery.oemrecovery import ( + OemRecovery, +) device_name = "oemrecovery" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @@ -35,7 +41,7 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = OemRecovery(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") diff --git a/src/snappy_device_agents/devices/oemrecovery/oemrecovery.py b/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py similarity index 97% rename from src/snappy_device_agents/devices/oemrecovery/oemrecovery.py rename to src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py index ba34dd1e..2bb56a79 100644 --- a/src/snappy_device_agents/devices/oemrecovery/oemrecovery.py +++ b/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py @@ -21,14 +21,17 @@ import yaml -from snappy_device_agents.devices import ProvisioningError, RecoveryError +from testflinger_device_connectors.devices import ( + ProvisioningError, + RecoveryError, +) logger = logging.getLogger() class OemRecovery: - """Device Agent for OEM Recovery.""" + """Device Connector for OEM Recovery.""" def __init__(self, config, job_data): with open(config, encoding="utf-8") as configfile: diff --git a/src/snappy_device_agents/devices/oemscript/__init__.py b/src/testflinger_device_connectors/devices/oemscript/__init__.py similarity index 76% rename from src/snappy_device_agents/devices/oemscript/__init__.py rename to src/testflinger_device_connectors/devices/oemscript/__init__.py index cf67ed95..766141e5 100644 --- a/src/snappy_device_agents/devices/oemscript/__init__.py +++ b/src/testflinger_device_connectors/devices/oemscript/__init__.py @@ -18,15 +18,19 @@ import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch -from snappy_device_agents.devices.oemscript.oemscript import OemScript +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( + DefaultDevice, + RecoveryError, + catch, +) +from testflinger_device_connectors.devices.oemscript.oemscript import OemScript device_name = "oemscript" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning baremetal with a given image.""" @@ -35,7 +39,7 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = OemScript(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") diff --git a/src/snappy_device_agents/devices/oemscript/oemscript.py b/src/testflinger_device_connectors/devices/oemscript/oemscript.py similarity index 97% rename from src/snappy_device_agents/devices/oemscript/oemscript.py rename to src/testflinger_device_connectors/devices/oemscript/oemscript.py index 54a9e58c..6bfa2af1 100644 --- a/src/snappy_device_agents/devices/oemscript/oemscript.py +++ b/src/testflinger_device_connectors/devices/oemscript/oemscript.py @@ -22,15 +22,18 @@ import time import yaml -from snappy_device_agents import download -from snappy_device_agents.devices import ProvisioningError, RecoveryError +from testflinger_device_connectors import download +from testflinger_device_connectors.devices import ( + ProvisioningError, + RecoveryError, +) logger = logging.getLogger() class OemScript: - """Device Agent for OEM Script.""" + """Device Connector for OEM Script.""" def __init__(self, config, job_data): with open(config, encoding="utf-8") as configfile: diff --git a/src/tests/test_snappy_device_agents.py b/src/tests/test_snappy_device_agents.py index 4fdec377..3a8593f9 100644 --- a/src/tests/test_snappy_device_agents.py +++ b/src/tests/test_snappy_device_agents.py @@ -14,7 +14,7 @@ # along with this program. If not, see . # -import snappy_device_agents +import testflinger_device_connectors class TestCommandsTemplate: @@ -26,7 +26,9 @@ def test_known_config_items(self): config = {"item": "foo"} expected = "test foo" assert ( - snappy_device_agents._process_cmds_template_vars(cmds, config) + testflinger_device_connectors._process_cmds_template_vars( + cmds, config + ) == expected ) @@ -35,7 +37,9 @@ def test_unknown_config_items(self): cmds = "test {unknown_item}" config = {} assert ( - snappy_device_agents._process_cmds_template_vars(cmds, config) + testflinger_device_connectors._process_cmds_template_vars( + cmds, config + ) == cmds ) @@ -45,6 +49,8 @@ def test_escaped_braces(self): config = {"item": "foo"} expected = "test {item}" assert ( - snappy_device_agents._process_cmds_template_vars(cmds, config) + testflinger_device_connectors._process_cmds_template_vars( + cmds, config + ) == expected ) From 8f6d57f424a76a2bd2a1285d0fd34e962ca69af5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 29 Sep 2023 09:29:43 -0700 Subject: [PATCH 551/569] Also support the old name for the entrypoint while we deprecate it --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 553c6123..f595959f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ ] [project.scripts] +snappy-device-agent = "snappy_device_agents.cmd:main" testflinger-device-connector = "testflinger_device_connectors.cmd:main" [tool.setuptools.package-data] From 6e9ee1cfae98211904ef720ce88f343d8fdf6b4b Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Wed, 4 Oct 2023 20:24:20 -0500 Subject: [PATCH 552/569] Fix module imported when calling the cli with the old name --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f595959f..a0f64200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ ] [project.scripts] -snappy-device-agent = "snappy_device_agents.cmd:main" +snappy-device-agent = "testflinger_device_connectors.cmd:main" testflinger-device-connector = "testflinger_device_connectors.cmd:main" [tool.setuptools.package-data] From 6baf802da2644bc8b22cf039a3abf166dc3aaad5 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 29 Aug 2023 16:51:46 -0500 Subject: [PATCH 553/569] Add oemscriptubr provisioning method --- .../devices/oemscriptubr/__init__.py | 48 +++++++++++++++++++ .../devices/oemscriptubr/oemscriptubr.py | 31 ++++++++++++ .../devices/oemscript/oemscript.py | 5 +- 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/snappy_device_agents/devices/oemscriptubr/__init__.py create mode 100644 src/snappy_device_agents/devices/oemscriptubr/oemscriptubr.py diff --git a/src/snappy_device_agents/devices/oemscriptubr/__init__.py b/src/snappy_device_agents/devices/oemscriptubr/__init__.py new file mode 100644 index 00000000..0c99d38c --- /dev/null +++ b/src/snappy_device_agents/devices/oemscriptubr/__init__.py @@ -0,0 +1,48 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Ubuntu OEM Recovery UBR provisioner support code. +Use this for systems that can use the oem recovery-from-iso.sh script +for provisioning, but require the --ubr flag in order to use the +"ubuntu recovery" method. +""" + +import logging + +import yaml + +import snappy_device_agents +from snappy_device_agents import logmsg +from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch +from snappy_device_agents.devices.oemscriptubr.oemscriptubr import OemScriptUbr + +device_name = "oemscriptubr" + + +class DeviceAgent(DefaultDevice): + + """Tool for provisioning baremetal with a given image.""" + + @catch(RecoveryError, 46) + def provision(self, args): + """Method called when the command is invoked.""" + with open(args.config) as configfile: + config = yaml.safe_load(configfile) + snappy_device_agents.configure_logging(config) + device = OemScriptUbr(args.config, args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") diff --git a/src/snappy_device_agents/devices/oemscriptubr/oemscriptubr.py b/src/snappy_device_agents/devices/oemscriptubr/oemscriptubr.py new file mode 100644 index 00000000..de1ddfd9 --- /dev/null +++ b/src/snappy_device_agents/devices/oemscriptubr/oemscriptubr.py @@ -0,0 +1,31 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu OEM Script UBR Provisioner support code. +this for systems that can use the oem recovery-from-iso.sh script +for provisioning, but require the --ubr flag in order to use the +"ubuntu recovery" method. +""" + +import logging +from snappy_device_agents.devices.oemscript.oemscript import OemScript + +logger = logging.getLogger() + + +class OemScriptUbr(OemScript): + """Device Agent for UBR OEM Script.""" + + # Extra arguments to pass to the OEM script + extra_script_args = ["--ubr"] diff --git a/src/testflinger_device_connectors/devices/oemscript/oemscript.py b/src/testflinger_device_connectors/devices/oemscript/oemscript.py index 6bfa2af1..45267fba 100644 --- a/src/testflinger_device_connectors/devices/oemscript/oemscript.py +++ b/src/testflinger_device_connectors/devices/oemscript/oemscript.py @@ -32,9 +32,11 @@ class OemScript: - """Device Connector for OEM Script.""" + # Extra arguments to pass to the OEM script + extra_script_args = [] + def __init__(self, config, job_data): with open(config, encoding="utf-8") as configfile: self.config = yaml.safe_load(configfile) @@ -112,6 +114,7 @@ def run_recovery_script(self, image_file): logger.info("Running recovery script") cmd = [ recovery_script, + *self.extra_script_args, "--local-iso", image_file, "--inject-ssh-key", From c54b9a2c1b3070913cd285a8cb7cbbe1a8d0faf7 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 5 Sep 2023 14:05:21 -0500 Subject: [PATCH 554/569] split oemscript into lenovo and dell variants --- .../__init__.py | 10 ++-- .../devices/dell_oemscript/dell_oemscript.py | 28 +++++++++++ .../devices/lenovo_oemscript/__init__.py | 48 +++++++++++++++++++ .../lenovo_oemscript.py} | 6 +-- 4 files changed, 84 insertions(+), 8 deletions(-) rename src/snappy_device_agents/devices/{oemscriptubr => dell_oemscript}/__init__.py (83%) create mode 100644 src/snappy_device_agents/devices/dell_oemscript/dell_oemscript.py create mode 100644 src/snappy_device_agents/devices/lenovo_oemscript/__init__.py rename src/snappy_device_agents/devices/{oemscriptubr/oemscriptubr.py => lenovo_oemscript/lenovo_oemscript.py} (87%) diff --git a/src/snappy_device_agents/devices/oemscriptubr/__init__.py b/src/snappy_device_agents/devices/dell_oemscript/__init__.py similarity index 83% rename from src/snappy_device_agents/devices/oemscriptubr/__init__.py rename to src/snappy_device_agents/devices/dell_oemscript/__init__.py index 0c99d38c..0f8efeb6 100644 --- a/src/snappy_device_agents/devices/oemscriptubr/__init__.py +++ b/src/snappy_device_agents/devices/dell_oemscript/__init__.py @@ -13,7 +13,7 @@ # along with this program. If not, see . """ -Ubuntu OEM Recovery UBR provisioner support code. +Ubuntu OEM Recovery provisioning for Dell OEM devices Use this for systems that can use the oem recovery-from-iso.sh script for provisioning, but require the --ubr flag in order to use the "ubuntu recovery" method. @@ -26,14 +26,14 @@ import snappy_device_agents from snappy_device_agents import logmsg from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch -from snappy_device_agents.devices.oemscriptubr.oemscriptubr import OemScriptUbr +from .dell_oemscript import DellOemScript -device_name = "oemscriptubr" +device_name = "dell_oemscript" class DeviceAgent(DefaultDevice): - """Tool for provisioning baremetal with a given image.""" + """Tool for provisioning Dell OEM devices with an oem image.""" @catch(RecoveryError, 46) def provision(self, args): @@ -41,7 +41,7 @@ def provision(self, args): with open(args.config) as configfile: config = yaml.safe_load(configfile) snappy_device_agents.configure_logging(config) - device = OemScriptUbr(args.config, args.job_data) + device = DellOemScript(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") device.provision() diff --git a/src/snappy_device_agents/devices/dell_oemscript/dell_oemscript.py b/src/snappy_device_agents/devices/dell_oemscript/dell_oemscript.py new file mode 100644 index 00000000..afa6b62b --- /dev/null +++ b/src/snappy_device_agents/devices/dell_oemscript/dell_oemscript.py @@ -0,0 +1,28 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Ubuntu OEM Script provisioning for Dell OEM devices +this for systems that can use the oem recovery-from-iso.sh script +for provisioning, but require the --ubr flag in order to use the +"ubuntu recovery" method. +""" + +import logging +from snappy_device_agents.devices.oemscript.oemscript import OemScript + +logger = logging.getLogger() + + +class DellOemScript(OemScript): + """Device Agent for Dell OEM devices.""" diff --git a/src/snappy_device_agents/devices/lenovo_oemscript/__init__.py b/src/snappy_device_agents/devices/lenovo_oemscript/__init__.py new file mode 100644 index 00000000..7e5ce52b --- /dev/null +++ b/src/snappy_device_agents/devices/lenovo_oemscript/__init__.py @@ -0,0 +1,48 @@ +# Copyright (C) 2023 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Ubuntu OEM Recovery Provisioning for Lenovo OEM devices +Use this for systems that can use the oem recovery-from-iso.sh script +for provisioning, but require the --ubr flag in order to use the +"ubuntu recovery" method. +""" + +import logging + +import yaml + +import snappy_device_agents +from snappy_device_agents import logmsg +from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch +from .lenovo_oemscript import LenovoOemScript + +device_name = "lenovo_oemscript" + + +class DeviceAgent(DefaultDevice): + + """Tool for provisioning Lenovo OEM devices with an oem image.""" + + @catch(RecoveryError, 46) + def provision(self, args): + """Method called when the command is invoked.""" + with open(args.config) as configfile: + config = yaml.safe_load(configfile) + snappy_device_agents.configure_logging(config) + device = LenovoOemScript(args.config, args.job_data) + logmsg(logging.INFO, "BEGIN provision") + logmsg(logging.INFO, "Provisioning device") + device.provision() + logmsg(logging.INFO, "END provision") diff --git a/src/snappy_device_agents/devices/oemscriptubr/oemscriptubr.py b/src/snappy_device_agents/devices/lenovo_oemscript/lenovo_oemscript.py similarity index 87% rename from src/snappy_device_agents/devices/oemscriptubr/oemscriptubr.py rename to src/snappy_device_agents/devices/lenovo_oemscript/lenovo_oemscript.py index de1ddfd9..650978b8 100644 --- a/src/snappy_device_agents/devices/oemscriptubr/oemscriptubr.py +++ b/src/snappy_device_agents/devices/lenovo_oemscript/lenovo_oemscript.py @@ -12,7 +12,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Ubuntu OEM Script UBR Provisioner support code. +"""Ubuntu OEM Script provisioning for Lenovo OEM devices this for systems that can use the oem recovery-from-iso.sh script for provisioning, but require the --ubr flag in order to use the "ubuntu recovery" method. @@ -24,8 +24,8 @@ logger = logging.getLogger() -class OemScriptUbr(OemScript): - """Device Agent for UBR OEM Script.""" +class LenovoOemScript(OemScript): + """Device Agent for Lenovo OEM devices.""" # Extra arguments to pass to the OEM script extra_script_args = ["--ubr"] From 9ab160e067f0fc6d31b0b4d55f8827e55d0a3191 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 5 Oct 2023 15:52:20 -0500 Subject: [PATCH 555/569] Update path for new device agents to testflinger_device_connectors --- .../devices/dell_oemscript/__init__.py | 12 ++++++++---- .../devices/dell_oemscript/dell_oemscript.py | 2 +- .../devices/lenovo_oemscript/__init__.py | 12 ++++++++---- .../devices/lenovo_oemscript/lenovo_oemscript.py | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/dell_oemscript/__init__.py (85%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/dell_oemscript/dell_oemscript.py (92%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/lenovo_oemscript/__init__.py (85%) rename src/{snappy_device_agents => testflinger_device_connectors}/devices/lenovo_oemscript/lenovo_oemscript.py (92%) diff --git a/src/snappy_device_agents/devices/dell_oemscript/__init__.py b/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py similarity index 85% rename from src/snappy_device_agents/devices/dell_oemscript/__init__.py rename to src/testflinger_device_connectors/devices/dell_oemscript/__init__.py index 0f8efeb6..7539b5c8 100644 --- a/src/snappy_device_agents/devices/dell_oemscript/__init__.py +++ b/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py @@ -23,9 +23,13 @@ import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( + DefaultDevice, + RecoveryError, + catch, +) from .dell_oemscript import DellOemScript device_name = "dell_oemscript" @@ -40,7 +44,7 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = DellOemScript(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") diff --git a/src/snappy_device_agents/devices/dell_oemscript/dell_oemscript.py b/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py similarity index 92% rename from src/snappy_device_agents/devices/dell_oemscript/dell_oemscript.py rename to src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py index afa6b62b..614409ff 100644 --- a/src/snappy_device_agents/devices/dell_oemscript/dell_oemscript.py +++ b/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py @@ -19,7 +19,7 @@ """ import logging -from snappy_device_agents.devices.oemscript.oemscript import OemScript +from testflinger_device_connectors.devices.oemscript.oemscript import OemScript logger = logging.getLogger() diff --git a/src/snappy_device_agents/devices/lenovo_oemscript/__init__.py b/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py similarity index 85% rename from src/snappy_device_agents/devices/lenovo_oemscript/__init__.py rename to src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py index 7e5ce52b..06660100 100644 --- a/src/snappy_device_agents/devices/lenovo_oemscript/__init__.py +++ b/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py @@ -23,9 +23,13 @@ import yaml -import snappy_device_agents -from snappy_device_agents import logmsg -from snappy_device_agents.devices import DefaultDevice, RecoveryError, catch +import testflinger_device_connectors +from testflinger_device_connectors import logmsg +from testflinger_device_connectors.devices import ( + DefaultDevice, + RecoveryError, + catch, +) from .lenovo_oemscript import LenovoOemScript device_name = "lenovo_oemscript" @@ -40,7 +44,7 @@ def provision(self, args): """Method called when the command is invoked.""" with open(args.config) as configfile: config = yaml.safe_load(configfile) - snappy_device_agents.configure_logging(config) + testflinger_device_connectors.configure_logging(config) device = LenovoOemScript(args.config, args.job_data) logmsg(logging.INFO, "BEGIN provision") logmsg(logging.INFO, "Provisioning device") diff --git a/src/snappy_device_agents/devices/lenovo_oemscript/lenovo_oemscript.py b/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py similarity index 92% rename from src/snappy_device_agents/devices/lenovo_oemscript/lenovo_oemscript.py rename to src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py index 650978b8..c57316f3 100644 --- a/src/snappy_device_agents/devices/lenovo_oemscript/lenovo_oemscript.py +++ b/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py @@ -19,7 +19,7 @@ """ import logging -from snappy_device_agents.devices.oemscript.oemscript import OemScript +from testflinger_device_connectors.devices.oemscript.oemscript import OemScript logger = logging.getLogger() From a2eeddcc01a205fb8ea5a7e10765c1b6f7cc48cb Mon Sep 17 00:00:00 2001 From: Remy MARTIN Date: Mon, 9 Oct 2023 17:58:02 +0200 Subject: [PATCH 556/569] Update tegra image detection test and surcharge cloud-init user config --- .../devices/muxpi/muxpi.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/testflinger_device_connectors/devices/muxpi/muxpi.py b/src/testflinger_device_connectors/devices/muxpi/muxpi.py index 55c03a43..1d0ea719 100644 --- a/src/testflinger_device_connectors/devices/muxpi/muxpi.py +++ b/src/testflinger_device_connectors/devices/muxpi/muxpi.py @@ -250,7 +250,7 @@ def check_path(dir): try: disk_info_path = ( - self.mount_point / "writable/lib/firmware/*-tegra/" + self.mount_point / "writable/lib/firmware/*-tegra*/" ) self._run_control(f"ls {disk_info_path} &>/dev/null") return "tegra" @@ -313,6 +313,15 @@ def create_user(self, image_type): self._run_control(cmd) self._configure_sudo() if image_type == "tegra": + base = self.mount_point / "writable" + ci_path = base / "var/lib/cloud/seed/nocloud" + self._run_control(f"sudo mkdir -p {ci_path}") + self._run_control(f"mkdir -p {remote_tmp}") + self._copy_to_control( + data_path / "classic/user-data", remote_tmp + ) + cmd = f"sudo cp {remote_tmp}/user-data {ci_path}" + self._run_control(cmd) self._configure_sudo() return if image_type == "pi-desktop": From e7b6818e4769706a2a3bff6d1a9a73f4c898cf29 Mon Sep 17 00:00:00 2001 From: nancyc12 Date: Thu, 12 Oct 2023 12:32:22 +0800 Subject: [PATCH 557/569] rename omitted DeviceAgent --- .../devices/dell_oemscript/__init__.py | 2 +- .../devices/lenovo_oemscript/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py b/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py index 7539b5c8..e44874ad 100644 --- a/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py +++ b/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py @@ -35,7 +35,7 @@ device_name = "dell_oemscript" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning Dell OEM devices with an oem image.""" diff --git a/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py b/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py index 06660100..2716962c 100644 --- a/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py +++ b/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py @@ -35,7 +35,7 @@ device_name = "lenovo_oemscript" -class DeviceAgent(DefaultDevice): +class DeviceConnector(DefaultDevice): """Tool for provisioning Lenovo OEM devices with an oem image.""" From 47900acf32e0c31e6a21f70dd18e1c06fe03576c Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 11:38:51 -0500 Subject: [PATCH 558/569] [monorepo] move testflinger to /testflinger-server --- .coveragerc => testflinger-server/.coveragerc | 0 {.github => testflinger-server/.github}/.jira_sync_config.yaml | 0 {.github => testflinger-server/.github}/pull_request_template.md | 0 .../.github}/workflows/charm_check_libs.yml | 0 .../.github}/workflows/charm_release_edge.yml | 0 .../.github}/workflows/publish_oci_image.yml | 0 {.github => testflinger-server/.github}/workflows/tox.yml | 0 .gitignore => testflinger-server/.gitignore | 0 .pylintrc => testflinger-server/.pylintrc | 0 .readthedocs.yaml => testflinger-server/.readthedocs.yaml | 0 COPYING => testflinger-server/COPYING | 0 Dockerfile => testflinger-server/Dockerfile | 0 HACKING.md => testflinger-server/HACKING.md | 0 README.rst => testflinger-server/README.rst | 0 {charm => testflinger-server/charm}/Makefile | 0 {charm => testflinger-server/charm}/README.md | 0 {charm => testflinger-server/charm}/charmcraft.yaml | 0 {charm => testflinger-server/charm}/config.yaml | 0 .../charm}/lib/charms/data_platform_libs/v0/data_interfaces.py | 0 .../charm}/lib/charms/nginx_ingress_integrator/v0/nginx_route.py | 0 {charm => testflinger-server/charm}/metadata.yaml | 0 {charm => testflinger-server/charm}/requirements.txt | 0 {charm => testflinger-server/charm}/src/charm.py | 0 {charm => testflinger-server/charm}/tests/unit/test_charm.py | 0 {devel => testflinger-server/devel}/docker-compose.override.yml | 0 {devel => testflinger-server/devel}/testflinger.yaml | 0 docker-compose.yml => testflinger-server/docker-compose.yml | 0 {docs => testflinger-server/docs}/.gitattributes | 0 {docs => testflinger-server/docs}/.sphinx/_static/custom.css | 0 {docs => testflinger-server/docs}/.sphinx/_static/favicon.png | 0 {docs => testflinger-server/docs}/.sphinx/_static/furo_colors.css | 0 .../docs}/.sphinx/_static/github_issue_links.css | 0 .../docs}/.sphinx/_static/github_issue_links.js | 0 {docs => testflinger-server/docs}/.sphinx/_static/header-nav.js | 0 {docs => testflinger-server/docs}/.sphinx/_static/header.css | 0 {docs => testflinger-server/docs}/.sphinx/_static/tag.png | 0 {docs => testflinger-server/docs}/.sphinx/_templates/base.html | 0 {docs => testflinger-server/docs}/.sphinx/_templates/footer.html | 0 {docs => testflinger-server/docs}/.sphinx/_templates/header.html | 0 {docs => testflinger-server/docs}/.sphinx/_templates/page.html | 0 {docs => testflinger-server/docs}/.sphinx/requirements.txt | 0 {docs => testflinger-server/docs}/.sphinx/spellingcheck.yaml | 0 {docs => testflinger-server/docs}/.wordlist.txt | 0 {docs => testflinger-server/docs}/Makefile | 0 {docs => testflinger-server/docs}/conf.py | 0 {docs => testflinger-server/docs}/custom_conf.py | 0 {docs => testflinger-server/docs}/explanation/index.rst | 0 {docs => testflinger-server/docs}/how-to/index.rst | 0 {docs => testflinger-server/docs}/index.rst | 0 {docs => testflinger-server/docs}/make.bat | 0 {docs => testflinger-server/docs}/reference/index.rst | 0 {docs => testflinger-server/docs}/reuse/links.txt | 0 {docs => testflinger-server/docs}/tutorial/index.rst | 0 {extras => testflinger-server/extras}/README.md | 0 {extras => testflinger-server/extras}/devices/LVFS/LVFS.py | 0 .../extras}/devices/LVFS/tests/fwupd_data.py | 0 .../extras}/devices/LVFS/tests/test_LVFS.py | 0 {extras => testflinger-server/extras}/devices/OEM/OEM.py | 0 {extras => testflinger-server/extras}/devices/__init__.py | 0 {extras => testflinger-server/extras}/devices/base.py | 0 {extras => testflinger-server/extras}/dmi.py | 0 {extras => testflinger-server/extras}/tests/test_upgrade_fw.py | 0 {extras => testflinger-server/extras}/upgrade_fw.py | 0 pyproject.toml => testflinger-server/pyproject.toml | 0 renovate.json => testflinger-server/renovate.json | 0 setup.py => testflinger-server/setup.py | 0 {src => testflinger-server/src}/__init__.py | 0 {src => testflinger-server/src}/api/__init__.py | 0 {src => testflinger-server/src}/api/schemas.py | 0 {src => testflinger-server/src}/api/v1.py | 0 {src => testflinger-server/src}/database.py | 0 {src => testflinger-server/src}/static/assets/css/testflinger.css | 0 {src => testflinger-server/src}/static/assets/js/filter.js | 0 {src => testflinger-server/src}/templates/agent_detail.html | 0 {src => testflinger-server/src}/templates/agents.html | 0 {src => testflinger-server/src}/templates/base.html | 0 {src => testflinger-server/src}/templates/job_detail.html | 0 {src => testflinger-server/src}/templates/jobs.html | 0 {src => testflinger-server/src}/templates/queue_detail.html | 0 {src => testflinger-server/src}/templates/queues.html | 0 {src => testflinger-server/src}/views.py | 0 {terraform => testflinger-server/terraform}/README.md | 0 {terraform => testflinger-server/terraform}/main.tf | 0 {terraform => testflinger-server/terraform}/variables.tf | 0 {terraform => testflinger-server/terraform}/versions.tf | 0 .../testflinger.conf.example | 0 testflinger.env => testflinger-server/testflinger.env | 0 testflinger.py => testflinger-server/testflinger.py | 0 {tests => testflinger-server/tests}/__init__.py | 0 {tests => testflinger-server/tests}/conftest.py | 0 {tests => testflinger-server/tests}/test_app.py | 0 {tests => testflinger-server/tests}/test_v1.py | 0 tox.ini => testflinger-server/tox.ini | 0 93 files changed, 0 insertions(+), 0 deletions(-) rename .coveragerc => testflinger-server/.coveragerc (100%) rename {.github => testflinger-server/.github}/.jira_sync_config.yaml (100%) rename {.github => testflinger-server/.github}/pull_request_template.md (100%) rename {.github => testflinger-server/.github}/workflows/charm_check_libs.yml (100%) rename {.github => testflinger-server/.github}/workflows/charm_release_edge.yml (100%) rename {.github => testflinger-server/.github}/workflows/publish_oci_image.yml (100%) rename {.github => testflinger-server/.github}/workflows/tox.yml (100%) rename .gitignore => testflinger-server/.gitignore (100%) rename .pylintrc => testflinger-server/.pylintrc (100%) rename .readthedocs.yaml => testflinger-server/.readthedocs.yaml (100%) rename COPYING => testflinger-server/COPYING (100%) rename Dockerfile => testflinger-server/Dockerfile (100%) rename HACKING.md => testflinger-server/HACKING.md (100%) rename README.rst => testflinger-server/README.rst (100%) rename {charm => testflinger-server/charm}/Makefile (100%) rename {charm => testflinger-server/charm}/README.md (100%) rename {charm => testflinger-server/charm}/charmcraft.yaml (100%) rename {charm => testflinger-server/charm}/config.yaml (100%) rename {charm => testflinger-server/charm}/lib/charms/data_platform_libs/v0/data_interfaces.py (100%) rename {charm => testflinger-server/charm}/lib/charms/nginx_ingress_integrator/v0/nginx_route.py (100%) rename {charm => testflinger-server/charm}/metadata.yaml (100%) rename {charm => testflinger-server/charm}/requirements.txt (100%) rename {charm => testflinger-server/charm}/src/charm.py (100%) rename {charm => testflinger-server/charm}/tests/unit/test_charm.py (100%) rename {devel => testflinger-server/devel}/docker-compose.override.yml (100%) rename {devel => testflinger-server/devel}/testflinger.yaml (100%) rename docker-compose.yml => testflinger-server/docker-compose.yml (100%) rename {docs => testflinger-server/docs}/.gitattributes (100%) rename {docs => testflinger-server/docs}/.sphinx/_static/custom.css (100%) rename {docs => testflinger-server/docs}/.sphinx/_static/favicon.png (100%) rename {docs => testflinger-server/docs}/.sphinx/_static/furo_colors.css (100%) rename {docs => testflinger-server/docs}/.sphinx/_static/github_issue_links.css (100%) rename {docs => testflinger-server/docs}/.sphinx/_static/github_issue_links.js (100%) rename {docs => testflinger-server/docs}/.sphinx/_static/header-nav.js (100%) rename {docs => testflinger-server/docs}/.sphinx/_static/header.css (100%) rename {docs => testflinger-server/docs}/.sphinx/_static/tag.png (100%) rename {docs => testflinger-server/docs}/.sphinx/_templates/base.html (100%) rename {docs => testflinger-server/docs}/.sphinx/_templates/footer.html (100%) rename {docs => testflinger-server/docs}/.sphinx/_templates/header.html (100%) rename {docs => testflinger-server/docs}/.sphinx/_templates/page.html (100%) rename {docs => testflinger-server/docs}/.sphinx/requirements.txt (100%) rename {docs => testflinger-server/docs}/.sphinx/spellingcheck.yaml (100%) rename {docs => testflinger-server/docs}/.wordlist.txt (100%) rename {docs => testflinger-server/docs}/Makefile (100%) rename {docs => testflinger-server/docs}/conf.py (100%) rename {docs => testflinger-server/docs}/custom_conf.py (100%) rename {docs => testflinger-server/docs}/explanation/index.rst (100%) rename {docs => testflinger-server/docs}/how-to/index.rst (100%) rename {docs => testflinger-server/docs}/index.rst (100%) rename {docs => testflinger-server/docs}/make.bat (100%) rename {docs => testflinger-server/docs}/reference/index.rst (100%) rename {docs => testflinger-server/docs}/reuse/links.txt (100%) rename {docs => testflinger-server/docs}/tutorial/index.rst (100%) rename {extras => testflinger-server/extras}/README.md (100%) rename {extras => testflinger-server/extras}/devices/LVFS/LVFS.py (100%) rename {extras => testflinger-server/extras}/devices/LVFS/tests/fwupd_data.py (100%) rename {extras => testflinger-server/extras}/devices/LVFS/tests/test_LVFS.py (100%) rename {extras => testflinger-server/extras}/devices/OEM/OEM.py (100%) rename {extras => testflinger-server/extras}/devices/__init__.py (100%) rename {extras => testflinger-server/extras}/devices/base.py (100%) rename {extras => testflinger-server/extras}/dmi.py (100%) rename {extras => testflinger-server/extras}/tests/test_upgrade_fw.py (100%) rename {extras => testflinger-server/extras}/upgrade_fw.py (100%) rename pyproject.toml => testflinger-server/pyproject.toml (100%) rename renovate.json => testflinger-server/renovate.json (100%) rename setup.py => testflinger-server/setup.py (100%) rename {src => testflinger-server/src}/__init__.py (100%) rename {src => testflinger-server/src}/api/__init__.py (100%) rename {src => testflinger-server/src}/api/schemas.py (100%) rename {src => testflinger-server/src}/api/v1.py (100%) rename {src => testflinger-server/src}/database.py (100%) rename {src => testflinger-server/src}/static/assets/css/testflinger.css (100%) rename {src => testflinger-server/src}/static/assets/js/filter.js (100%) rename {src => testflinger-server/src}/templates/agent_detail.html (100%) rename {src => testflinger-server/src}/templates/agents.html (100%) rename {src => testflinger-server/src}/templates/base.html (100%) rename {src => testflinger-server/src}/templates/job_detail.html (100%) rename {src => testflinger-server/src}/templates/jobs.html (100%) rename {src => testflinger-server/src}/templates/queue_detail.html (100%) rename {src => testflinger-server/src}/templates/queues.html (100%) rename {src => testflinger-server/src}/views.py (100%) rename {terraform => testflinger-server/terraform}/README.md (100%) rename {terraform => testflinger-server/terraform}/main.tf (100%) rename {terraform => testflinger-server/terraform}/variables.tf (100%) rename {terraform => testflinger-server/terraform}/versions.tf (100%) rename testflinger.conf.example => testflinger-server/testflinger.conf.example (100%) rename testflinger.env => testflinger-server/testflinger.env (100%) rename testflinger.py => testflinger-server/testflinger.py (100%) rename {tests => testflinger-server/tests}/__init__.py (100%) rename {tests => testflinger-server/tests}/conftest.py (100%) rename {tests => testflinger-server/tests}/test_app.py (100%) rename {tests => testflinger-server/tests}/test_v1.py (100%) rename tox.ini => testflinger-server/tox.ini (100%) diff --git a/.coveragerc b/testflinger-server/.coveragerc similarity index 100% rename from .coveragerc rename to testflinger-server/.coveragerc diff --git a/.github/.jira_sync_config.yaml b/testflinger-server/.github/.jira_sync_config.yaml similarity index 100% rename from .github/.jira_sync_config.yaml rename to testflinger-server/.github/.jira_sync_config.yaml diff --git a/.github/pull_request_template.md b/testflinger-server/.github/pull_request_template.md similarity index 100% rename from .github/pull_request_template.md rename to testflinger-server/.github/pull_request_template.md diff --git a/.github/workflows/charm_check_libs.yml b/testflinger-server/.github/workflows/charm_check_libs.yml similarity index 100% rename from .github/workflows/charm_check_libs.yml rename to testflinger-server/.github/workflows/charm_check_libs.yml diff --git a/.github/workflows/charm_release_edge.yml b/testflinger-server/.github/workflows/charm_release_edge.yml similarity index 100% rename from .github/workflows/charm_release_edge.yml rename to testflinger-server/.github/workflows/charm_release_edge.yml diff --git a/.github/workflows/publish_oci_image.yml b/testflinger-server/.github/workflows/publish_oci_image.yml similarity index 100% rename from .github/workflows/publish_oci_image.yml rename to testflinger-server/.github/workflows/publish_oci_image.yml diff --git a/.github/workflows/tox.yml b/testflinger-server/.github/workflows/tox.yml similarity index 100% rename from .github/workflows/tox.yml rename to testflinger-server/.github/workflows/tox.yml diff --git a/.gitignore b/testflinger-server/.gitignore similarity index 100% rename from .gitignore rename to testflinger-server/.gitignore diff --git a/.pylintrc b/testflinger-server/.pylintrc similarity index 100% rename from .pylintrc rename to testflinger-server/.pylintrc diff --git a/.readthedocs.yaml b/testflinger-server/.readthedocs.yaml similarity index 100% rename from .readthedocs.yaml rename to testflinger-server/.readthedocs.yaml diff --git a/COPYING b/testflinger-server/COPYING similarity index 100% rename from COPYING rename to testflinger-server/COPYING diff --git a/Dockerfile b/testflinger-server/Dockerfile similarity index 100% rename from Dockerfile rename to testflinger-server/Dockerfile diff --git a/HACKING.md b/testflinger-server/HACKING.md similarity index 100% rename from HACKING.md rename to testflinger-server/HACKING.md diff --git a/README.rst b/testflinger-server/README.rst similarity index 100% rename from README.rst rename to testflinger-server/README.rst diff --git a/charm/Makefile b/testflinger-server/charm/Makefile similarity index 100% rename from charm/Makefile rename to testflinger-server/charm/Makefile diff --git a/charm/README.md b/testflinger-server/charm/README.md similarity index 100% rename from charm/README.md rename to testflinger-server/charm/README.md diff --git a/charm/charmcraft.yaml b/testflinger-server/charm/charmcraft.yaml similarity index 100% rename from charm/charmcraft.yaml rename to testflinger-server/charm/charmcraft.yaml diff --git a/charm/config.yaml b/testflinger-server/charm/config.yaml similarity index 100% rename from charm/config.yaml rename to testflinger-server/charm/config.yaml diff --git a/charm/lib/charms/data_platform_libs/v0/data_interfaces.py b/testflinger-server/charm/lib/charms/data_platform_libs/v0/data_interfaces.py similarity index 100% rename from charm/lib/charms/data_platform_libs/v0/data_interfaces.py rename to testflinger-server/charm/lib/charms/data_platform_libs/v0/data_interfaces.py diff --git a/charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py b/testflinger-server/charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py similarity index 100% rename from charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py rename to testflinger-server/charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py diff --git a/charm/metadata.yaml b/testflinger-server/charm/metadata.yaml similarity index 100% rename from charm/metadata.yaml rename to testflinger-server/charm/metadata.yaml diff --git a/charm/requirements.txt b/testflinger-server/charm/requirements.txt similarity index 100% rename from charm/requirements.txt rename to testflinger-server/charm/requirements.txt diff --git a/charm/src/charm.py b/testflinger-server/charm/src/charm.py similarity index 100% rename from charm/src/charm.py rename to testflinger-server/charm/src/charm.py diff --git a/charm/tests/unit/test_charm.py b/testflinger-server/charm/tests/unit/test_charm.py similarity index 100% rename from charm/tests/unit/test_charm.py rename to testflinger-server/charm/tests/unit/test_charm.py diff --git a/devel/docker-compose.override.yml b/testflinger-server/devel/docker-compose.override.yml similarity index 100% rename from devel/docker-compose.override.yml rename to testflinger-server/devel/docker-compose.override.yml diff --git a/devel/testflinger.yaml b/testflinger-server/devel/testflinger.yaml similarity index 100% rename from devel/testflinger.yaml rename to testflinger-server/devel/testflinger.yaml diff --git a/docker-compose.yml b/testflinger-server/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to testflinger-server/docker-compose.yml diff --git a/docs/.gitattributes b/testflinger-server/docs/.gitattributes similarity index 100% rename from docs/.gitattributes rename to testflinger-server/docs/.gitattributes diff --git a/docs/.sphinx/_static/custom.css b/testflinger-server/docs/.sphinx/_static/custom.css similarity index 100% rename from docs/.sphinx/_static/custom.css rename to testflinger-server/docs/.sphinx/_static/custom.css diff --git a/docs/.sphinx/_static/favicon.png b/testflinger-server/docs/.sphinx/_static/favicon.png similarity index 100% rename from docs/.sphinx/_static/favicon.png rename to testflinger-server/docs/.sphinx/_static/favicon.png diff --git a/docs/.sphinx/_static/furo_colors.css b/testflinger-server/docs/.sphinx/_static/furo_colors.css similarity index 100% rename from docs/.sphinx/_static/furo_colors.css rename to testflinger-server/docs/.sphinx/_static/furo_colors.css diff --git a/docs/.sphinx/_static/github_issue_links.css b/testflinger-server/docs/.sphinx/_static/github_issue_links.css similarity index 100% rename from docs/.sphinx/_static/github_issue_links.css rename to testflinger-server/docs/.sphinx/_static/github_issue_links.css diff --git a/docs/.sphinx/_static/github_issue_links.js b/testflinger-server/docs/.sphinx/_static/github_issue_links.js similarity index 100% rename from docs/.sphinx/_static/github_issue_links.js rename to testflinger-server/docs/.sphinx/_static/github_issue_links.js diff --git a/docs/.sphinx/_static/header-nav.js b/testflinger-server/docs/.sphinx/_static/header-nav.js similarity index 100% rename from docs/.sphinx/_static/header-nav.js rename to testflinger-server/docs/.sphinx/_static/header-nav.js diff --git a/docs/.sphinx/_static/header.css b/testflinger-server/docs/.sphinx/_static/header.css similarity index 100% rename from docs/.sphinx/_static/header.css rename to testflinger-server/docs/.sphinx/_static/header.css diff --git a/docs/.sphinx/_static/tag.png b/testflinger-server/docs/.sphinx/_static/tag.png similarity index 100% rename from docs/.sphinx/_static/tag.png rename to testflinger-server/docs/.sphinx/_static/tag.png diff --git a/docs/.sphinx/_templates/base.html b/testflinger-server/docs/.sphinx/_templates/base.html similarity index 100% rename from docs/.sphinx/_templates/base.html rename to testflinger-server/docs/.sphinx/_templates/base.html diff --git a/docs/.sphinx/_templates/footer.html b/testflinger-server/docs/.sphinx/_templates/footer.html similarity index 100% rename from docs/.sphinx/_templates/footer.html rename to testflinger-server/docs/.sphinx/_templates/footer.html diff --git a/docs/.sphinx/_templates/header.html b/testflinger-server/docs/.sphinx/_templates/header.html similarity index 100% rename from docs/.sphinx/_templates/header.html rename to testflinger-server/docs/.sphinx/_templates/header.html diff --git a/docs/.sphinx/_templates/page.html b/testflinger-server/docs/.sphinx/_templates/page.html similarity index 100% rename from docs/.sphinx/_templates/page.html rename to testflinger-server/docs/.sphinx/_templates/page.html diff --git a/docs/.sphinx/requirements.txt b/testflinger-server/docs/.sphinx/requirements.txt similarity index 100% rename from docs/.sphinx/requirements.txt rename to testflinger-server/docs/.sphinx/requirements.txt diff --git a/docs/.sphinx/spellingcheck.yaml b/testflinger-server/docs/.sphinx/spellingcheck.yaml similarity index 100% rename from docs/.sphinx/spellingcheck.yaml rename to testflinger-server/docs/.sphinx/spellingcheck.yaml diff --git a/docs/.wordlist.txt b/testflinger-server/docs/.wordlist.txt similarity index 100% rename from docs/.wordlist.txt rename to testflinger-server/docs/.wordlist.txt diff --git a/docs/Makefile b/testflinger-server/docs/Makefile similarity index 100% rename from docs/Makefile rename to testflinger-server/docs/Makefile diff --git a/docs/conf.py b/testflinger-server/docs/conf.py similarity index 100% rename from docs/conf.py rename to testflinger-server/docs/conf.py diff --git a/docs/custom_conf.py b/testflinger-server/docs/custom_conf.py similarity index 100% rename from docs/custom_conf.py rename to testflinger-server/docs/custom_conf.py diff --git a/docs/explanation/index.rst b/testflinger-server/docs/explanation/index.rst similarity index 100% rename from docs/explanation/index.rst rename to testflinger-server/docs/explanation/index.rst diff --git a/docs/how-to/index.rst b/testflinger-server/docs/how-to/index.rst similarity index 100% rename from docs/how-to/index.rst rename to testflinger-server/docs/how-to/index.rst diff --git a/docs/index.rst b/testflinger-server/docs/index.rst similarity index 100% rename from docs/index.rst rename to testflinger-server/docs/index.rst diff --git a/docs/make.bat b/testflinger-server/docs/make.bat similarity index 100% rename from docs/make.bat rename to testflinger-server/docs/make.bat diff --git a/docs/reference/index.rst b/testflinger-server/docs/reference/index.rst similarity index 100% rename from docs/reference/index.rst rename to testflinger-server/docs/reference/index.rst diff --git a/docs/reuse/links.txt b/testflinger-server/docs/reuse/links.txt similarity index 100% rename from docs/reuse/links.txt rename to testflinger-server/docs/reuse/links.txt diff --git a/docs/tutorial/index.rst b/testflinger-server/docs/tutorial/index.rst similarity index 100% rename from docs/tutorial/index.rst rename to testflinger-server/docs/tutorial/index.rst diff --git a/extras/README.md b/testflinger-server/extras/README.md similarity index 100% rename from extras/README.md rename to testflinger-server/extras/README.md diff --git a/extras/devices/LVFS/LVFS.py b/testflinger-server/extras/devices/LVFS/LVFS.py similarity index 100% rename from extras/devices/LVFS/LVFS.py rename to testflinger-server/extras/devices/LVFS/LVFS.py diff --git a/extras/devices/LVFS/tests/fwupd_data.py b/testflinger-server/extras/devices/LVFS/tests/fwupd_data.py similarity index 100% rename from extras/devices/LVFS/tests/fwupd_data.py rename to testflinger-server/extras/devices/LVFS/tests/fwupd_data.py diff --git a/extras/devices/LVFS/tests/test_LVFS.py b/testflinger-server/extras/devices/LVFS/tests/test_LVFS.py similarity index 100% rename from extras/devices/LVFS/tests/test_LVFS.py rename to testflinger-server/extras/devices/LVFS/tests/test_LVFS.py diff --git a/extras/devices/OEM/OEM.py b/testflinger-server/extras/devices/OEM/OEM.py similarity index 100% rename from extras/devices/OEM/OEM.py rename to testflinger-server/extras/devices/OEM/OEM.py diff --git a/extras/devices/__init__.py b/testflinger-server/extras/devices/__init__.py similarity index 100% rename from extras/devices/__init__.py rename to testflinger-server/extras/devices/__init__.py diff --git a/extras/devices/base.py b/testflinger-server/extras/devices/base.py similarity index 100% rename from extras/devices/base.py rename to testflinger-server/extras/devices/base.py diff --git a/extras/dmi.py b/testflinger-server/extras/dmi.py similarity index 100% rename from extras/dmi.py rename to testflinger-server/extras/dmi.py diff --git a/extras/tests/test_upgrade_fw.py b/testflinger-server/extras/tests/test_upgrade_fw.py similarity index 100% rename from extras/tests/test_upgrade_fw.py rename to testflinger-server/extras/tests/test_upgrade_fw.py diff --git a/extras/upgrade_fw.py b/testflinger-server/extras/upgrade_fw.py similarity index 100% rename from extras/upgrade_fw.py rename to testflinger-server/extras/upgrade_fw.py diff --git a/pyproject.toml b/testflinger-server/pyproject.toml similarity index 100% rename from pyproject.toml rename to testflinger-server/pyproject.toml diff --git a/renovate.json b/testflinger-server/renovate.json similarity index 100% rename from renovate.json rename to testflinger-server/renovate.json diff --git a/setup.py b/testflinger-server/setup.py similarity index 100% rename from setup.py rename to testflinger-server/setup.py diff --git a/src/__init__.py b/testflinger-server/src/__init__.py similarity index 100% rename from src/__init__.py rename to testflinger-server/src/__init__.py diff --git a/src/api/__init__.py b/testflinger-server/src/api/__init__.py similarity index 100% rename from src/api/__init__.py rename to testflinger-server/src/api/__init__.py diff --git a/src/api/schemas.py b/testflinger-server/src/api/schemas.py similarity index 100% rename from src/api/schemas.py rename to testflinger-server/src/api/schemas.py diff --git a/src/api/v1.py b/testflinger-server/src/api/v1.py similarity index 100% rename from src/api/v1.py rename to testflinger-server/src/api/v1.py diff --git a/src/database.py b/testflinger-server/src/database.py similarity index 100% rename from src/database.py rename to testflinger-server/src/database.py diff --git a/src/static/assets/css/testflinger.css b/testflinger-server/src/static/assets/css/testflinger.css similarity index 100% rename from src/static/assets/css/testflinger.css rename to testflinger-server/src/static/assets/css/testflinger.css diff --git a/src/static/assets/js/filter.js b/testflinger-server/src/static/assets/js/filter.js similarity index 100% rename from src/static/assets/js/filter.js rename to testflinger-server/src/static/assets/js/filter.js diff --git a/src/templates/agent_detail.html b/testflinger-server/src/templates/agent_detail.html similarity index 100% rename from src/templates/agent_detail.html rename to testflinger-server/src/templates/agent_detail.html diff --git a/src/templates/agents.html b/testflinger-server/src/templates/agents.html similarity index 100% rename from src/templates/agents.html rename to testflinger-server/src/templates/agents.html diff --git a/src/templates/base.html b/testflinger-server/src/templates/base.html similarity index 100% rename from src/templates/base.html rename to testflinger-server/src/templates/base.html diff --git a/src/templates/job_detail.html b/testflinger-server/src/templates/job_detail.html similarity index 100% rename from src/templates/job_detail.html rename to testflinger-server/src/templates/job_detail.html diff --git a/src/templates/jobs.html b/testflinger-server/src/templates/jobs.html similarity index 100% rename from src/templates/jobs.html rename to testflinger-server/src/templates/jobs.html diff --git a/src/templates/queue_detail.html b/testflinger-server/src/templates/queue_detail.html similarity index 100% rename from src/templates/queue_detail.html rename to testflinger-server/src/templates/queue_detail.html diff --git a/src/templates/queues.html b/testflinger-server/src/templates/queues.html similarity index 100% rename from src/templates/queues.html rename to testflinger-server/src/templates/queues.html diff --git a/src/views.py b/testflinger-server/src/views.py similarity index 100% rename from src/views.py rename to testflinger-server/src/views.py diff --git a/terraform/README.md b/testflinger-server/terraform/README.md similarity index 100% rename from terraform/README.md rename to testflinger-server/terraform/README.md diff --git a/terraform/main.tf b/testflinger-server/terraform/main.tf similarity index 100% rename from terraform/main.tf rename to testflinger-server/terraform/main.tf diff --git a/terraform/variables.tf b/testflinger-server/terraform/variables.tf similarity index 100% rename from terraform/variables.tf rename to testflinger-server/terraform/variables.tf diff --git a/terraform/versions.tf b/testflinger-server/terraform/versions.tf similarity index 100% rename from terraform/versions.tf rename to testflinger-server/terraform/versions.tf diff --git a/testflinger.conf.example b/testflinger-server/testflinger.conf.example similarity index 100% rename from testflinger.conf.example rename to testflinger-server/testflinger.conf.example diff --git a/testflinger.env b/testflinger-server/testflinger.env similarity index 100% rename from testflinger.env rename to testflinger-server/testflinger.env diff --git a/testflinger.py b/testflinger-server/testflinger.py similarity index 100% rename from testflinger.py rename to testflinger-server/testflinger.py diff --git a/tests/__init__.py b/testflinger-server/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to testflinger-server/tests/__init__.py diff --git a/tests/conftest.py b/testflinger-server/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to testflinger-server/tests/conftest.py diff --git a/tests/test_app.py b/testflinger-server/tests/test_app.py similarity index 100% rename from tests/test_app.py rename to testflinger-server/tests/test_app.py diff --git a/tests/test_v1.py b/testflinger-server/tests/test_v1.py similarity index 100% rename from tests/test_v1.py rename to testflinger-server/tests/test_v1.py diff --git a/tox.ini b/testflinger-server/tox.ini similarity index 100% rename from tox.ini rename to testflinger-server/tox.ini From 594e83b54646eaa219c1f30232c8e76e04020cc9 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 11:40:27 -0500 Subject: [PATCH 559/569] Monorepo related changes to testflinger-server .github dir --- .../.github => .github}/.jira_sync_config.yaml | 0 .../.github => .github}/pull_request_template.md | 0 .../workflows/server-charm-check-libs.yml | 4 +++- .../workflows/server-charm-release-edge.yml | 4 +++- .../workflows/server-publish-oci-image.yml | 4 +++- .../workflows/tox.yml => .github/workflows/server-tox.yml | 7 +++++++ 6 files changed, 16 insertions(+), 3 deletions(-) rename {testflinger-server/.github => .github}/.jira_sync_config.yaml (100%) rename {testflinger-server/.github => .github}/pull_request_template.md (100%) rename testflinger-server/.github/workflows/charm_check_libs.yml => .github/workflows/server-charm-check-libs.yml (84%) rename testflinger-server/.github/workflows/charm_release_edge.yml => .github/workflows/server-charm-release-edge.yml (86%) rename testflinger-server/.github/workflows/publish_oci_image.yml => .github/workflows/server-publish-oci-image.yml (93%) rename testflinger-server/.github/workflows/tox.yml => .github/workflows/server-tox.yml (75%) diff --git a/testflinger-server/.github/.jira_sync_config.yaml b/.github/.jira_sync_config.yaml similarity index 100% rename from testflinger-server/.github/.jira_sync_config.yaml rename to .github/.jira_sync_config.yaml diff --git a/testflinger-server/.github/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from testflinger-server/.github/pull_request_template.md rename to .github/pull_request_template.md diff --git a/testflinger-server/.github/workflows/charm_check_libs.yml b/.github/workflows/server-charm-check-libs.yml similarity index 84% rename from testflinger-server/.github/workflows/charm_check_libs.yml rename to .github/workflows/server-charm-check-libs.yml index 1a62c972..0e2905ed 100644 --- a/testflinger-server/.github/workflows/charm_check_libs.yml +++ b/.github/workflows/server-charm-check-libs.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths: + - testflinger-server/** jobs: build: @@ -17,6 +19,6 @@ jobs: - name: Check libraries uses: canonical/charming-actions/check-libraries@2.4.0 with: - charm-path: charm + charm-path: testflinger-server/charm credentials: "${{ secrets.CHARMHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/testflinger-server/.github/workflows/charm_release_edge.yml b/.github/workflows/server-charm-release-edge.yml similarity index 86% rename from testflinger-server/.github/workflows/charm_release_edge.yml rename to .github/workflows/server-charm-release-edge.yml index af15a086..684ecee7 100644 --- a/testflinger-server/.github/workflows/charm_release_edge.yml +++ b/.github/workflows/server-charm-release-edge.yml @@ -4,6 +4,8 @@ on: push: branches: - main + paths: + - testflinger-server/** jobs: build: @@ -17,7 +19,7 @@ jobs: - name: Upload charm to charmhub uses: canonical/charming-actions/upload-charm@2.4.0 with: - charm-path: charm + charm-path: testflinger-server/charm credentials: "${{ secrets.CHARMHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}" upload-image: "true" diff --git a/testflinger-server/.github/workflows/publish_oci_image.yml b/.github/workflows/server-publish-oci-image.yml similarity index 93% rename from testflinger-server/.github/workflows/publish_oci_image.yml rename to .github/workflows/server-publish-oci-image.yml index bf9935e5..90616118 100644 --- a/testflinger-server/.github/workflows/publish_oci_image.yml +++ b/.github/workflows/server-publish-oci-image.yml @@ -3,6 +3,8 @@ on: push: branches: ["main"] tags: ["v*.*.*"] + paths: + - testflinger-server/** env: REGISTRY: ghcr.io @@ -37,7 +39,7 @@ jobs: - name: Build and push backend Docker image uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 with: - context: . + context: ./testflinger-server file: Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/testflinger-server/.github/workflows/tox.yml b/.github/workflows/server-tox.yml similarity index 75% rename from testflinger-server/.github/workflows/tox.yml rename to .github/workflows/server-tox.yml index c9011a8b..2ac0b010 100644 --- a/testflinger-server/.github/workflows/tox.yml +++ b/.github/workflows/server-tox.yml @@ -3,11 +3,18 @@ name: Run unit tests on: push: branches: [ main ] + paths: + - testflinger-server/** pull_request: branches: [ main ] + paths: + - testflinger-server/** jobs: build: + defaults: + run: + working-directory: testflinger-server runs-on: ubuntu-latest strategy: matrix: From acd79239bc4894f5ada078b05cabb49309e7269f Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 11:41:54 -0500 Subject: [PATCH 560/569] Adapt docs dir to monorepo --- testflinger-server/.readthedocs.yaml => .readthedocs.yaml | 0 {testflinger-server/docs => docs}/.gitattributes | 0 {testflinger-server/docs => docs}/.sphinx/_static/custom.css | 0 {testflinger-server/docs => docs}/.sphinx/_static/favicon.png | 0 {testflinger-server/docs => docs}/.sphinx/_static/furo_colors.css | 0 .../docs => docs}/.sphinx/_static/github_issue_links.css | 0 .../docs => docs}/.sphinx/_static/github_issue_links.js | 0 {testflinger-server/docs => docs}/.sphinx/_static/header-nav.js | 0 {testflinger-server/docs => docs}/.sphinx/_static/header.css | 0 {testflinger-server/docs => docs}/.sphinx/_static/tag.png | 0 {testflinger-server/docs => docs}/.sphinx/_templates/base.html | 0 {testflinger-server/docs => docs}/.sphinx/_templates/footer.html | 0 {testflinger-server/docs => docs}/.sphinx/_templates/header.html | 0 {testflinger-server/docs => docs}/.sphinx/_templates/page.html | 0 {testflinger-server/docs => docs}/.sphinx/requirements.txt | 0 {testflinger-server/docs => docs}/.sphinx/spellingcheck.yaml | 0 {testflinger-server/docs => docs}/.wordlist.txt | 0 {testflinger-server/docs => docs}/Makefile | 0 {testflinger-server/docs => docs}/conf.py | 0 {testflinger-server/docs => docs}/custom_conf.py | 0 {testflinger-server/docs => docs}/explanation/index.rst | 0 {testflinger-server/docs => docs}/how-to/index.rst | 0 {testflinger-server/docs => docs}/index.rst | 0 {testflinger-server/docs => docs}/make.bat | 0 {testflinger-server/docs => docs}/reference/index.rst | 0 {testflinger-server/docs => docs}/reuse/links.txt | 0 {testflinger-server/docs => docs}/tutorial/index.rst | 0 27 files changed, 0 insertions(+), 0 deletions(-) rename testflinger-server/.readthedocs.yaml => .readthedocs.yaml (100%) rename {testflinger-server/docs => docs}/.gitattributes (100%) rename {testflinger-server/docs => docs}/.sphinx/_static/custom.css (100%) rename {testflinger-server/docs => docs}/.sphinx/_static/favicon.png (100%) rename {testflinger-server/docs => docs}/.sphinx/_static/furo_colors.css (100%) rename {testflinger-server/docs => docs}/.sphinx/_static/github_issue_links.css (100%) rename {testflinger-server/docs => docs}/.sphinx/_static/github_issue_links.js (100%) rename {testflinger-server/docs => docs}/.sphinx/_static/header-nav.js (100%) rename {testflinger-server/docs => docs}/.sphinx/_static/header.css (100%) rename {testflinger-server/docs => docs}/.sphinx/_static/tag.png (100%) rename {testflinger-server/docs => docs}/.sphinx/_templates/base.html (100%) rename {testflinger-server/docs => docs}/.sphinx/_templates/footer.html (100%) rename {testflinger-server/docs => docs}/.sphinx/_templates/header.html (100%) rename {testflinger-server/docs => docs}/.sphinx/_templates/page.html (100%) rename {testflinger-server/docs => docs}/.sphinx/requirements.txt (100%) rename {testflinger-server/docs => docs}/.sphinx/spellingcheck.yaml (100%) rename {testflinger-server/docs => docs}/.wordlist.txt (100%) rename {testflinger-server/docs => docs}/Makefile (100%) rename {testflinger-server/docs => docs}/conf.py (100%) rename {testflinger-server/docs => docs}/custom_conf.py (100%) rename {testflinger-server/docs => docs}/explanation/index.rst (100%) rename {testflinger-server/docs => docs}/how-to/index.rst (100%) rename {testflinger-server/docs => docs}/index.rst (100%) rename {testflinger-server/docs => docs}/make.bat (100%) rename {testflinger-server/docs => docs}/reference/index.rst (100%) rename {testflinger-server/docs => docs}/reuse/links.txt (100%) rename {testflinger-server/docs => docs}/tutorial/index.rst (100%) diff --git a/testflinger-server/.readthedocs.yaml b/.readthedocs.yaml similarity index 100% rename from testflinger-server/.readthedocs.yaml rename to .readthedocs.yaml diff --git a/testflinger-server/docs/.gitattributes b/docs/.gitattributes similarity index 100% rename from testflinger-server/docs/.gitattributes rename to docs/.gitattributes diff --git a/testflinger-server/docs/.sphinx/_static/custom.css b/docs/.sphinx/_static/custom.css similarity index 100% rename from testflinger-server/docs/.sphinx/_static/custom.css rename to docs/.sphinx/_static/custom.css diff --git a/testflinger-server/docs/.sphinx/_static/favicon.png b/docs/.sphinx/_static/favicon.png similarity index 100% rename from testflinger-server/docs/.sphinx/_static/favicon.png rename to docs/.sphinx/_static/favicon.png diff --git a/testflinger-server/docs/.sphinx/_static/furo_colors.css b/docs/.sphinx/_static/furo_colors.css similarity index 100% rename from testflinger-server/docs/.sphinx/_static/furo_colors.css rename to docs/.sphinx/_static/furo_colors.css diff --git a/testflinger-server/docs/.sphinx/_static/github_issue_links.css b/docs/.sphinx/_static/github_issue_links.css similarity index 100% rename from testflinger-server/docs/.sphinx/_static/github_issue_links.css rename to docs/.sphinx/_static/github_issue_links.css diff --git a/testflinger-server/docs/.sphinx/_static/github_issue_links.js b/docs/.sphinx/_static/github_issue_links.js similarity index 100% rename from testflinger-server/docs/.sphinx/_static/github_issue_links.js rename to docs/.sphinx/_static/github_issue_links.js diff --git a/testflinger-server/docs/.sphinx/_static/header-nav.js b/docs/.sphinx/_static/header-nav.js similarity index 100% rename from testflinger-server/docs/.sphinx/_static/header-nav.js rename to docs/.sphinx/_static/header-nav.js diff --git a/testflinger-server/docs/.sphinx/_static/header.css b/docs/.sphinx/_static/header.css similarity index 100% rename from testflinger-server/docs/.sphinx/_static/header.css rename to docs/.sphinx/_static/header.css diff --git a/testflinger-server/docs/.sphinx/_static/tag.png b/docs/.sphinx/_static/tag.png similarity index 100% rename from testflinger-server/docs/.sphinx/_static/tag.png rename to docs/.sphinx/_static/tag.png diff --git a/testflinger-server/docs/.sphinx/_templates/base.html b/docs/.sphinx/_templates/base.html similarity index 100% rename from testflinger-server/docs/.sphinx/_templates/base.html rename to docs/.sphinx/_templates/base.html diff --git a/testflinger-server/docs/.sphinx/_templates/footer.html b/docs/.sphinx/_templates/footer.html similarity index 100% rename from testflinger-server/docs/.sphinx/_templates/footer.html rename to docs/.sphinx/_templates/footer.html diff --git a/testflinger-server/docs/.sphinx/_templates/header.html b/docs/.sphinx/_templates/header.html similarity index 100% rename from testflinger-server/docs/.sphinx/_templates/header.html rename to docs/.sphinx/_templates/header.html diff --git a/testflinger-server/docs/.sphinx/_templates/page.html b/docs/.sphinx/_templates/page.html similarity index 100% rename from testflinger-server/docs/.sphinx/_templates/page.html rename to docs/.sphinx/_templates/page.html diff --git a/testflinger-server/docs/.sphinx/requirements.txt b/docs/.sphinx/requirements.txt similarity index 100% rename from testflinger-server/docs/.sphinx/requirements.txt rename to docs/.sphinx/requirements.txt diff --git a/testflinger-server/docs/.sphinx/spellingcheck.yaml b/docs/.sphinx/spellingcheck.yaml similarity index 100% rename from testflinger-server/docs/.sphinx/spellingcheck.yaml rename to docs/.sphinx/spellingcheck.yaml diff --git a/testflinger-server/docs/.wordlist.txt b/docs/.wordlist.txt similarity index 100% rename from testflinger-server/docs/.wordlist.txt rename to docs/.wordlist.txt diff --git a/testflinger-server/docs/Makefile b/docs/Makefile similarity index 100% rename from testflinger-server/docs/Makefile rename to docs/Makefile diff --git a/testflinger-server/docs/conf.py b/docs/conf.py similarity index 100% rename from testflinger-server/docs/conf.py rename to docs/conf.py diff --git a/testflinger-server/docs/custom_conf.py b/docs/custom_conf.py similarity index 100% rename from testflinger-server/docs/custom_conf.py rename to docs/custom_conf.py diff --git a/testflinger-server/docs/explanation/index.rst b/docs/explanation/index.rst similarity index 100% rename from testflinger-server/docs/explanation/index.rst rename to docs/explanation/index.rst diff --git a/testflinger-server/docs/how-to/index.rst b/docs/how-to/index.rst similarity index 100% rename from testflinger-server/docs/how-to/index.rst rename to docs/how-to/index.rst diff --git a/testflinger-server/docs/index.rst b/docs/index.rst similarity index 100% rename from testflinger-server/docs/index.rst rename to docs/index.rst diff --git a/testflinger-server/docs/make.bat b/docs/make.bat similarity index 100% rename from testflinger-server/docs/make.bat rename to docs/make.bat diff --git a/testflinger-server/docs/reference/index.rst b/docs/reference/index.rst similarity index 100% rename from testflinger-server/docs/reference/index.rst rename to docs/reference/index.rst diff --git a/testflinger-server/docs/reuse/links.txt b/docs/reuse/links.txt similarity index 100% rename from testflinger-server/docs/reuse/links.txt rename to docs/reuse/links.txt diff --git a/testflinger-server/docs/tutorial/index.rst b/docs/tutorial/index.rst similarity index 100% rename from testflinger-server/docs/tutorial/index.rst rename to docs/tutorial/index.rst From a6cd39ab8b42559d667bdb42ee83effce9d84405 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 16:16:34 -0500 Subject: [PATCH 561/569] [monorepo] move testflinger-agent to /testflinger-agent --- {.github => testflinger-agent/.github}/workflows/tox.yml | 0 .gitignore => testflinger-agent/.gitignore | 0 .pmr-merge-hook => testflinger-agent/.pmr-merge-hook | 0 README.rst => testflinger-agent/README.rst | 0 pyproject.toml => testflinger-agent/pyproject.toml | 0 renovate.json => testflinger-agent/renovate.json | 0 setup.cfg => testflinger-agent/setup.cfg | 0 setup.py => testflinger-agent/setup.py | 0 .../testflinger-agent.conf.example | 0 .../testflinger_agent}/__init__.py | 0 .../testflinger_agent}/agent.py | 0 .../testflinger_agent}/client.py | 0 {testflinger_agent => testflinger-agent/testflinger_agent}/cmd.py | 0 .../testflinger_agent}/errors.py | 0 {testflinger_agent => testflinger-agent/testflinger_agent}/job.py | 0 .../testflinger_agent}/schema.py | 0 .../testflinger_agent}/tests/__init__.py | 0 .../testflinger_agent}/tests/test_agent.py | 0 .../testflinger_agent}/tests/test_client.py | 0 .../testflinger_agent}/tests/test_config.py | 0 .../testflinger_agent}/tests/test_job.py | 0 tox.ini => testflinger-agent/tox.ini | 0 22 files changed, 0 insertions(+), 0 deletions(-) rename {.github => testflinger-agent/.github}/workflows/tox.yml (100%) rename .gitignore => testflinger-agent/.gitignore (100%) rename .pmr-merge-hook => testflinger-agent/.pmr-merge-hook (100%) rename README.rst => testflinger-agent/README.rst (100%) rename pyproject.toml => testflinger-agent/pyproject.toml (100%) rename renovate.json => testflinger-agent/renovate.json (100%) rename setup.cfg => testflinger-agent/setup.cfg (100%) rename setup.py => testflinger-agent/setup.py (100%) rename testflinger-agent.conf.example => testflinger-agent/testflinger-agent.conf.example (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/__init__.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/agent.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/client.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/cmd.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/errors.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/job.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/schema.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/tests/__init__.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/tests/test_agent.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/tests/test_client.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/tests/test_config.py (100%) rename {testflinger_agent => testflinger-agent/testflinger_agent}/tests/test_job.py (100%) rename tox.ini => testflinger-agent/tox.ini (100%) diff --git a/.github/workflows/tox.yml b/testflinger-agent/.github/workflows/tox.yml similarity index 100% rename from .github/workflows/tox.yml rename to testflinger-agent/.github/workflows/tox.yml diff --git a/.gitignore b/testflinger-agent/.gitignore similarity index 100% rename from .gitignore rename to testflinger-agent/.gitignore diff --git a/.pmr-merge-hook b/testflinger-agent/.pmr-merge-hook similarity index 100% rename from .pmr-merge-hook rename to testflinger-agent/.pmr-merge-hook diff --git a/README.rst b/testflinger-agent/README.rst similarity index 100% rename from README.rst rename to testflinger-agent/README.rst diff --git a/pyproject.toml b/testflinger-agent/pyproject.toml similarity index 100% rename from pyproject.toml rename to testflinger-agent/pyproject.toml diff --git a/renovate.json b/testflinger-agent/renovate.json similarity index 100% rename from renovate.json rename to testflinger-agent/renovate.json diff --git a/setup.cfg b/testflinger-agent/setup.cfg similarity index 100% rename from setup.cfg rename to testflinger-agent/setup.cfg diff --git a/setup.py b/testflinger-agent/setup.py similarity index 100% rename from setup.py rename to testflinger-agent/setup.py diff --git a/testflinger-agent.conf.example b/testflinger-agent/testflinger-agent.conf.example similarity index 100% rename from testflinger-agent.conf.example rename to testflinger-agent/testflinger-agent.conf.example diff --git a/testflinger_agent/__init__.py b/testflinger-agent/testflinger_agent/__init__.py similarity index 100% rename from testflinger_agent/__init__.py rename to testflinger-agent/testflinger_agent/__init__.py diff --git a/testflinger_agent/agent.py b/testflinger-agent/testflinger_agent/agent.py similarity index 100% rename from testflinger_agent/agent.py rename to testflinger-agent/testflinger_agent/agent.py diff --git a/testflinger_agent/client.py b/testflinger-agent/testflinger_agent/client.py similarity index 100% rename from testflinger_agent/client.py rename to testflinger-agent/testflinger_agent/client.py diff --git a/testflinger_agent/cmd.py b/testflinger-agent/testflinger_agent/cmd.py similarity index 100% rename from testflinger_agent/cmd.py rename to testflinger-agent/testflinger_agent/cmd.py diff --git a/testflinger_agent/errors.py b/testflinger-agent/testflinger_agent/errors.py similarity index 100% rename from testflinger_agent/errors.py rename to testflinger-agent/testflinger_agent/errors.py diff --git a/testflinger_agent/job.py b/testflinger-agent/testflinger_agent/job.py similarity index 100% rename from testflinger_agent/job.py rename to testflinger-agent/testflinger_agent/job.py diff --git a/testflinger_agent/schema.py b/testflinger-agent/testflinger_agent/schema.py similarity index 100% rename from testflinger_agent/schema.py rename to testflinger-agent/testflinger_agent/schema.py diff --git a/testflinger_agent/tests/__init__.py b/testflinger-agent/testflinger_agent/tests/__init__.py similarity index 100% rename from testflinger_agent/tests/__init__.py rename to testflinger-agent/testflinger_agent/tests/__init__.py diff --git a/testflinger_agent/tests/test_agent.py b/testflinger-agent/testflinger_agent/tests/test_agent.py similarity index 100% rename from testflinger_agent/tests/test_agent.py rename to testflinger-agent/testflinger_agent/tests/test_agent.py diff --git a/testflinger_agent/tests/test_client.py b/testflinger-agent/testflinger_agent/tests/test_client.py similarity index 100% rename from testflinger_agent/tests/test_client.py rename to testflinger-agent/testflinger_agent/tests/test_client.py diff --git a/testflinger_agent/tests/test_config.py b/testflinger-agent/testflinger_agent/tests/test_config.py similarity index 100% rename from testflinger_agent/tests/test_config.py rename to testflinger-agent/testflinger_agent/tests/test_config.py diff --git a/testflinger_agent/tests/test_job.py b/testflinger-agent/testflinger_agent/tests/test_job.py similarity index 100% rename from testflinger_agent/tests/test_job.py rename to testflinger-agent/testflinger_agent/tests/test_job.py diff --git a/tox.ini b/testflinger-agent/tox.ini similarity index 100% rename from tox.ini rename to testflinger-agent/tox.ini From 05f7f8b4e3dfd4b460ea7368d8c30abd40901dcc Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 16:23:56 -0500 Subject: [PATCH 562/569] Move testflinger-agent github workflows to monorepo location --- .../workflows/tox.yml => .github/workflows/agent-tox.yml | 7 +++++++ 1 file changed, 7 insertions(+) rename testflinger-agent/.github/workflows/tox.yml => .github/workflows/agent-tox.yml (75%) diff --git a/testflinger-agent/.github/workflows/tox.yml b/.github/workflows/agent-tox.yml similarity index 75% rename from testflinger-agent/.github/workflows/tox.yml rename to .github/workflows/agent-tox.yml index c9011a8b..aa22580f 100644 --- a/testflinger-agent/.github/workflows/tox.yml +++ b/.github/workflows/agent-tox.yml @@ -3,11 +3,18 @@ name: Run unit tests on: push: branches: [ main ] + paths: + - testflinger-agent/** pull_request: branches: [ main ] + paths: + - testflinger-agent/** jobs: build: + defaults: + run: + working-directory: testflinger-agent runs-on: ubuntu-latest strategy: matrix: From 95848e5e6a9b861ea6ccbc5f76a67a72d5777ead Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 17:10:03 -0500 Subject: [PATCH 563/569] [monorepo] move testflinger-device-connectors to /testflinger-device-connectors --- .../.github}/workflows/tox.yml | 0 .gitignore => testflinger-device-connectors/.gitignore | 0 .pmr-merge-hook => testflinger-device-connectors/.pmr-merge-hook | 0 AUTHORS => testflinger-device-connectors/AUTHORS | 0 COPYING => testflinger-device-connectors/COPYING | 0 .../README-pip-cache.rst | 0 README.rst => testflinger-device-connectors/README.rst | 0 pyproject.toml => testflinger-device-connectors/pyproject.toml | 0 renovate.json => testflinger-device-connectors/renovate.json | 0 setup.cfg => testflinger-device-connectors/setup.cfg | 0 setup.py => testflinger-device-connectors/setup.py | 0 .../src}/testflinger_device_connectors/__init__.py | 0 .../src}/testflinger_device_connectors/cmd.py | 0 .../testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data | 0 .../testflinger_device_connectors/data/muxpi/classic/meta-data | 0 .../testflinger_device_connectors/data/muxpi/classic/user-data | 0 .../testflinger_device_connectors/data/muxpi/oemscript/README | 0 .../data/muxpi/oemscript/recovery-from-iso.sh | 0 .../data/muxpi/pi-desktop/oem-config.service | 0 .../data/muxpi/pi-desktop/preseed.cfg | 0 .../testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg | 0 .../data/pi-desktop/oem-config.service | 0 .../testflinger_device_connectors/data/pi-desktop/preseed.cfg | 0 .../src}/testflinger_device_connectors/devices/__init__.py | 0 .../src}/testflinger_device_connectors/devices/cm3/__init__.py | 0 .../src}/testflinger_device_connectors/devices/cm3/cm3.py | 0 .../devices/dell_oemscript/__init__.py | 0 .../devices/dell_oemscript/dell_oemscript.py | 0 .../testflinger_device_connectors/devices/dragonboard/__init__.py | 0 .../devices/dragonboard/dragonboard.py | 0 .../devices/lenovo_oemscript/__init__.py | 0 .../devices/lenovo_oemscript/lenovo_oemscript.py | 0 .../src}/testflinger_device_connectors/devices/maas2/__init__.py | 0 .../devices/maas2/doc/maas_storage.rst | 0 .../src}/testflinger_device_connectors/devices/maas2/maas2.py | 0 .../testflinger_device_connectors/devices/maas2/maas_storage.py | 0 .../devices/maas2/tests/test_maas_storage.py | 0 .../src}/testflinger_device_connectors/devices/multi/__init__.py | 0 .../src}/testflinger_device_connectors/devices/multi/multi.py | 0 .../devices/multi/tests/test_multi.py | 0 .../src}/testflinger_device_connectors/devices/multi/tfclient.py | 0 .../src}/testflinger_device_connectors/devices/muxpi/__init__.py | 0 .../src}/testflinger_device_connectors/devices/muxpi/muxpi.py | 0 .../devices/muxpi/tests/test_muxpi.py | 0 .../testflinger_device_connectors/devices/netboot/__init__.py | 0 .../src}/testflinger_device_connectors/devices/netboot/netboot.py | 0 .../testflinger_device_connectors/devices/noprovision/__init__.py | 0 .../devices/noprovision/noprovision.py | 0 .../testflinger_device_connectors/devices/oemrecovery/__init__.py | 0 .../devices/oemrecovery/oemrecovery.py | 0 .../testflinger_device_connectors/devices/oemscript/__init__.py | 0 .../testflinger_device_connectors/devices/oemscript/oemscript.py | 0 {src => testflinger-device-connectors/src}/tests/__init__.py | 0 .../src}/tests/test_snappy_device_agents.py | 0 tox.ini => testflinger-device-connectors/tox.ini | 0 55 files changed, 0 insertions(+), 0 deletions(-) rename {.github => testflinger-device-connectors/.github}/workflows/tox.yml (100%) rename .gitignore => testflinger-device-connectors/.gitignore (100%) rename .pmr-merge-hook => testflinger-device-connectors/.pmr-merge-hook (100%) rename AUTHORS => testflinger-device-connectors/AUTHORS (100%) rename COPYING => testflinger-device-connectors/COPYING (100%) rename README-pip-cache.rst => testflinger-device-connectors/README-pip-cache.rst (100%) rename README.rst => testflinger-device-connectors/README.rst (100%) rename pyproject.toml => testflinger-device-connectors/pyproject.toml (100%) rename renovate.json => testflinger-device-connectors/renovate.json (100%) rename setup.cfg => testflinger-device-connectors/setup.cfg (100%) rename setup.py => testflinger-device-connectors/setup.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/cmd.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/muxpi/classic/meta-data (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/muxpi/classic/user-data (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/muxpi/oemscript/README (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/pi-desktop/oem-config.service (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/data/pi-desktop/preseed.cfg (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/cm3/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/cm3/cm3.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/dell_oemscript/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/dragonboard/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/dragonboard/dragonboard.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/maas2/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/maas2/maas2.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/maas2/maas_storage.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/multi/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/multi/multi.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/multi/tests/test_multi.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/multi/tfclient.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/muxpi/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/muxpi/muxpi.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/netboot/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/netboot/netboot.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/noprovision/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/noprovision/noprovision.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/oemrecovery/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/oemscript/__init__.py (100%) rename {src => testflinger-device-connectors/src}/testflinger_device_connectors/devices/oemscript/oemscript.py (100%) rename {src => testflinger-device-connectors/src}/tests/__init__.py (100%) rename {src => testflinger-device-connectors/src}/tests/test_snappy_device_agents.py (100%) rename tox.ini => testflinger-device-connectors/tox.ini (100%) diff --git a/.github/workflows/tox.yml b/testflinger-device-connectors/.github/workflows/tox.yml similarity index 100% rename from .github/workflows/tox.yml rename to testflinger-device-connectors/.github/workflows/tox.yml diff --git a/.gitignore b/testflinger-device-connectors/.gitignore similarity index 100% rename from .gitignore rename to testflinger-device-connectors/.gitignore diff --git a/.pmr-merge-hook b/testflinger-device-connectors/.pmr-merge-hook similarity index 100% rename from .pmr-merge-hook rename to testflinger-device-connectors/.pmr-merge-hook diff --git a/AUTHORS b/testflinger-device-connectors/AUTHORS similarity index 100% rename from AUTHORS rename to testflinger-device-connectors/AUTHORS diff --git a/COPYING b/testflinger-device-connectors/COPYING similarity index 100% rename from COPYING rename to testflinger-device-connectors/COPYING diff --git a/README-pip-cache.rst b/testflinger-device-connectors/README-pip-cache.rst similarity index 100% rename from README-pip-cache.rst rename to testflinger-device-connectors/README-pip-cache.rst diff --git a/README.rst b/testflinger-device-connectors/README.rst similarity index 100% rename from README.rst rename to testflinger-device-connectors/README.rst diff --git a/pyproject.toml b/testflinger-device-connectors/pyproject.toml similarity index 100% rename from pyproject.toml rename to testflinger-device-connectors/pyproject.toml diff --git a/renovate.json b/testflinger-device-connectors/renovate.json similarity index 100% rename from renovate.json rename to testflinger-device-connectors/renovate.json diff --git a/setup.cfg b/testflinger-device-connectors/setup.cfg similarity index 100% rename from setup.cfg rename to testflinger-device-connectors/setup.cfg diff --git a/setup.py b/testflinger-device-connectors/setup.py similarity index 100% rename from setup.py rename to testflinger-device-connectors/setup.py diff --git a/src/testflinger_device_connectors/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/__init__.py similarity index 100% rename from src/testflinger_device_connectors/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/__init__.py diff --git a/src/testflinger_device_connectors/cmd.py b/testflinger-device-connectors/src/testflinger_device_connectors/cmd.py similarity index 100% rename from src/testflinger_device_connectors/cmd.py rename to testflinger-device-connectors/src/testflinger_device_connectors/cmd.py diff --git a/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data b/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data similarity index 100% rename from src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data rename to testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data diff --git a/src/testflinger_device_connectors/data/muxpi/classic/meta-data b/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/classic/meta-data similarity index 100% rename from src/testflinger_device_connectors/data/muxpi/classic/meta-data rename to testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/classic/meta-data diff --git a/src/testflinger_device_connectors/data/muxpi/classic/user-data b/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/classic/user-data similarity index 100% rename from src/testflinger_device_connectors/data/muxpi/classic/user-data rename to testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/classic/user-data diff --git a/src/testflinger_device_connectors/data/muxpi/oemscript/README b/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/README similarity index 100% rename from src/testflinger_device_connectors/data/muxpi/oemscript/README rename to testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/README diff --git a/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh b/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh similarity index 100% rename from src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh rename to testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh diff --git a/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service b/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service similarity index 100% rename from src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service rename to testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service diff --git a/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg b/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg similarity index 100% rename from src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg rename to testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg diff --git a/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg b/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg similarity index 100% rename from src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg rename to testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg diff --git a/src/testflinger_device_connectors/data/pi-desktop/oem-config.service b/testflinger-device-connectors/src/testflinger_device_connectors/data/pi-desktop/oem-config.service similarity index 100% rename from src/testflinger_device_connectors/data/pi-desktop/oem-config.service rename to testflinger-device-connectors/src/testflinger_device_connectors/data/pi-desktop/oem-config.service diff --git a/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg b/testflinger-device-connectors/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg similarity index 100% rename from src/testflinger_device_connectors/data/pi-desktop/preseed.cfg rename to testflinger-device-connectors/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg diff --git a/src/testflinger_device_connectors/devices/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/__init__.py diff --git a/src/testflinger_device_connectors/devices/cm3/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/cm3/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/cm3/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/cm3/__init__.py diff --git a/src/testflinger_device_connectors/devices/cm3/cm3.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/cm3/cm3.py similarity index 100% rename from src/testflinger_device_connectors/devices/cm3/cm3.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/cm3/cm3.py diff --git a/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/dell_oemscript/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py diff --git a/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py similarity index 100% rename from src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py diff --git a/src/testflinger_device_connectors/devices/dragonboard/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/dragonboard/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/dragonboard/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/dragonboard/__init__.py diff --git a/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py similarity index 100% rename from src/testflinger_device_connectors/devices/dragonboard/dragonboard.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py diff --git a/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py diff --git a/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py similarity index 100% rename from src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py diff --git a/src/testflinger_device_connectors/devices/maas2/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/maas2/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/__init__.py diff --git a/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst b/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst similarity index 100% rename from src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst diff --git a/src/testflinger_device_connectors/devices/maas2/maas2.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/maas2.py similarity index 100% rename from src/testflinger_device_connectors/devices/maas2/maas2.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/maas2.py diff --git a/src/testflinger_device_connectors/devices/maas2/maas_storage.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/maas_storage.py similarity index 100% rename from src/testflinger_device_connectors/devices/maas2/maas_storage.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/maas_storage.py diff --git a/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py similarity index 100% rename from src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py diff --git a/src/testflinger_device_connectors/devices/multi/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/multi/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/__init__.py diff --git a/src/testflinger_device_connectors/devices/multi/multi.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/multi.py similarity index 100% rename from src/testflinger_device_connectors/devices/multi/multi.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/multi.py diff --git a/src/testflinger_device_connectors/devices/multi/tests/test_multi.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py similarity index 100% rename from src/testflinger_device_connectors/devices/multi/tests/test_multi.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py diff --git a/src/testflinger_device_connectors/devices/multi/tfclient.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py similarity index 100% rename from src/testflinger_device_connectors/devices/multi/tfclient.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py diff --git a/src/testflinger_device_connectors/devices/muxpi/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/muxpi/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/__init__.py diff --git a/src/testflinger_device_connectors/devices/muxpi/muxpi.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/muxpi.py similarity index 100% rename from src/testflinger_device_connectors/devices/muxpi/muxpi.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/muxpi.py diff --git a/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py similarity index 100% rename from src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py diff --git a/src/testflinger_device_connectors/devices/netboot/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/netboot/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/netboot/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/netboot/__init__.py diff --git a/src/testflinger_device_connectors/devices/netboot/netboot.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/netboot/netboot.py similarity index 100% rename from src/testflinger_device_connectors/devices/netboot/netboot.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/netboot/netboot.py diff --git a/src/testflinger_device_connectors/devices/noprovision/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/noprovision/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/noprovision/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/noprovision/__init__.py diff --git a/src/testflinger_device_connectors/devices/noprovision/noprovision.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/noprovision/noprovision.py similarity index 100% rename from src/testflinger_device_connectors/devices/noprovision/noprovision.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/noprovision/noprovision.py diff --git a/src/testflinger_device_connectors/devices/oemrecovery/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/oemrecovery/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/oemrecovery/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/oemrecovery/__init__.py diff --git a/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py similarity index 100% rename from src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py diff --git a/src/testflinger_device_connectors/devices/oemscript/__init__.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/oemscript/__init__.py similarity index 100% rename from src/testflinger_device_connectors/devices/oemscript/__init__.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/oemscript/__init__.py diff --git a/src/testflinger_device_connectors/devices/oemscript/oemscript.py b/testflinger-device-connectors/src/testflinger_device_connectors/devices/oemscript/oemscript.py similarity index 100% rename from src/testflinger_device_connectors/devices/oemscript/oemscript.py rename to testflinger-device-connectors/src/testflinger_device_connectors/devices/oemscript/oemscript.py diff --git a/src/tests/__init__.py b/testflinger-device-connectors/src/tests/__init__.py similarity index 100% rename from src/tests/__init__.py rename to testflinger-device-connectors/src/tests/__init__.py diff --git a/src/tests/test_snappy_device_agents.py b/testflinger-device-connectors/src/tests/test_snappy_device_agents.py similarity index 100% rename from src/tests/test_snappy_device_agents.py rename to testflinger-device-connectors/src/tests/test_snappy_device_agents.py diff --git a/tox.ini b/testflinger-device-connectors/tox.ini similarity index 100% rename from tox.ini rename to testflinger-device-connectors/tox.ini From 8cf1acf99a4ad0171d27e910c5b0ffce1edfc4e3 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 17:12:34 -0500 Subject: [PATCH 564/569] Move testflinger-device-connectors github workflows to monorepo location --- .../workflows/tox.yml => .github/workflows/device-tox.yml | 7 +++++++ 1 file changed, 7 insertions(+) rename testflinger-device-connectors/.github/workflows/tox.yml => .github/workflows/device-tox.yml (72%) diff --git a/testflinger-device-connectors/.github/workflows/tox.yml b/.github/workflows/device-tox.yml similarity index 72% rename from testflinger-device-connectors/.github/workflows/tox.yml rename to .github/workflows/device-tox.yml index 1d501837..ff8018c3 100644 --- a/testflinger-device-connectors/.github/workflows/tox.yml +++ b/.github/workflows/device-tox.yml @@ -3,11 +3,18 @@ name: Run unit tests on: push: branches: [ main ] + paths: + - testflinger-device-connectors/** pull_request: branches: [ main ] + paths: + - testflinger-device-connectors/** jobs: build: + defaults: + run: + working-directory: testflinger-device-connectors runs-on: ubuntu-latest strategy: matrix: From ccb4e86ced082cc7ee5ead1ed7c582631ca834ec Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 17:13:27 -0500 Subject: [PATCH 565/569] [monorepo] move testflinger-cli to /testflinger-cli --- {.github => testflinger-cli/.github}/workflows/tox.yml | 0 .gitignore => testflinger-cli/.gitignore | 0 .pylintrc => testflinger-cli/.pylintrc | 0 README.rst => testflinger-cli/README.rst | 0 pyproject.toml => testflinger-cli/pyproject.toml | 0 renovate.json => testflinger-cli/renovate.json | 0 setup.py => testflinger-cli/setup.py | 0 snapcraft.yaml => testflinger-cli/snapcraft.yaml | 0 testflinger-cli => testflinger-cli/testflinger-cli | 0 .../testflinger-cli.wrapper | 0 {testflinger_cli => testflinger-cli/testflinger_cli}/__init__.py | 0 {testflinger_cli => testflinger-cli/testflinger_cli}/client.py | 0 {testflinger_cli => testflinger-cli/testflinger_cli}/config.py | 0 {testflinger_cli => testflinger-cli/testflinger_cli}/history.py | 0 .../testflinger_cli}/tests/__init__.py | 0 .../testflinger_cli}/tests/test_cli.py | 0 tox.ini => testflinger-cli/tox.ini | 0 17 files changed, 0 insertions(+), 0 deletions(-) rename {.github => testflinger-cli/.github}/workflows/tox.yml (100%) rename .gitignore => testflinger-cli/.gitignore (100%) rename .pylintrc => testflinger-cli/.pylintrc (100%) rename README.rst => testflinger-cli/README.rst (100%) rename pyproject.toml => testflinger-cli/pyproject.toml (100%) rename renovate.json => testflinger-cli/renovate.json (100%) rename setup.py => testflinger-cli/setup.py (100%) rename snapcraft.yaml => testflinger-cli/snapcraft.yaml (100%) rename testflinger-cli => testflinger-cli/testflinger-cli (100%) rename testflinger-cli.wrapper => testflinger-cli/testflinger-cli.wrapper (100%) rename {testflinger_cli => testflinger-cli/testflinger_cli}/__init__.py (100%) rename {testflinger_cli => testflinger-cli/testflinger_cli}/client.py (100%) rename {testflinger_cli => testflinger-cli/testflinger_cli}/config.py (100%) rename {testflinger_cli => testflinger-cli/testflinger_cli}/history.py (100%) rename {testflinger_cli => testflinger-cli/testflinger_cli}/tests/__init__.py (100%) rename {testflinger_cli => testflinger-cli/testflinger_cli}/tests/test_cli.py (100%) rename tox.ini => testflinger-cli/tox.ini (100%) diff --git a/.github/workflows/tox.yml b/testflinger-cli/.github/workflows/tox.yml similarity index 100% rename from .github/workflows/tox.yml rename to testflinger-cli/.github/workflows/tox.yml diff --git a/.gitignore b/testflinger-cli/.gitignore similarity index 100% rename from .gitignore rename to testflinger-cli/.gitignore diff --git a/.pylintrc b/testflinger-cli/.pylintrc similarity index 100% rename from .pylintrc rename to testflinger-cli/.pylintrc diff --git a/README.rst b/testflinger-cli/README.rst similarity index 100% rename from README.rst rename to testflinger-cli/README.rst diff --git a/pyproject.toml b/testflinger-cli/pyproject.toml similarity index 100% rename from pyproject.toml rename to testflinger-cli/pyproject.toml diff --git a/renovate.json b/testflinger-cli/renovate.json similarity index 100% rename from renovate.json rename to testflinger-cli/renovate.json diff --git a/setup.py b/testflinger-cli/setup.py similarity index 100% rename from setup.py rename to testflinger-cli/setup.py diff --git a/snapcraft.yaml b/testflinger-cli/snapcraft.yaml similarity index 100% rename from snapcraft.yaml rename to testflinger-cli/snapcraft.yaml diff --git a/testflinger-cli b/testflinger-cli/testflinger-cli similarity index 100% rename from testflinger-cli rename to testflinger-cli/testflinger-cli diff --git a/testflinger-cli.wrapper b/testflinger-cli/testflinger-cli.wrapper similarity index 100% rename from testflinger-cli.wrapper rename to testflinger-cli/testflinger-cli.wrapper diff --git a/testflinger_cli/__init__.py b/testflinger-cli/testflinger_cli/__init__.py similarity index 100% rename from testflinger_cli/__init__.py rename to testflinger-cli/testflinger_cli/__init__.py diff --git a/testflinger_cli/client.py b/testflinger-cli/testflinger_cli/client.py similarity index 100% rename from testflinger_cli/client.py rename to testflinger-cli/testflinger_cli/client.py diff --git a/testflinger_cli/config.py b/testflinger-cli/testflinger_cli/config.py similarity index 100% rename from testflinger_cli/config.py rename to testflinger-cli/testflinger_cli/config.py diff --git a/testflinger_cli/history.py b/testflinger-cli/testflinger_cli/history.py similarity index 100% rename from testflinger_cli/history.py rename to testflinger-cli/testflinger_cli/history.py diff --git a/testflinger_cli/tests/__init__.py b/testflinger-cli/testflinger_cli/tests/__init__.py similarity index 100% rename from testflinger_cli/tests/__init__.py rename to testflinger-cli/testflinger_cli/tests/__init__.py diff --git a/testflinger_cli/tests/test_cli.py b/testflinger-cli/testflinger_cli/tests/test_cli.py similarity index 100% rename from testflinger_cli/tests/test_cli.py rename to testflinger-cli/testflinger_cli/tests/test_cli.py diff --git a/tox.ini b/testflinger-cli/tox.ini similarity index 100% rename from tox.ini rename to testflinger-cli/tox.ini From 63f210b5673a77e2385ab3d657ba5c27efa01f62 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Tue, 17 Oct 2023 17:15:52 -0500 Subject: [PATCH 566/569] Move testflinger-cli github workflows to monorepo location --- .../workflows/tox.yml => .github/workflows/cli-tox.yml | 7 +++++++ 1 file changed, 7 insertions(+) rename testflinger-cli/.github/workflows/tox.yml => .github/workflows/cli-tox.yml (76%) diff --git a/testflinger-cli/.github/workflows/tox.yml b/.github/workflows/cli-tox.yml similarity index 76% rename from testflinger-cli/.github/workflows/tox.yml rename to .github/workflows/cli-tox.yml index 329db888..802431dc 100644 --- a/testflinger-cli/.github/workflows/tox.yml +++ b/.github/workflows/cli-tox.yml @@ -3,11 +3,18 @@ name: Run unit tests on: push: branches: [ main ] + paths: + - testflinger-cli/** pull_request: branches: [ main ] + paths: + - testflinger-cli/** jobs: build: + defaults: + run: + working-directory: testflinger-cli runs-on: ubuntu-latest strategy: matrix: From c420dd062c365ecc83424283b127928f3b0b6a80 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Oct 2023 10:32:45 -0500 Subject: [PATCH 567/569] Strip testflinger- prefix from directory names --- {testflinger-agent => agent}/.gitignore | 0 {testflinger-agent => agent}/.pmr-merge-hook | 0 {testflinger-agent => agent}/README.rst | 0 {testflinger-agent => agent}/pyproject.toml | 0 {testflinger-agent => agent}/renovate.json | 0 {testflinger-agent => agent}/setup.cfg | 0 {testflinger-agent => agent}/setup.py | 0 {testflinger-agent => agent}/testflinger-agent.conf.example | 0 {testflinger-agent => agent}/testflinger_agent/__init__.py | 0 {testflinger-agent => agent}/testflinger_agent/agent.py | 0 {testflinger-agent => agent}/testflinger_agent/client.py | 0 {testflinger-agent => agent}/testflinger_agent/cmd.py | 0 {testflinger-agent => agent}/testflinger_agent/errors.py | 0 {testflinger-agent => agent}/testflinger_agent/job.py | 0 {testflinger-agent => agent}/testflinger_agent/schema.py | 0 {testflinger-agent => agent}/testflinger_agent/tests/__init__.py | 0 .../testflinger_agent/tests/test_agent.py | 0 .../testflinger_agent/tests/test_client.py | 0 .../testflinger_agent/tests/test_config.py | 0 {testflinger-agent => agent}/testflinger_agent/tests/test_job.py | 0 {testflinger-agent => agent}/tox.ini | 0 {testflinger-cli => cli}/.gitignore | 0 {testflinger-cli => cli}/.pylintrc | 0 {testflinger-cli => cli}/README.rst | 0 {testflinger-cli => cli}/pyproject.toml | 0 {testflinger-cli => cli}/renovate.json | 0 {testflinger-cli => cli}/setup.py | 0 {testflinger-cli => cli}/snapcraft.yaml | 0 {testflinger-cli => cli}/testflinger-cli | 0 {testflinger-cli => cli}/testflinger-cli.wrapper | 0 {testflinger-cli => cli}/testflinger_cli/__init__.py | 0 {testflinger-cli => cli}/testflinger_cli/client.py | 0 {testflinger-cli => cli}/testflinger_cli/config.py | 0 {testflinger-cli => cli}/testflinger_cli/history.py | 0 {testflinger-cli => cli}/testflinger_cli/tests/__init__.py | 0 {testflinger-cli => cli}/testflinger_cli/tests/test_cli.py | 0 {testflinger-cli => cli}/tox.ini | 0 {testflinger-device-connectors => device-connectors}/.gitignore | 0 .../.pmr-merge-hook | 0 {testflinger-device-connectors => device-connectors}/AUTHORS | 0 {testflinger-device-connectors => device-connectors}/COPYING | 0 .../README-pip-cache.rst | 0 {testflinger-device-connectors => device-connectors}/README.rst | 0 .../pyproject.toml | 0 .../renovate.json | 0 {testflinger-device-connectors => device-connectors}/setup.cfg | 0 {testflinger-device-connectors => device-connectors}/setup.py | 0 .../src/testflinger_device_connectors/__init__.py | 0 .../src/testflinger_device_connectors/cmd.py | 0 .../testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data | 0 .../testflinger_device_connectors/data/muxpi/classic/meta-data | 0 .../testflinger_device_connectors/data/muxpi/classic/user-data | 0 .../src/testflinger_device_connectors/data/muxpi/oemscript/README | 0 .../data/muxpi/oemscript/recovery-from-iso.sh | 0 .../data/muxpi/pi-desktop/oem-config.service | 0 .../data/muxpi/pi-desktop/preseed.cfg | 0 .../testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg | 0 .../data/pi-desktop/oem-config.service | 0 .../src/testflinger_device_connectors/data/pi-desktop/preseed.cfg | 0 .../src/testflinger_device_connectors/devices/__init__.py | 0 .../src/testflinger_device_connectors/devices/cm3/__init__.py | 0 .../src/testflinger_device_connectors/devices/cm3/cm3.py | 0 .../devices/dell_oemscript/__init__.py | 0 .../devices/dell_oemscript/dell_oemscript.py | 0 .../testflinger_device_connectors/devices/dragonboard/__init__.py | 0 .../devices/dragonboard/dragonboard.py | 0 .../devices/lenovo_oemscript/__init__.py | 0 .../devices/lenovo_oemscript/lenovo_oemscript.py | 0 .../src/testflinger_device_connectors/devices/maas2/__init__.py | 0 .../devices/maas2/doc/maas_storage.rst | 0 .../src/testflinger_device_connectors/devices/maas2/maas2.py | 0 .../testflinger_device_connectors/devices/maas2/maas_storage.py | 0 .../devices/maas2/tests/test_maas_storage.py | 0 .../src/testflinger_device_connectors/devices/multi/__init__.py | 0 .../src/testflinger_device_connectors/devices/multi/multi.py | 0 .../devices/multi/tests/test_multi.py | 0 .../src/testflinger_device_connectors/devices/multi/tfclient.py | 0 .../src/testflinger_device_connectors/devices/muxpi/__init__.py | 0 .../src/testflinger_device_connectors/devices/muxpi/muxpi.py | 0 .../devices/muxpi/tests/test_muxpi.py | 0 .../src/testflinger_device_connectors/devices/netboot/__init__.py | 0 .../src/testflinger_device_connectors/devices/netboot/netboot.py | 0 .../testflinger_device_connectors/devices/noprovision/__init__.py | 0 .../devices/noprovision/noprovision.py | 0 .../testflinger_device_connectors/devices/oemrecovery/__init__.py | 0 .../devices/oemrecovery/oemrecovery.py | 0 .../testflinger_device_connectors/devices/oemscript/__init__.py | 0 .../testflinger_device_connectors/devices/oemscript/oemscript.py | 0 .../src/tests/__init__.py | 0 .../src/tests/test_snappy_device_agents.py | 0 {testflinger-device-connectors => device-connectors}/tox.ini | 0 {testflinger-server => server}/.coveragerc | 0 {testflinger-server => server}/.gitignore | 0 {testflinger-server => server}/.pylintrc | 0 {testflinger-server => server}/COPYING | 0 {testflinger-server => server}/Dockerfile | 0 {testflinger-server => server}/HACKING.md | 0 {testflinger-server => server}/README.rst | 0 {testflinger-server => server}/charm/Makefile | 0 {testflinger-server => server}/charm/README.md | 0 {testflinger-server => server}/charm/charmcraft.yaml | 0 {testflinger-server => server}/charm/config.yaml | 0 .../charm/lib/charms/data_platform_libs/v0/data_interfaces.py | 0 .../charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py | 0 {testflinger-server => server}/charm/metadata.yaml | 0 {testflinger-server => server}/charm/requirements.txt | 0 {testflinger-server => server}/charm/src/charm.py | 0 {testflinger-server => server}/charm/tests/unit/test_charm.py | 0 {testflinger-server => server}/devel/docker-compose.override.yml | 0 {testflinger-server => server}/devel/testflinger.yaml | 0 {testflinger-server => server}/docker-compose.yml | 0 {testflinger-server => server}/extras/README.md | 0 {testflinger-server => server}/extras/devices/LVFS/LVFS.py | 0 .../extras/devices/LVFS/tests/fwupd_data.py | 0 .../extras/devices/LVFS/tests/test_LVFS.py | 0 {testflinger-server => server}/extras/devices/OEM/OEM.py | 0 {testflinger-server => server}/extras/devices/__init__.py | 0 {testflinger-server => server}/extras/devices/base.py | 0 {testflinger-server => server}/extras/dmi.py | 0 {testflinger-server => server}/extras/tests/test_upgrade_fw.py | 0 {testflinger-server => server}/extras/upgrade_fw.py | 0 {testflinger-server => server}/pyproject.toml | 0 {testflinger-server => server}/renovate.json | 0 {testflinger-server => server}/setup.py | 0 {testflinger-server => server}/src/__init__.py | 0 {testflinger-server => server}/src/api/__init__.py | 0 {testflinger-server => server}/src/api/schemas.py | 0 {testflinger-server => server}/src/api/v1.py | 0 {testflinger-server => server}/src/database.py | 0 .../src/static/assets/css/testflinger.css | 0 {testflinger-server => server}/src/static/assets/js/filter.js | 0 {testflinger-server => server}/src/templates/agent_detail.html | 0 {testflinger-server => server}/src/templates/agents.html | 0 {testflinger-server => server}/src/templates/base.html | 0 {testflinger-server => server}/src/templates/job_detail.html | 0 {testflinger-server => server}/src/templates/jobs.html | 0 {testflinger-server => server}/src/templates/queue_detail.html | 0 {testflinger-server => server}/src/templates/queues.html | 0 {testflinger-server => server}/src/views.py | 0 {testflinger-server => server}/terraform/README.md | 0 {testflinger-server => server}/terraform/main.tf | 0 {testflinger-server => server}/terraform/variables.tf | 0 {testflinger-server => server}/terraform/versions.tf | 0 {testflinger-server => server}/testflinger.conf.example | 0 {testflinger-server => server}/testflinger.env | 0 {testflinger-server => server}/testflinger.py | 0 {testflinger-server => server}/tests/__init__.py | 0 {testflinger-server => server}/tests/conftest.py | 0 {testflinger-server => server}/tests/test_app.py | 0 {testflinger-server => server}/tests/test_v1.py | 0 {testflinger-server => server}/tox.ini | 0 151 files changed, 0 insertions(+), 0 deletions(-) rename {testflinger-agent => agent}/.gitignore (100%) rename {testflinger-agent => agent}/.pmr-merge-hook (100%) rename {testflinger-agent => agent}/README.rst (100%) rename {testflinger-agent => agent}/pyproject.toml (100%) rename {testflinger-agent => agent}/renovate.json (100%) rename {testflinger-agent => agent}/setup.cfg (100%) rename {testflinger-agent => agent}/setup.py (100%) rename {testflinger-agent => agent}/testflinger-agent.conf.example (100%) rename {testflinger-agent => agent}/testflinger_agent/__init__.py (100%) rename {testflinger-agent => agent}/testflinger_agent/agent.py (100%) rename {testflinger-agent => agent}/testflinger_agent/client.py (100%) rename {testflinger-agent => agent}/testflinger_agent/cmd.py (100%) rename {testflinger-agent => agent}/testflinger_agent/errors.py (100%) rename {testflinger-agent => agent}/testflinger_agent/job.py (100%) rename {testflinger-agent => agent}/testflinger_agent/schema.py (100%) rename {testflinger-agent => agent}/testflinger_agent/tests/__init__.py (100%) rename {testflinger-agent => agent}/testflinger_agent/tests/test_agent.py (100%) rename {testflinger-agent => agent}/testflinger_agent/tests/test_client.py (100%) rename {testflinger-agent => agent}/testflinger_agent/tests/test_config.py (100%) rename {testflinger-agent => agent}/testflinger_agent/tests/test_job.py (100%) rename {testflinger-agent => agent}/tox.ini (100%) rename {testflinger-cli => cli}/.gitignore (100%) rename {testflinger-cli => cli}/.pylintrc (100%) rename {testflinger-cli => cli}/README.rst (100%) rename {testflinger-cli => cli}/pyproject.toml (100%) rename {testflinger-cli => cli}/renovate.json (100%) rename {testflinger-cli => cli}/setup.py (100%) rename {testflinger-cli => cli}/snapcraft.yaml (100%) rename {testflinger-cli => cli}/testflinger-cli (100%) rename {testflinger-cli => cli}/testflinger-cli.wrapper (100%) rename {testflinger-cli => cli}/testflinger_cli/__init__.py (100%) rename {testflinger-cli => cli}/testflinger_cli/client.py (100%) rename {testflinger-cli => cli}/testflinger_cli/config.py (100%) rename {testflinger-cli => cli}/testflinger_cli/history.py (100%) rename {testflinger-cli => cli}/testflinger_cli/tests/__init__.py (100%) rename {testflinger-cli => cli}/testflinger_cli/tests/test_cli.py (100%) rename {testflinger-cli => cli}/tox.ini (100%) rename {testflinger-device-connectors => device-connectors}/.gitignore (100%) rename {testflinger-device-connectors => device-connectors}/.pmr-merge-hook (100%) rename {testflinger-device-connectors => device-connectors}/AUTHORS (100%) rename {testflinger-device-connectors => device-connectors}/COPYING (100%) rename {testflinger-device-connectors => device-connectors}/README-pip-cache.rst (100%) rename {testflinger-device-connectors => device-connectors}/README.rst (100%) rename {testflinger-device-connectors => device-connectors}/pyproject.toml (100%) rename {testflinger-device-connectors => device-connectors}/renovate.json (100%) rename {testflinger-device-connectors => device-connectors}/setup.cfg (100%) rename {testflinger-device-connectors => device-connectors}/setup.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/cmd.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/muxpi/classic/meta-data (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/muxpi/classic/user-data (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/muxpi/oemscript/README (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/pi-desktop/oem-config.service (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/cm3/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/cm3/cm3.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/dragonboard/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/maas2/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/maas2/maas2.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/maas2/maas_storage.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/multi/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/multi/multi.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/multi/tests/test_multi.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/multi/tfclient.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/muxpi/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/muxpi/muxpi.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/netboot/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/netboot/netboot.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/noprovision/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/noprovision/noprovision.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/oemrecovery/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/oemscript/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/testflinger_device_connectors/devices/oemscript/oemscript.py (100%) rename {testflinger-device-connectors => device-connectors}/src/tests/__init__.py (100%) rename {testflinger-device-connectors => device-connectors}/src/tests/test_snappy_device_agents.py (100%) rename {testflinger-device-connectors => device-connectors}/tox.ini (100%) rename {testflinger-server => server}/.coveragerc (100%) rename {testflinger-server => server}/.gitignore (100%) rename {testflinger-server => server}/.pylintrc (100%) rename {testflinger-server => server}/COPYING (100%) rename {testflinger-server => server}/Dockerfile (100%) rename {testflinger-server => server}/HACKING.md (100%) rename {testflinger-server => server}/README.rst (100%) rename {testflinger-server => server}/charm/Makefile (100%) rename {testflinger-server => server}/charm/README.md (100%) rename {testflinger-server => server}/charm/charmcraft.yaml (100%) rename {testflinger-server => server}/charm/config.yaml (100%) rename {testflinger-server => server}/charm/lib/charms/data_platform_libs/v0/data_interfaces.py (100%) rename {testflinger-server => server}/charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py (100%) rename {testflinger-server => server}/charm/metadata.yaml (100%) rename {testflinger-server => server}/charm/requirements.txt (100%) rename {testflinger-server => server}/charm/src/charm.py (100%) rename {testflinger-server => server}/charm/tests/unit/test_charm.py (100%) rename {testflinger-server => server}/devel/docker-compose.override.yml (100%) rename {testflinger-server => server}/devel/testflinger.yaml (100%) rename {testflinger-server => server}/docker-compose.yml (100%) rename {testflinger-server => server}/extras/README.md (100%) rename {testflinger-server => server}/extras/devices/LVFS/LVFS.py (100%) rename {testflinger-server => server}/extras/devices/LVFS/tests/fwupd_data.py (100%) rename {testflinger-server => server}/extras/devices/LVFS/tests/test_LVFS.py (100%) rename {testflinger-server => server}/extras/devices/OEM/OEM.py (100%) rename {testflinger-server => server}/extras/devices/__init__.py (100%) rename {testflinger-server => server}/extras/devices/base.py (100%) rename {testflinger-server => server}/extras/dmi.py (100%) rename {testflinger-server => server}/extras/tests/test_upgrade_fw.py (100%) rename {testflinger-server => server}/extras/upgrade_fw.py (100%) rename {testflinger-server => server}/pyproject.toml (100%) rename {testflinger-server => server}/renovate.json (100%) rename {testflinger-server => server}/setup.py (100%) rename {testflinger-server => server}/src/__init__.py (100%) rename {testflinger-server => server}/src/api/__init__.py (100%) rename {testflinger-server => server}/src/api/schemas.py (100%) rename {testflinger-server => server}/src/api/v1.py (100%) rename {testflinger-server => server}/src/database.py (100%) rename {testflinger-server => server}/src/static/assets/css/testflinger.css (100%) rename {testflinger-server => server}/src/static/assets/js/filter.js (100%) rename {testflinger-server => server}/src/templates/agent_detail.html (100%) rename {testflinger-server => server}/src/templates/agents.html (100%) rename {testflinger-server => server}/src/templates/base.html (100%) rename {testflinger-server => server}/src/templates/job_detail.html (100%) rename {testflinger-server => server}/src/templates/jobs.html (100%) rename {testflinger-server => server}/src/templates/queue_detail.html (100%) rename {testflinger-server => server}/src/templates/queues.html (100%) rename {testflinger-server => server}/src/views.py (100%) rename {testflinger-server => server}/terraform/README.md (100%) rename {testflinger-server => server}/terraform/main.tf (100%) rename {testflinger-server => server}/terraform/variables.tf (100%) rename {testflinger-server => server}/terraform/versions.tf (100%) rename {testflinger-server => server}/testflinger.conf.example (100%) rename {testflinger-server => server}/testflinger.env (100%) rename {testflinger-server => server}/testflinger.py (100%) rename {testflinger-server => server}/tests/__init__.py (100%) rename {testflinger-server => server}/tests/conftest.py (100%) rename {testflinger-server => server}/tests/test_app.py (100%) rename {testflinger-server => server}/tests/test_v1.py (100%) rename {testflinger-server => server}/tox.ini (100%) diff --git a/testflinger-agent/.gitignore b/agent/.gitignore similarity index 100% rename from testflinger-agent/.gitignore rename to agent/.gitignore diff --git a/testflinger-agent/.pmr-merge-hook b/agent/.pmr-merge-hook similarity index 100% rename from testflinger-agent/.pmr-merge-hook rename to agent/.pmr-merge-hook diff --git a/testflinger-agent/README.rst b/agent/README.rst similarity index 100% rename from testflinger-agent/README.rst rename to agent/README.rst diff --git a/testflinger-agent/pyproject.toml b/agent/pyproject.toml similarity index 100% rename from testflinger-agent/pyproject.toml rename to agent/pyproject.toml diff --git a/testflinger-agent/renovate.json b/agent/renovate.json similarity index 100% rename from testflinger-agent/renovate.json rename to agent/renovate.json diff --git a/testflinger-agent/setup.cfg b/agent/setup.cfg similarity index 100% rename from testflinger-agent/setup.cfg rename to agent/setup.cfg diff --git a/testflinger-agent/setup.py b/agent/setup.py similarity index 100% rename from testflinger-agent/setup.py rename to agent/setup.py diff --git a/testflinger-agent/testflinger-agent.conf.example b/agent/testflinger-agent.conf.example similarity index 100% rename from testflinger-agent/testflinger-agent.conf.example rename to agent/testflinger-agent.conf.example diff --git a/testflinger-agent/testflinger_agent/__init__.py b/agent/testflinger_agent/__init__.py similarity index 100% rename from testflinger-agent/testflinger_agent/__init__.py rename to agent/testflinger_agent/__init__.py diff --git a/testflinger-agent/testflinger_agent/agent.py b/agent/testflinger_agent/agent.py similarity index 100% rename from testflinger-agent/testflinger_agent/agent.py rename to agent/testflinger_agent/agent.py diff --git a/testflinger-agent/testflinger_agent/client.py b/agent/testflinger_agent/client.py similarity index 100% rename from testflinger-agent/testflinger_agent/client.py rename to agent/testflinger_agent/client.py diff --git a/testflinger-agent/testflinger_agent/cmd.py b/agent/testflinger_agent/cmd.py similarity index 100% rename from testflinger-agent/testflinger_agent/cmd.py rename to agent/testflinger_agent/cmd.py diff --git a/testflinger-agent/testflinger_agent/errors.py b/agent/testflinger_agent/errors.py similarity index 100% rename from testflinger-agent/testflinger_agent/errors.py rename to agent/testflinger_agent/errors.py diff --git a/testflinger-agent/testflinger_agent/job.py b/agent/testflinger_agent/job.py similarity index 100% rename from testflinger-agent/testflinger_agent/job.py rename to agent/testflinger_agent/job.py diff --git a/testflinger-agent/testflinger_agent/schema.py b/agent/testflinger_agent/schema.py similarity index 100% rename from testflinger-agent/testflinger_agent/schema.py rename to agent/testflinger_agent/schema.py diff --git a/testflinger-agent/testflinger_agent/tests/__init__.py b/agent/testflinger_agent/tests/__init__.py similarity index 100% rename from testflinger-agent/testflinger_agent/tests/__init__.py rename to agent/testflinger_agent/tests/__init__.py diff --git a/testflinger-agent/testflinger_agent/tests/test_agent.py b/agent/testflinger_agent/tests/test_agent.py similarity index 100% rename from testflinger-agent/testflinger_agent/tests/test_agent.py rename to agent/testflinger_agent/tests/test_agent.py diff --git a/testflinger-agent/testflinger_agent/tests/test_client.py b/agent/testflinger_agent/tests/test_client.py similarity index 100% rename from testflinger-agent/testflinger_agent/tests/test_client.py rename to agent/testflinger_agent/tests/test_client.py diff --git a/testflinger-agent/testflinger_agent/tests/test_config.py b/agent/testflinger_agent/tests/test_config.py similarity index 100% rename from testflinger-agent/testflinger_agent/tests/test_config.py rename to agent/testflinger_agent/tests/test_config.py diff --git a/testflinger-agent/testflinger_agent/tests/test_job.py b/agent/testflinger_agent/tests/test_job.py similarity index 100% rename from testflinger-agent/testflinger_agent/tests/test_job.py rename to agent/testflinger_agent/tests/test_job.py diff --git a/testflinger-agent/tox.ini b/agent/tox.ini similarity index 100% rename from testflinger-agent/tox.ini rename to agent/tox.ini diff --git a/testflinger-cli/.gitignore b/cli/.gitignore similarity index 100% rename from testflinger-cli/.gitignore rename to cli/.gitignore diff --git a/testflinger-cli/.pylintrc b/cli/.pylintrc similarity index 100% rename from testflinger-cli/.pylintrc rename to cli/.pylintrc diff --git a/testflinger-cli/README.rst b/cli/README.rst similarity index 100% rename from testflinger-cli/README.rst rename to cli/README.rst diff --git a/testflinger-cli/pyproject.toml b/cli/pyproject.toml similarity index 100% rename from testflinger-cli/pyproject.toml rename to cli/pyproject.toml diff --git a/testflinger-cli/renovate.json b/cli/renovate.json similarity index 100% rename from testflinger-cli/renovate.json rename to cli/renovate.json diff --git a/testflinger-cli/setup.py b/cli/setup.py similarity index 100% rename from testflinger-cli/setup.py rename to cli/setup.py diff --git a/testflinger-cli/snapcraft.yaml b/cli/snapcraft.yaml similarity index 100% rename from testflinger-cli/snapcraft.yaml rename to cli/snapcraft.yaml diff --git a/testflinger-cli/testflinger-cli b/cli/testflinger-cli similarity index 100% rename from testflinger-cli/testflinger-cli rename to cli/testflinger-cli diff --git a/testflinger-cli/testflinger-cli.wrapper b/cli/testflinger-cli.wrapper similarity index 100% rename from testflinger-cli/testflinger-cli.wrapper rename to cli/testflinger-cli.wrapper diff --git a/testflinger-cli/testflinger_cli/__init__.py b/cli/testflinger_cli/__init__.py similarity index 100% rename from testflinger-cli/testflinger_cli/__init__.py rename to cli/testflinger_cli/__init__.py diff --git a/testflinger-cli/testflinger_cli/client.py b/cli/testflinger_cli/client.py similarity index 100% rename from testflinger-cli/testflinger_cli/client.py rename to cli/testflinger_cli/client.py diff --git a/testflinger-cli/testflinger_cli/config.py b/cli/testflinger_cli/config.py similarity index 100% rename from testflinger-cli/testflinger_cli/config.py rename to cli/testflinger_cli/config.py diff --git a/testflinger-cli/testflinger_cli/history.py b/cli/testflinger_cli/history.py similarity index 100% rename from testflinger-cli/testflinger_cli/history.py rename to cli/testflinger_cli/history.py diff --git a/testflinger-cli/testflinger_cli/tests/__init__.py b/cli/testflinger_cli/tests/__init__.py similarity index 100% rename from testflinger-cli/testflinger_cli/tests/__init__.py rename to cli/testflinger_cli/tests/__init__.py diff --git a/testflinger-cli/testflinger_cli/tests/test_cli.py b/cli/testflinger_cli/tests/test_cli.py similarity index 100% rename from testflinger-cli/testflinger_cli/tests/test_cli.py rename to cli/testflinger_cli/tests/test_cli.py diff --git a/testflinger-cli/tox.ini b/cli/tox.ini similarity index 100% rename from testflinger-cli/tox.ini rename to cli/tox.ini diff --git a/testflinger-device-connectors/.gitignore b/device-connectors/.gitignore similarity index 100% rename from testflinger-device-connectors/.gitignore rename to device-connectors/.gitignore diff --git a/testflinger-device-connectors/.pmr-merge-hook b/device-connectors/.pmr-merge-hook similarity index 100% rename from testflinger-device-connectors/.pmr-merge-hook rename to device-connectors/.pmr-merge-hook diff --git a/testflinger-device-connectors/AUTHORS b/device-connectors/AUTHORS similarity index 100% rename from testflinger-device-connectors/AUTHORS rename to device-connectors/AUTHORS diff --git a/testflinger-device-connectors/COPYING b/device-connectors/COPYING similarity index 100% rename from testflinger-device-connectors/COPYING rename to device-connectors/COPYING diff --git a/testflinger-device-connectors/README-pip-cache.rst b/device-connectors/README-pip-cache.rst similarity index 100% rename from testflinger-device-connectors/README-pip-cache.rst rename to device-connectors/README-pip-cache.rst diff --git a/testflinger-device-connectors/README.rst b/device-connectors/README.rst similarity index 100% rename from testflinger-device-connectors/README.rst rename to device-connectors/README.rst diff --git a/testflinger-device-connectors/pyproject.toml b/device-connectors/pyproject.toml similarity index 100% rename from testflinger-device-connectors/pyproject.toml rename to device-connectors/pyproject.toml diff --git a/testflinger-device-connectors/renovate.json b/device-connectors/renovate.json similarity index 100% rename from testflinger-device-connectors/renovate.json rename to device-connectors/renovate.json diff --git a/testflinger-device-connectors/setup.cfg b/device-connectors/setup.cfg similarity index 100% rename from testflinger-device-connectors/setup.cfg rename to device-connectors/setup.cfg diff --git a/testflinger-device-connectors/setup.py b/device-connectors/setup.py similarity index 100% rename from testflinger-device-connectors/setup.py rename to device-connectors/setup.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/__init__.py b/device-connectors/src/testflinger_device_connectors/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/__init__.py rename to device-connectors/src/testflinger_device_connectors/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/cmd.py b/device-connectors/src/testflinger_device_connectors/cmd.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/cmd.py rename to device-connectors/src/testflinger_device_connectors/cmd.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data b/device-connectors/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data rename to device-connectors/src/testflinger_device_connectors/data/muxpi/ce-oem-iot/user-data diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/classic/meta-data b/device-connectors/src/testflinger_device_connectors/data/muxpi/classic/meta-data similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/classic/meta-data rename to device-connectors/src/testflinger_device_connectors/data/muxpi/classic/meta-data diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/classic/user-data b/device-connectors/src/testflinger_device_connectors/data/muxpi/classic/user-data similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/classic/user-data rename to device-connectors/src/testflinger_device_connectors/data/muxpi/classic/user-data diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/README b/device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/README similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/README rename to device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/README diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh b/device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh rename to device-connectors/src/testflinger_device_connectors/data/muxpi/oemscript/recovery-from-iso.sh diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service b/device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service rename to device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/oem-config.service diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg b/device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg rename to device-connectors/src/testflinger_device_connectors/data/muxpi/pi-desktop/preseed.cfg diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg b/device-connectors/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg rename to device-connectors/src/testflinger_device_connectors/data/muxpi/uc20/99_nocloud.cfg diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/pi-desktop/oem-config.service b/device-connectors/src/testflinger_device_connectors/data/pi-desktop/oem-config.service similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/pi-desktop/oem-config.service rename to device-connectors/src/testflinger_device_connectors/data/pi-desktop/oem-config.service diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg b/device-connectors/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg rename to device-connectors/src/testflinger_device_connectors/data/pi-desktop/preseed.cfg diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/cm3/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/cm3/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/cm3/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/cm3/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/cm3/cm3.py b/device-connectors/src/testflinger_device_connectors/devices/cm3/cm3.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/cm3/cm3.py rename to device-connectors/src/testflinger_device_connectors/devices/cm3/cm3.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py b/device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py rename to device-connectors/src/testflinger_device_connectors/devices/dell_oemscript/dell_oemscript.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/dragonboard/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/dragonboard/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/dragonboard/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/dragonboard/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py b/device-connectors/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py rename to device-connectors/src/testflinger_device_connectors/devices/dragonboard/dragonboard.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py b/device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py rename to device-connectors/src/testflinger_device_connectors/devices/lenovo_oemscript/lenovo_oemscript.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/maas2/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/maas2/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst b/device-connectors/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst rename to device-connectors/src/testflinger_device_connectors/devices/maas2/doc/maas_storage.rst diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/maas2.py b/device-connectors/src/testflinger_device_connectors/devices/maas2/maas2.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/maas2.py rename to device-connectors/src/testflinger_device_connectors/devices/maas2/maas2.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/maas_storage.py b/device-connectors/src/testflinger_device_connectors/devices/maas2/maas_storage.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/maas_storage.py rename to device-connectors/src/testflinger_device_connectors/devices/maas2/maas_storage.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py b/device-connectors/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py rename to device-connectors/src/testflinger_device_connectors/devices/maas2/tests/test_maas_storage.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/multi/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/multi/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/multi.py b/device-connectors/src/testflinger_device_connectors/devices/multi/multi.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/multi.py rename to device-connectors/src/testflinger_device_connectors/devices/multi/multi.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py b/device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py rename to device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py b/device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py rename to device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/muxpi/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/muxpi/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/muxpi.py b/device-connectors/src/testflinger_device_connectors/devices/muxpi/muxpi.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/muxpi.py rename to device-connectors/src/testflinger_device_connectors/devices/muxpi/muxpi.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py b/device-connectors/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py rename to device-connectors/src/testflinger_device_connectors/devices/muxpi/tests/test_muxpi.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/netboot/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/netboot/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/netboot/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/netboot/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/netboot/netboot.py b/device-connectors/src/testflinger_device_connectors/devices/netboot/netboot.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/netboot/netboot.py rename to device-connectors/src/testflinger_device_connectors/devices/netboot/netboot.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/noprovision/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/noprovision/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/noprovision/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/noprovision/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/noprovision/noprovision.py b/device-connectors/src/testflinger_device_connectors/devices/noprovision/noprovision.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/noprovision/noprovision.py rename to device-connectors/src/testflinger_device_connectors/devices/noprovision/noprovision.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/oemrecovery/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/oemrecovery/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/oemrecovery/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/oemrecovery/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py b/device-connectors/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py rename to device-connectors/src/testflinger_device_connectors/devices/oemrecovery/oemrecovery.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/oemscript/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/oemscript/__init__.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/oemscript/__init__.py rename to device-connectors/src/testflinger_device_connectors/devices/oemscript/__init__.py diff --git a/testflinger-device-connectors/src/testflinger_device_connectors/devices/oemscript/oemscript.py b/device-connectors/src/testflinger_device_connectors/devices/oemscript/oemscript.py similarity index 100% rename from testflinger-device-connectors/src/testflinger_device_connectors/devices/oemscript/oemscript.py rename to device-connectors/src/testflinger_device_connectors/devices/oemscript/oemscript.py diff --git a/testflinger-device-connectors/src/tests/__init__.py b/device-connectors/src/tests/__init__.py similarity index 100% rename from testflinger-device-connectors/src/tests/__init__.py rename to device-connectors/src/tests/__init__.py diff --git a/testflinger-device-connectors/src/tests/test_snappy_device_agents.py b/device-connectors/src/tests/test_snappy_device_agents.py similarity index 100% rename from testflinger-device-connectors/src/tests/test_snappy_device_agents.py rename to device-connectors/src/tests/test_snappy_device_agents.py diff --git a/testflinger-device-connectors/tox.ini b/device-connectors/tox.ini similarity index 100% rename from testflinger-device-connectors/tox.ini rename to device-connectors/tox.ini diff --git a/testflinger-server/.coveragerc b/server/.coveragerc similarity index 100% rename from testflinger-server/.coveragerc rename to server/.coveragerc diff --git a/testflinger-server/.gitignore b/server/.gitignore similarity index 100% rename from testflinger-server/.gitignore rename to server/.gitignore diff --git a/testflinger-server/.pylintrc b/server/.pylintrc similarity index 100% rename from testflinger-server/.pylintrc rename to server/.pylintrc diff --git a/testflinger-server/COPYING b/server/COPYING similarity index 100% rename from testflinger-server/COPYING rename to server/COPYING diff --git a/testflinger-server/Dockerfile b/server/Dockerfile similarity index 100% rename from testflinger-server/Dockerfile rename to server/Dockerfile diff --git a/testflinger-server/HACKING.md b/server/HACKING.md similarity index 100% rename from testflinger-server/HACKING.md rename to server/HACKING.md diff --git a/testflinger-server/README.rst b/server/README.rst similarity index 100% rename from testflinger-server/README.rst rename to server/README.rst diff --git a/testflinger-server/charm/Makefile b/server/charm/Makefile similarity index 100% rename from testflinger-server/charm/Makefile rename to server/charm/Makefile diff --git a/testflinger-server/charm/README.md b/server/charm/README.md similarity index 100% rename from testflinger-server/charm/README.md rename to server/charm/README.md diff --git a/testflinger-server/charm/charmcraft.yaml b/server/charm/charmcraft.yaml similarity index 100% rename from testflinger-server/charm/charmcraft.yaml rename to server/charm/charmcraft.yaml diff --git a/testflinger-server/charm/config.yaml b/server/charm/config.yaml similarity index 100% rename from testflinger-server/charm/config.yaml rename to server/charm/config.yaml diff --git a/testflinger-server/charm/lib/charms/data_platform_libs/v0/data_interfaces.py b/server/charm/lib/charms/data_platform_libs/v0/data_interfaces.py similarity index 100% rename from testflinger-server/charm/lib/charms/data_platform_libs/v0/data_interfaces.py rename to server/charm/lib/charms/data_platform_libs/v0/data_interfaces.py diff --git a/testflinger-server/charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py b/server/charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py similarity index 100% rename from testflinger-server/charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py rename to server/charm/lib/charms/nginx_ingress_integrator/v0/nginx_route.py diff --git a/testflinger-server/charm/metadata.yaml b/server/charm/metadata.yaml similarity index 100% rename from testflinger-server/charm/metadata.yaml rename to server/charm/metadata.yaml diff --git a/testflinger-server/charm/requirements.txt b/server/charm/requirements.txt similarity index 100% rename from testflinger-server/charm/requirements.txt rename to server/charm/requirements.txt diff --git a/testflinger-server/charm/src/charm.py b/server/charm/src/charm.py similarity index 100% rename from testflinger-server/charm/src/charm.py rename to server/charm/src/charm.py diff --git a/testflinger-server/charm/tests/unit/test_charm.py b/server/charm/tests/unit/test_charm.py similarity index 100% rename from testflinger-server/charm/tests/unit/test_charm.py rename to server/charm/tests/unit/test_charm.py diff --git a/testflinger-server/devel/docker-compose.override.yml b/server/devel/docker-compose.override.yml similarity index 100% rename from testflinger-server/devel/docker-compose.override.yml rename to server/devel/docker-compose.override.yml diff --git a/testflinger-server/devel/testflinger.yaml b/server/devel/testflinger.yaml similarity index 100% rename from testflinger-server/devel/testflinger.yaml rename to server/devel/testflinger.yaml diff --git a/testflinger-server/docker-compose.yml b/server/docker-compose.yml similarity index 100% rename from testflinger-server/docker-compose.yml rename to server/docker-compose.yml diff --git a/testflinger-server/extras/README.md b/server/extras/README.md similarity index 100% rename from testflinger-server/extras/README.md rename to server/extras/README.md diff --git a/testflinger-server/extras/devices/LVFS/LVFS.py b/server/extras/devices/LVFS/LVFS.py similarity index 100% rename from testflinger-server/extras/devices/LVFS/LVFS.py rename to server/extras/devices/LVFS/LVFS.py diff --git a/testflinger-server/extras/devices/LVFS/tests/fwupd_data.py b/server/extras/devices/LVFS/tests/fwupd_data.py similarity index 100% rename from testflinger-server/extras/devices/LVFS/tests/fwupd_data.py rename to server/extras/devices/LVFS/tests/fwupd_data.py diff --git a/testflinger-server/extras/devices/LVFS/tests/test_LVFS.py b/server/extras/devices/LVFS/tests/test_LVFS.py similarity index 100% rename from testflinger-server/extras/devices/LVFS/tests/test_LVFS.py rename to server/extras/devices/LVFS/tests/test_LVFS.py diff --git a/testflinger-server/extras/devices/OEM/OEM.py b/server/extras/devices/OEM/OEM.py similarity index 100% rename from testflinger-server/extras/devices/OEM/OEM.py rename to server/extras/devices/OEM/OEM.py diff --git a/testflinger-server/extras/devices/__init__.py b/server/extras/devices/__init__.py similarity index 100% rename from testflinger-server/extras/devices/__init__.py rename to server/extras/devices/__init__.py diff --git a/testflinger-server/extras/devices/base.py b/server/extras/devices/base.py similarity index 100% rename from testflinger-server/extras/devices/base.py rename to server/extras/devices/base.py diff --git a/testflinger-server/extras/dmi.py b/server/extras/dmi.py similarity index 100% rename from testflinger-server/extras/dmi.py rename to server/extras/dmi.py diff --git a/testflinger-server/extras/tests/test_upgrade_fw.py b/server/extras/tests/test_upgrade_fw.py similarity index 100% rename from testflinger-server/extras/tests/test_upgrade_fw.py rename to server/extras/tests/test_upgrade_fw.py diff --git a/testflinger-server/extras/upgrade_fw.py b/server/extras/upgrade_fw.py similarity index 100% rename from testflinger-server/extras/upgrade_fw.py rename to server/extras/upgrade_fw.py diff --git a/testflinger-server/pyproject.toml b/server/pyproject.toml similarity index 100% rename from testflinger-server/pyproject.toml rename to server/pyproject.toml diff --git a/testflinger-server/renovate.json b/server/renovate.json similarity index 100% rename from testflinger-server/renovate.json rename to server/renovate.json diff --git a/testflinger-server/setup.py b/server/setup.py similarity index 100% rename from testflinger-server/setup.py rename to server/setup.py diff --git a/testflinger-server/src/__init__.py b/server/src/__init__.py similarity index 100% rename from testflinger-server/src/__init__.py rename to server/src/__init__.py diff --git a/testflinger-server/src/api/__init__.py b/server/src/api/__init__.py similarity index 100% rename from testflinger-server/src/api/__init__.py rename to server/src/api/__init__.py diff --git a/testflinger-server/src/api/schemas.py b/server/src/api/schemas.py similarity index 100% rename from testflinger-server/src/api/schemas.py rename to server/src/api/schemas.py diff --git a/testflinger-server/src/api/v1.py b/server/src/api/v1.py similarity index 100% rename from testflinger-server/src/api/v1.py rename to server/src/api/v1.py diff --git a/testflinger-server/src/database.py b/server/src/database.py similarity index 100% rename from testflinger-server/src/database.py rename to server/src/database.py diff --git a/testflinger-server/src/static/assets/css/testflinger.css b/server/src/static/assets/css/testflinger.css similarity index 100% rename from testflinger-server/src/static/assets/css/testflinger.css rename to server/src/static/assets/css/testflinger.css diff --git a/testflinger-server/src/static/assets/js/filter.js b/server/src/static/assets/js/filter.js similarity index 100% rename from testflinger-server/src/static/assets/js/filter.js rename to server/src/static/assets/js/filter.js diff --git a/testflinger-server/src/templates/agent_detail.html b/server/src/templates/agent_detail.html similarity index 100% rename from testflinger-server/src/templates/agent_detail.html rename to server/src/templates/agent_detail.html diff --git a/testflinger-server/src/templates/agents.html b/server/src/templates/agents.html similarity index 100% rename from testflinger-server/src/templates/agents.html rename to server/src/templates/agents.html diff --git a/testflinger-server/src/templates/base.html b/server/src/templates/base.html similarity index 100% rename from testflinger-server/src/templates/base.html rename to server/src/templates/base.html diff --git a/testflinger-server/src/templates/job_detail.html b/server/src/templates/job_detail.html similarity index 100% rename from testflinger-server/src/templates/job_detail.html rename to server/src/templates/job_detail.html diff --git a/testflinger-server/src/templates/jobs.html b/server/src/templates/jobs.html similarity index 100% rename from testflinger-server/src/templates/jobs.html rename to server/src/templates/jobs.html diff --git a/testflinger-server/src/templates/queue_detail.html b/server/src/templates/queue_detail.html similarity index 100% rename from testflinger-server/src/templates/queue_detail.html rename to server/src/templates/queue_detail.html diff --git a/testflinger-server/src/templates/queues.html b/server/src/templates/queues.html similarity index 100% rename from testflinger-server/src/templates/queues.html rename to server/src/templates/queues.html diff --git a/testflinger-server/src/views.py b/server/src/views.py similarity index 100% rename from testflinger-server/src/views.py rename to server/src/views.py diff --git a/testflinger-server/terraform/README.md b/server/terraform/README.md similarity index 100% rename from testflinger-server/terraform/README.md rename to server/terraform/README.md diff --git a/testflinger-server/terraform/main.tf b/server/terraform/main.tf similarity index 100% rename from testflinger-server/terraform/main.tf rename to server/terraform/main.tf diff --git a/testflinger-server/terraform/variables.tf b/server/terraform/variables.tf similarity index 100% rename from testflinger-server/terraform/variables.tf rename to server/terraform/variables.tf diff --git a/testflinger-server/terraform/versions.tf b/server/terraform/versions.tf similarity index 100% rename from testflinger-server/terraform/versions.tf rename to server/terraform/versions.tf diff --git a/testflinger-server/testflinger.conf.example b/server/testflinger.conf.example similarity index 100% rename from testflinger-server/testflinger.conf.example rename to server/testflinger.conf.example diff --git a/testflinger-server/testflinger.env b/server/testflinger.env similarity index 100% rename from testflinger-server/testflinger.env rename to server/testflinger.env diff --git a/testflinger-server/testflinger.py b/server/testflinger.py similarity index 100% rename from testflinger-server/testflinger.py rename to server/testflinger.py diff --git a/testflinger-server/tests/__init__.py b/server/tests/__init__.py similarity index 100% rename from testflinger-server/tests/__init__.py rename to server/tests/__init__.py diff --git a/testflinger-server/tests/conftest.py b/server/tests/conftest.py similarity index 100% rename from testflinger-server/tests/conftest.py rename to server/tests/conftest.py diff --git a/testflinger-server/tests/test_app.py b/server/tests/test_app.py similarity index 100% rename from testflinger-server/tests/test_app.py rename to server/tests/test_app.py diff --git a/testflinger-server/tests/test_v1.py b/server/tests/test_v1.py similarity index 100% rename from testflinger-server/tests/test_v1.py rename to server/tests/test_v1.py diff --git a/testflinger-server/tox.ini b/server/tox.ini similarity index 100% rename from testflinger-server/tox.ini rename to server/tox.ini From 86697fc3aca58d5a7a26531cc3114bb015d0feed Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Oct 2023 10:39:17 -0500 Subject: [PATCH 568/569] Update github workflows for the new directory structure --- .github/workflows/agent-tox.yml | 6 +++--- .github/workflows/cli-tox.yml | 6 +++--- .github/workflows/device-tox.yml | 6 +++--- .github/workflows/server-charm-check-libs.yml | 4 ++-- .github/workflows/server-charm-release-edge.yml | 4 ++-- .github/workflows/server-publish-oci-image.yml | 4 ++-- .github/workflows/server-tox.yml | 6 +++--- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/agent-tox.yml b/.github/workflows/agent-tox.yml index aa22580f..e88960bf 100644 --- a/.github/workflows/agent-tox.yml +++ b/.github/workflows/agent-tox.yml @@ -4,17 +4,17 @@ on: push: branches: [ main ] paths: - - testflinger-agent/** + - agent/** pull_request: branches: [ main ] paths: - - testflinger-agent/** + - agent/** jobs: build: defaults: run: - working-directory: testflinger-agent + working-directory: agent runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/cli-tox.yml b/.github/workflows/cli-tox.yml index 802431dc..fc003d61 100644 --- a/.github/workflows/cli-tox.yml +++ b/.github/workflows/cli-tox.yml @@ -4,17 +4,17 @@ on: push: branches: [ main ] paths: - - testflinger-cli/** + - cli/** pull_request: branches: [ main ] paths: - - testflinger-cli/** + - cli/** jobs: build: defaults: run: - working-directory: testflinger-cli + working-directory: cli runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/device-tox.yml b/.github/workflows/device-tox.yml index ff8018c3..5216f39b 100644 --- a/.github/workflows/device-tox.yml +++ b/.github/workflows/device-tox.yml @@ -4,17 +4,17 @@ on: push: branches: [ main ] paths: - - testflinger-device-connectors/** + - device-connectors/** pull_request: branches: [ main ] paths: - - testflinger-device-connectors/** + - device-connectors/** jobs: build: defaults: run: - working-directory: testflinger-device-connectors + working-directory: device-connectors runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/server-charm-check-libs.yml b/.github/workflows/server-charm-check-libs.yml index 0e2905ed..5d6d33e7 100644 --- a/.github/workflows/server-charm-check-libs.yml +++ b/.github/workflows/server-charm-check-libs.yml @@ -5,7 +5,7 @@ on: branches: - main paths: - - testflinger-server/** + - server/** jobs: build: @@ -19,6 +19,6 @@ jobs: - name: Check libraries uses: canonical/charming-actions/check-libraries@2.4.0 with: - charm-path: testflinger-server/charm + charm-path: server/charm credentials: "${{ secrets.CHARMHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/server-charm-release-edge.yml b/.github/workflows/server-charm-release-edge.yml index 684ecee7..3b1ccd2b 100644 --- a/.github/workflows/server-charm-release-edge.yml +++ b/.github/workflows/server-charm-release-edge.yml @@ -5,7 +5,7 @@ on: branches: - main paths: - - testflinger-server/** + - server/** jobs: build: @@ -19,7 +19,7 @@ jobs: - name: Upload charm to charmhub uses: canonical/charming-actions/upload-charm@2.4.0 with: - charm-path: testflinger-server/charm + charm-path: server/charm credentials: "${{ secrets.CHARMHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}" upload-image: "true" diff --git a/.github/workflows/server-publish-oci-image.yml b/.github/workflows/server-publish-oci-image.yml index 90616118..25d1f7b8 100644 --- a/.github/workflows/server-publish-oci-image.yml +++ b/.github/workflows/server-publish-oci-image.yml @@ -4,7 +4,7 @@ on: branches: ["main"] tags: ["v*.*.*"] paths: - - testflinger-server/** + - server/** env: REGISTRY: ghcr.io @@ -39,7 +39,7 @@ jobs: - name: Build and push backend Docker image uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 with: - context: ./testflinger-server + context: ./server file: Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/server-tox.yml b/.github/workflows/server-tox.yml index 2ac0b010..f4dfdc34 100644 --- a/.github/workflows/server-tox.yml +++ b/.github/workflows/server-tox.yml @@ -4,17 +4,17 @@ on: push: branches: [ main ] paths: - - testflinger-server/** + - server/** pull_request: branches: [ main ] paths: - - testflinger-server/** + - server/** jobs: build: defaults: run: - working-directory: testflinger-server + working-directory: server runs-on: ubuntu-latest strategy: matrix: From 6d1c66ce91521c26ac9749590bf648656527a471 Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Fri, 20 Oct 2023 10:41:17 -0500 Subject: [PATCH 569/569] Differentiate the names for each of the tox runs in github --- .github/workflows/agent-tox.yml | 2 +- .github/workflows/cli-tox.yml | 2 +- .github/workflows/device-tox.yml | 2 +- .github/workflows/server-tox.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/agent-tox.yml b/.github/workflows/agent-tox.yml index e88960bf..c085338a 100644 --- a/.github/workflows/agent-tox.yml +++ b/.github/workflows/agent-tox.yml @@ -1,4 +1,4 @@ -name: Run unit tests +name: "[agent] Run unit tests" on: push: diff --git a/.github/workflows/cli-tox.yml b/.github/workflows/cli-tox.yml index fc003d61..bef84f2c 100644 --- a/.github/workflows/cli-tox.yml +++ b/.github/workflows/cli-tox.yml @@ -1,4 +1,4 @@ -name: Run unit tests +name: "[cli] Run unit tests" on: push: diff --git a/.github/workflows/device-tox.yml b/.github/workflows/device-tox.yml index 5216f39b..1a681070 100644 --- a/.github/workflows/device-tox.yml +++ b/.github/workflows/device-tox.yml @@ -1,4 +1,4 @@ -name: Run unit tests +name: "[device-connectors] Run unit tests" on: push: diff --git a/.github/workflows/server-tox.yml b/.github/workflows/server-tox.yml index f4dfdc34..37ca7cdd 100644 --- a/.github/workflows/server-tox.yml +++ b/.github/workflows/server-tox.yml @@ -1,4 +1,4 @@ -name: Run unit tests +name: "[server] Run unit tests" on: push: