From be02ef96aa89a6af554a622f266d700ac0c98fdf Mon Sep 17 00:00:00 2001 From: Joey Hess Date: Fri, 11 Apr 2014 01:19:05 -0400 Subject: [PATCH] propellor (0.3.0) unstable; urgency=medium * ipv6to4: Ensure interface is brought up automatically on boot. * Enabling unattended upgrades now ensures that cron is installed and running to perform them. * Properties can be scheduled to only be checked after a given time period. * Fix bootstrapping of dependencies. * Fix compilation on Debian stable. * Include security updates in sources.list for stable and testing. * Use ssh connection caching, especially when bootstrapping. * Properties now run in a Propellor monad, which provides access to attributes of the host. # imported from the archive --- CHANGELOG | 1 + GPL | 674 ++++++++++++++++++ Makefile | 41 ++ Propellor.hs | 77 ++ Propellor/Attr.hs | 47 ++ Propellor/CmdLine.hs | 359 ++++++++++ Propellor/Engine.hs | 37 + Propellor/Exception.hs | 16 + Propellor/Message.hs | 51 ++ Propellor/PrivData.hs | 84 +++ Propellor/Property.hs | 120 ++++ Propellor/Property/Apt.hs | 193 +++++ Propellor/Property/Cmd.hs | 48 ++ Propellor/Property/Cron.hs | 32 + Propellor/Property/Dns.hs | 63 ++ Propellor/Property/Docker.hs | 462 ++++++++++++ Propellor/Property/Docker/Shim.hs | 61 ++ Propellor/Property/File.hs | 70 ++ Propellor/Property/Git.hs | 48 ++ Propellor/Property/Hostname.hs | 34 + Propellor/Property/Network.hs | 30 + Propellor/Property/OpenId.hs | 26 + Propellor/Property/Reboot.hs | 7 + Propellor/Property/Scheduled.hs | 67 ++ Propellor/Property/Service.hs | 31 + .../Property/SiteSpecific/GitAnnexBuilder.hs | 57 ++ Propellor/Property/SiteSpecific/GitHome.hs | 36 + Propellor/Property/SiteSpecific/JoeySites.hs | 23 + Propellor/Property/Ssh.hs | 62 ++ Propellor/Property/Sudo.hs | 32 + Propellor/Property/Tor.hs | 19 + Propellor/Property/User.hs | 61 ++ Propellor/SimpleSh.hs | 97 +++ Propellor/Types.hs | 170 +++++ Propellor/Types/Attr.hs | 36 + README.md | 105 +++ Setup.hs | 5 + TODO | 20 + Utility/Applicative.hs | 16 + Utility/Data.hs | 17 + Utility/Directory.hs | 135 ++++ Utility/Env.hs | 81 +++ Utility/Exception.hs | 59 ++ Utility/FileMode.hs | 157 ++++ Utility/FileSystemEncoding.hs | 132 ++++ Utility/LinuxMkLibs.hs | 61 ++ Utility/Misc.hs | 148 ++++ Utility/Monad.hs | 69 ++ Utility/PartialPrelude.hs | 68 ++ Utility/Path.hs | 293 ++++++++ Utility/PosixFiles.hs | 33 + Utility/Process.hs | 360 ++++++++++ Utility/QuickCheck.hs | 52 ++ Utility/SafeCommand.hs | 120 ++++ Utility/Scheduled.hs | 358 ++++++++++ Utility/ThreadScheduler.hs | 73 ++ Utility/Tmp.hs | 100 +++ Utility/UserInfo.hs | 55 ++ config-joey.hs | 202 ++++++ config-simple.hs | 47 ++ config.hs | 1 + debian/README.Debian | 7 + debian/changelog | 57 ++ debian/compat | 1 + debian/control | 40 ++ debian/copyright | 11 + debian/lintian-overrides | 3 + debian/propellor.1 | 15 + debian/rules | 14 + privdata/clam.kitenet.net.gpg | 25 + privdata/darkstar.kitenet.net.gpg | 22 + privdata/diatom.kitenet.net.gpg | 19 + privdata/keyring.gpg | Bin 0 -> 113014 bytes privdata/orca.kitenet.net.gpg | 22 + propellor.cabal | 125 ++++ propellor.hs | 91 +++ 76 files changed, 6491 insertions(+) create mode 120000 CHANGELOG create mode 100644 GPL create mode 100644 Makefile create mode 100644 Propellor.hs create mode 100644 Propellor/Attr.hs create mode 100644 Propellor/CmdLine.hs create mode 100644 Propellor/Engine.hs create mode 100644 Propellor/Exception.hs create mode 100644 Propellor/Message.hs create mode 100644 Propellor/PrivData.hs create mode 100644 Propellor/Property.hs create mode 100644 Propellor/Property/Apt.hs create mode 100644 Propellor/Property/Cmd.hs create mode 100644 Propellor/Property/Cron.hs create mode 100644 Propellor/Property/Dns.hs create mode 100644 Propellor/Property/Docker.hs create mode 100644 Propellor/Property/Docker/Shim.hs create mode 100644 Propellor/Property/File.hs create mode 100644 Propellor/Property/Git.hs create mode 100644 Propellor/Property/Hostname.hs create mode 100644 Propellor/Property/Network.hs create mode 100644 Propellor/Property/OpenId.hs create mode 100644 Propellor/Property/Reboot.hs create mode 100644 Propellor/Property/Scheduled.hs create mode 100644 Propellor/Property/Service.hs create mode 100644 Propellor/Property/SiteSpecific/GitAnnexBuilder.hs create mode 100644 Propellor/Property/SiteSpecific/GitHome.hs create mode 100644 Propellor/Property/SiteSpecific/JoeySites.hs create mode 100644 Propellor/Property/Ssh.hs create mode 100644 Propellor/Property/Sudo.hs create mode 100644 Propellor/Property/Tor.hs create mode 100644 Propellor/Property/User.hs create mode 100644 Propellor/SimpleSh.hs create mode 100644 Propellor/Types.hs create mode 100644 Propellor/Types/Attr.hs create mode 100644 README.md create mode 100644 Setup.hs create mode 100644 TODO create mode 100644 Utility/Applicative.hs create mode 100644 Utility/Data.hs create mode 100644 Utility/Directory.hs create mode 100644 Utility/Env.hs create mode 100644 Utility/Exception.hs create mode 100644 Utility/FileMode.hs create mode 100644 Utility/FileSystemEncoding.hs create mode 100644 Utility/LinuxMkLibs.hs create mode 100644 Utility/Misc.hs create mode 100644 Utility/Monad.hs create mode 100644 Utility/PartialPrelude.hs create mode 100644 Utility/Path.hs create mode 100644 Utility/PosixFiles.hs create mode 100644 Utility/Process.hs create mode 100644 Utility/QuickCheck.hs create mode 100644 Utility/SafeCommand.hs create mode 100644 Utility/Scheduled.hs create mode 100644 Utility/ThreadScheduler.hs create mode 100644 Utility/Tmp.hs create mode 100644 Utility/UserInfo.hs create mode 100644 config-joey.hs create mode 100644 config-simple.hs create mode 120000 config.hs create mode 100644 debian/README.Debian create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/lintian-overrides create mode 100644 debian/propellor.1 create mode 100755 debian/rules create mode 100644 privdata/clam.kitenet.net.gpg create mode 100644 privdata/darkstar.kitenet.net.gpg create mode 100644 privdata/diatom.kitenet.net.gpg create mode 100644 privdata/keyring.gpg create mode 100644 privdata/orca.kitenet.net.gpg create mode 100644 propellor.cabal create mode 100644 propellor.hs diff --git a/CHANGELOG b/CHANGELOG new file mode 120000 index 0000000..d526672 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1 @@ +debian/changelog \ No newline at end of file diff --git a/GPL b/GPL new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/GPL @@ -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/Makefile b/Makefile new file mode 100644 index 0000000..e53de8c --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +CABAL?=cabal + +run: deps build + ./propellor + +dev: build tags + +build: dist/setup-config + if ! $(CABAL) build; then $(CABAL) configure; $(CABAL) build; fi + ln -sf dist/build/config/config propellor + +deps: + @if [ $$(whoami) = root ]; then apt-get --no-upgrade --no-install-recommends -y install gnupg ghc cabal-install libghc-missingh-dev libghc-ansi-terminal-dev libghc-ifelse-dev libghc-unix-compat-dev libghc-hslogger-dev libghc-network-dev libghc-quickcheck2-dev libghc-mtl-dev libghc-monadcatchio-transformers-dev; fi || true + @if [ $$(whoami) = root ]; then apt-get --no-upgrade --no-install-recommends -y install libghc-async-dev || cabal update; cabal install async; fi || true + +dist/setup-config: propellor.cabal + if [ "$(CABAL)" = ./Setup ]; then ghc --make Setup; fi + $(CABAL) configure + +install: + install -d $(DESTDIR)/usr/bin $(DESTDIR)/usr/src/propellor + install -s dist/build/propellor/propellor $(DESTDIR)/usr/bin + $(CABAL) sdist + cat dist/propellor-*.tar.gz | \ + (cd $(DESTDIR)/usr/src/propellor && tar zx --strip-components=1) + +clean: + rm -rf dist Setup tags propellor propellor-wrapper privdata/local + find -name \*.o -exec rm {} \; + find -name \*.hi -exec rm {} \; + +# hothasktags chokes on some template haskell etc, so ignore errors +tags: + find . | grep -v /.git/ | grep -v /tmp/ | grep -v /dist/ | grep -v /doc/ | egrep '\.hs$$' | xargs hothasktags > tags 2>/dev/null + +# Upload to hackage. +hackage: + @cabal sdist + @cabal upload dist/*.tar.gz + +.PHONY: tags diff --git a/Propellor.hs b/Propellor.hs new file mode 100644 index 0000000..e631224 --- /dev/null +++ b/Propellor.hs @@ -0,0 +1,77 @@ +{-# LANGUAGE PackageImports #-} + +-- | Pulls in lots of useful modules for building and using Properties. +-- +-- When propellor runs on a Host, it ensures that its list of Properties +-- is satisfied, taking action as necessary when a Property is not +-- currently satisfied. +-- +-- A simple propellor program example: +-- +-- > import Propellor +-- > import Propellor.CmdLine +-- > import qualified Propellor.Property.File as File +-- > import qualified Propellor.Property.Apt as Apt +-- > +-- > main :: IO () +-- > main = defaultMain hosts +-- > +-- > hosts :: [Host] +-- > hosts = +-- > [ host "example.com" +-- > & Apt.installed ["mydaemon"] +-- > & "/etc/mydaemon.conf" `File.containsLine` "secure=1" +-- > `onChange` cmdProperty "service" ["mydaemon", "restart"] +-- > ! Apt.installed ["unwantedpackage"] +-- > ] +-- +-- See config.hs for a more complete example, and clone Propellor's +-- git repository for a deployable system using Propellor: +-- git clone + +module Propellor ( + module Propellor.Types + , module Propellor.Property + , module Propellor.Property.Cmd + , module Propellor.Attr + , module Propellor.PrivData + , module Propellor.Engine + , module Propellor.Exception + , module Propellor.Message + , localdir + + , module X +) where + +import Propellor.Types +import Propellor.Property +import Propellor.Engine +import Propellor.Property.Cmd +import Propellor.PrivData +import Propellor.Message +import Propellor.Exception +import Propellor.Attr + +import Utility.PartialPrelude as X +import Utility.Process as X +import Utility.Exception as X +import Utility.Env as X +import Utility.Directory as X +import Utility.Tmp as X +import Utility.Monad as X +import Utility.Misc as X + +import System.Directory as X +import System.IO as X +import System.FilePath as X +import Data.Maybe as X +import Data.Either as X +import Control.Applicative as X +import Control.Monad as X +import Data.Monoid as X +import Control.Monad.IfElse as X +import "mtl" Control.Monad.Reader as X + +-- | This is where propellor installs itself when deploying a host. +localdir :: FilePath +localdir = "/usr/local/propellor" diff --git a/Propellor/Attr.hs b/Propellor/Attr.hs new file mode 100644 index 0000000..4bc1c2c --- /dev/null +++ b/Propellor/Attr.hs @@ -0,0 +1,47 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Attr where + +import Propellor.Types +import Propellor.Types.Attr + +import "mtl" Control.Monad.Reader +import qualified Data.Set as S +import qualified Data.Map as M + +pureAttrProperty :: Desc -> (Attr -> Attr) -> AttrProperty +pureAttrProperty desc = AttrProperty $ Property ("has " ++ desc) + (return NoChange) + +hostname :: HostName -> AttrProperty +hostname name = pureAttrProperty ("hostname " ++ name) $ + \d -> d { _hostname = name } + +getHostName :: Propellor HostName +getHostName = asks _hostname + +cname :: Domain -> AttrProperty +cname domain = pureAttrProperty ("cname " ++ domain) (addCName domain) + +cnameFor :: IsProp p => Domain -> (Domain -> p) -> AttrProperty +cnameFor domain mkp = + let p = mkp domain + in AttrProperty p (addCName domain) + +addCName :: HostName -> Attr -> Attr +addCName domain d = d { _cnames = S.insert domain (_cnames d) } + +hostnameless :: Attr +hostnameless = newAttr (error "hostname Attr not specified") + +hostAttr :: Host -> Attr +hostAttr (Host _ mkattrs) = mkattrs hostnameless + +hostProperties :: Host -> [Property] +hostProperties (Host ps _) = ps + +hostMap :: [Host] -> M.Map HostName Host +hostMap l = M.fromList $ zip (map (_hostname . hostAttr) l) l + +findHost :: [Host] -> HostName -> Maybe Host +findHost l hn = M.lookup hn (hostMap l) diff --git a/Propellor/CmdLine.hs b/Propellor/CmdLine.hs new file mode 100644 index 0000000..5be91c4 --- /dev/null +++ b/Propellor/CmdLine.hs @@ -0,0 +1,359 @@ +module Propellor.CmdLine where + +import System.Environment (getArgs) +import Data.List +import System.Exit +import System.Log.Logger +import System.Log.Formatter +import System.Log.Handler (setFormatter, LogHandler) +import System.Log.Handler.Simple +import System.PosixCompat +import Control.Exception (bracket) +import System.Posix.IO + +import Propellor +import qualified Propellor.Property.Docker as Docker +import qualified Propellor.Property.Docker.Shim as DockerShim +import Utility.FileMode +import Utility.SafeCommand +import Utility.UserInfo + +usage :: IO a +usage = do + putStrLn $ unlines + [ "Usage:" + , " propellor" + , " propellor hostname" + , " propellor --spin hostname" + , " propellor --set hostname field" + , " propellor --add-key keyid" + ] + exitFailure + +processCmdLine :: IO CmdLine +processCmdLine = go =<< getArgs + where + go ("--help":_) = usage + go ("--spin":h:[]) = return $ Spin h + go ("--boot":h:[]) = return $ Boot h + go ("--add-key":k:[]) = return $ AddKey k + go ("--set":h:f:[]) = case readish f of + Just pf -> return $ Set h pf + Nothing -> errorMessage $ "Unknown privdata field " ++ f + go ("--continue":s:[]) = case readish s of + Just cmdline -> return $ Continue cmdline + Nothing -> errorMessage "--continue serialization failure" + go ("--chain":h:[]) = return $ Chain h + go ("--docker":h:[]) = return $ Docker h + go (h:[]) + | "--" `isPrefixOf` h = usage + | otherwise = return $ Run h + go [] = do + s <- takeWhile (/= '\n') <$> readProcess "hostname" ["-f"] + if null s + then errorMessage "Cannot determine hostname! Pass it on the command line." + else return $ Run s + go _ = usage + +defaultMain :: [Host] -> IO () +defaultMain hostlist = do + DockerShim.cleanEnv + checkDebugMode + cmdline <- processCmdLine + debug ["command line: ", show cmdline] + go True cmdline + where + go _ (Continue cmdline) = go False cmdline + go _ (Set hn field) = setPrivData hn field + go _ (AddKey keyid) = addKey keyid + go _ (Chain hn) = withprops hn $ \attr ps -> do + r <- runPropellor attr $ ensureProperties ps + putStrLn $ "\n" ++ show r + go _ (Docker hn) = Docker.chain hn + go True cmdline@(Spin _) = buildFirst cmdline $ go False cmdline + go True cmdline = updateFirst cmdline $ go False cmdline + go False (Spin hn) = withprops hn $ const . const $ spin hn + go False (Run hn) = ifM ((==) 0 <$> getRealUserID) + ( onlyProcess $ withprops hn mainProperties + , go True (Spin hn) + ) + go False (Boot hn) = onlyProcess $ withprops hn boot + + withprops :: HostName -> (Attr -> [Property] -> IO ()) -> IO () + withprops hn a = maybe + (unknownhost hn) + (\h -> a (hostAttr h) (hostProperties h)) + (findHost hostlist hn) + +onlyProcess :: IO a -> IO a +onlyProcess a = bracket lock unlock (const a) + where + lock = do + l <- createFile lockfile stdFileMode + setLock l (WriteLock, AbsoluteSeek, 0, 0) + `catchIO` const alreadyrunning + return l + unlock = closeFd + alreadyrunning = error "Propellor is already running on this host!" + lockfile = localdir ".lock" + +unknownhost :: HostName -> IO a +unknownhost h = errorMessage $ unlines + [ "Propellor does not know about host: " ++ h + , "(Perhaps you should specify the real hostname on the command line?)" + , "(Or, edit propellor's config.hs to configure this host)" + ] + +buildFirst :: CmdLine -> IO () -> IO () +buildFirst cmdline next = do + oldtime <- getmtime + ifM (actionMessage "Propellor build" $ boolSystem "make" [Param "build"]) + ( do + newtime <- getmtime + if newtime == oldtime + then next + else void $ boolSystem "./propellor" [Param "--continue", Param (show cmdline)] + , errorMessage "Propellor build failed!" + ) + where + getmtime = catchMaybeIO $ getModificationTime "propellor" + +getCurrentBranch :: IO String +getCurrentBranch = takeWhile (/= '\n') + <$> readProcess "git" ["symbolic-ref", "--short", "HEAD"] + +updateFirst :: CmdLine -> IO () -> IO () +updateFirst cmdline next = do + branchref <- getCurrentBranch + let originbranch = "origin" branchref + + void $ actionMessage "Git fetch" $ boolSystem "git" [Param "fetch"] + + whenM (doesFileExist keyring) $ do + {- To verify origin branch commit's signature, have to + - convince gpg to use our keyring. While running git log. + - Which has no way to pass options to gpg. + - Argh! -} + let gpgconf = privDataDir "gpg.conf" + writeFile gpgconf $ unlines + [ " keyring " ++ keyring + , "no-auto-check-trustdb" + ] + -- gpg is picky about perms + modifyFileMode privDataDir (removeModes otherGroupModes) + s <- readProcessEnv "git" ["log", "-n", "1", "--format=%G?", originbranch] + (Just [("GNUPGHOME", privDataDir)]) + nukeFile $ privDataDir "trustdb.gpg" + nukeFile $ privDataDir "pubring.gpg" + nukeFile $ privDataDir "gpg.conf" + if s == "U\n" || s == "G\n" + then do + putStrLn $ "git branch " ++ originbranch ++ " gpg signature verified; merging" + hFlush stdout + else errorMessage $ "git branch " ++ originbranch ++ " is not signed with a trusted gpg key; refusing to deploy it!" + + oldsha <- getCurrentGitSha1 branchref + void $ boolSystem "git" [Param "merge", Param originbranch] + newsha <- getCurrentGitSha1 branchref + + if oldsha == newsha + then next + else ifM (actionMessage "Propellor build" $ boolSystem "make" [Param "build"]) + ( void $ boolSystem "./propellor" [Param "--continue", Param (show cmdline)] + , errorMessage "Propellor build failed!" + ) + +getCurrentGitSha1 :: String -> IO String +getCurrentGitSha1 branchref = readProcess "git" ["show-ref", "--hash", branchref] + +spin :: HostName -> IO () +spin hn = do + url <- getUrl + void $ gitCommit [Param "--allow-empty", Param "-a", Param "-m", Param "propellor spin"] + void $ boolSystem "git" [Param "push"] + cacheparams <- toCommand <$> sshCachingParams hn + go cacheparams url =<< gpgDecrypt (privDataFile hn) + where + go cacheparams url privdata = withBothHandles createProcessSuccess (proc "ssh" $ cacheparams ++ [user, bootstrapcmd]) $ \(toh, fromh) -> do + let finish = do + senddata toh (privDataFile hn) privDataMarker privdata + hClose toh + + -- Display remaining output. + void $ tryIO $ forever $ + showremote =<< hGetLine fromh + hClose fromh + status <- getstatus fromh `catchIO` (const $ errorMessage "protocol error (perhaps the remote propellor failed to run?)") + case status of + Ready -> finish + NeedGitClone -> do + hClose toh + hClose fromh + sendGitClone hn url + go cacheparams url privdata + + user = "root@"++hn + + bootstrapcmd = shellWrap $ intercalate " ; " + [ "if [ ! -d " ++ localdir ++ " ]" + , "then " ++ intercalate " && " + [ "apt-get --no-install-recommends --no-upgrade -y install git make" + , "echo " ++ toMarked statusMarker (show NeedGitClone) + ] + , "else " ++ intercalate " && " + [ "cd " ++ localdir + , "if ! test -x ./propellor; then make deps build; fi" + , "./propellor --boot " ++ hn + ] + , "fi" + ] + + getstatus :: Handle -> IO BootStrapStatus + getstatus h = do + l <- hGetLine h + case readish =<< fromMarked statusMarker l of + Nothing -> do + showremote l + getstatus h + Just status -> return status + + showremote s = putStrLn s + senddata toh f marker s = void $ + actionMessage ("Sending " ++ f ++ " (" ++ show (length s) ++ " bytes) to " ++ hn) $ do + sendMarked toh marker s + return True + +sendGitClone :: HostName -> String -> IO () +sendGitClone hn url = void $ actionMessage ("Pushing git repository to " ++ hn) $ do + branch <- getCurrentBranch + cacheparams <- sshCachingParams hn + withTmpFile "propellor.git" $ \tmp _ -> allM id + [ boolSystem "git" [Param "bundle", Param "create", File tmp, Param "HEAD"] + , boolSystem "scp" $ cacheparams ++ [File tmp, Param ("root@"++hn++":"++remotebundle)] + , boolSystem "ssh" $ cacheparams ++ [Param ("root@"++hn), Param $ unpackcmd branch] + ] + where + remotebundle = "/usr/local/propellor.git" + unpackcmd branch = shellWrap $ intercalate " && " + [ "git clone " ++ remotebundle ++ " " ++ localdir + , "cd " ++ localdir + , "git checkout -b " ++ branch + , "git remote rm origin" + , "rm -f " ++ remotebundle + , "git remote add origin " ++ url + -- same as --set-upstream-to, except origin branch + -- has not been pulled yet + , "git config branch."++branch++".remote origin" + , "git config branch."++branch++".merge refs/heads/"++branch + ] + +data BootStrapStatus = Ready | NeedGitClone + deriving (Read, Show, Eq) + +type Marker = String +type Marked = String + +statusMarker :: Marker +statusMarker = "STATUS" + +privDataMarker :: String +privDataMarker = "PRIVDATA " + +toMarked :: Marker -> String -> String +toMarked marker = intercalate "\n" . map (marker ++) . lines + +sendMarked :: Handle -> Marker -> String -> IO () +sendMarked h marker s = do + -- Prefix string with newline because sometimes a + -- incomplete line is output. + hPutStrLn h ("\n" ++ toMarked marker s) + hFlush h + +fromMarked :: Marker -> Marked -> Maybe String +fromMarked marker s + | null matches = Nothing + | otherwise = Just $ intercalate "\n" $ + map (drop len) matches + where + len = length marker + matches = filter (marker `isPrefixOf`) $ lines s + +boot :: Attr -> [Property] -> IO () +boot attr ps = do + sendMarked stdout statusMarker $ show Ready + reply <- hGetContentsStrict stdin + + makePrivDataDir + maybe noop (writeFileProtected privDataLocal) $ + fromMarked privDataMarker reply + mainProperties attr ps + +addKey :: String -> IO () +addKey keyid = exitBool =<< allM id [ gpg, gitadd, gitcommit ] + where + gpg = boolSystem "sh" + [ Param "-c" + , Param $ "gpg --export " ++ keyid ++ " | gpg " ++ + unwords (gpgopts ++ ["--import"]) + ] + gitadd = boolSystem "git" + [ Param "add" + , File keyring + ] + gitcommit = gitCommit + [ File keyring + , Param "-m" + , Param "propellor addkey" + ] + +{- Automatically sign the commit if there'a a keyring. -} +gitCommit :: [CommandParam] -> IO Bool +gitCommit ps = do + k <- doesFileExist keyring + boolSystem "git" $ catMaybes $ + [ Just (Param "commit") + , if k then Just (Param "--gpg-sign") else Nothing + ] ++ map Just ps + +keyring :: FilePath +keyring = privDataDir "keyring.gpg" + +gpgopts :: [String] +gpgopts = ["--options", "/dev/null", "--no-default-keyring", "--keyring", keyring] + +getUrl :: IO String +getUrl = maybe nourl return =<< getM get urls + where + urls = ["remote.deploy.url", "remote.origin.url"] + nourl = errorMessage $ "Cannot find deploy url in " ++ show urls + get u = do + v <- catchMaybeIO $ + takeWhile (/= '\n') + <$> readProcess "git" ["config", u] + return $ case v of + Just url | not (null url) -> Just url + _ -> Nothing + +checkDebugMode :: IO () +checkDebugMode = go =<< getEnv "PROPELLOR_DEBUG" + where + go (Just s) + | s == "1" = do + f <- setFormatter + <$> streamHandler stderr DEBUG + <*> pure (simpleLogFormatter "[$time] $msg") + updateGlobalLogger rootLoggerName $ + setLevel DEBUG . setHandlers [f] + go _ = noop + +-- Parameters can be passed to both ssh and scp. +sshCachingParams :: HostName -> IO [CommandParam] +sshCachingParams hn = do + home <- myHomeDir + let cachedir = home ".ssh" "propellor" + createDirectoryIfMissing False cachedir + let socketfile = cachedir hn ++ ".sock" + return + [ Param "-o", Param ("ControlPath=" ++ socketfile) + , Params "-o ControlMaster=auto -o ControlPersist=yes" + ] diff --git a/Propellor/Engine.hs b/Propellor/Engine.hs new file mode 100644 index 0000000..81d979a --- /dev/null +++ b/Propellor/Engine.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Engine where + +import System.Exit +import System.IO +import Data.Monoid +import System.Console.ANSI +import "mtl" Control.Monad.Reader + +import Propellor.Types +import Propellor.Message +import Propellor.Exception + +runPropellor :: Attr -> Propellor a -> IO a +runPropellor attr a = runReaderT (runWithAttr a) attr + +mainProperties :: Attr -> [Property] -> IO () +mainProperties attr ps = do + r <- runPropellor attr $ + ensureProperties [Property "overall" $ ensureProperties ps] + setTitle "propellor: done" + hFlush stdout + case r of + FailedChange -> exitWith (ExitFailure 1) + _ -> exitWith ExitSuccess + +ensureProperties :: [Property] -> Propellor Result +ensureProperties ps = ensure ps NoChange + where + ensure [] rs = return rs + ensure (l:ls) rs = do + r <- actionMessage (propertyDesc l) (ensureProperty l) + ensure ls (r <> rs) + +ensureProperty :: Property -> Propellor Result +ensureProperty = catchPropellor . propertySatisfy diff --git a/Propellor/Exception.hs b/Propellor/Exception.hs new file mode 100644 index 0000000..bd9212a --- /dev/null +++ b/Propellor/Exception.hs @@ -0,0 +1,16 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Exception where + +import qualified "MonadCatchIO-transformers" Control.Monad.CatchIO as M +import Control.Exception +import Control.Applicative + +import Propellor.Types + +-- | Catches IO exceptions and returns FailedChange. +catchPropellor :: Propellor Result -> Propellor Result +catchPropellor a = either (\_ -> FailedChange) id <$> tryPropellor a + +tryPropellor :: Propellor a -> Propellor (Either IOException a) +tryPropellor = M.try diff --git a/Propellor/Message.hs b/Propellor/Message.hs new file mode 100644 index 0000000..2e63061 --- /dev/null +++ b/Propellor/Message.hs @@ -0,0 +1,51 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Message where + +import System.Console.ANSI +import System.IO +import System.Log.Logger +import "mtl" Control.Monad.Reader + +import Propellor.Types + +-- | Shows a message while performing an action, with a colored status +-- display. +actionMessage :: (MonadIO m, ActionResult r) => Desc -> m r -> m r +actionMessage desc a = do + liftIO $ do + setTitle $ "propellor: " ++ desc + hFlush stdout + + r <- a + + liftIO $ do + setTitle "propellor: running" + let (msg, intensity, color) = getActionResult r + putStr $ desc ++ " ... " + colorLine intensity color msg + hFlush stdout + + return r + +warningMessage :: MonadIO m => String -> m () +warningMessage s = liftIO $ colorLine Vivid Red $ "** warning: " ++ s + +colorLine :: ColorIntensity -> Color -> String -> IO () +colorLine intensity color msg = do + setSGR [SetColor Foreground intensity color] + putStr msg + setSGR [] + -- Note this comes after the color is reset, so that + -- the color set and reset happen in the same line. + putStrLn "" + hFlush stdout + +errorMessage :: String -> IO a +errorMessage s = do + warningMessage s + error "Cannot continue!" + +-- | Causes a debug message to be displayed when PROPELLOR_DEBUG=1 +debug :: [String] -> IO () +debug = debugM "propellor" . unwords diff --git a/Propellor/PrivData.hs b/Propellor/PrivData.hs new file mode 100644 index 0000000..5adc9e9 --- /dev/null +++ b/Propellor/PrivData.hs @@ -0,0 +1,84 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.PrivData where + +import qualified Data.Map as M +import Control.Applicative +import System.FilePath +import System.IO +import System.Directory +import Data.Maybe +import Control.Monad +import "mtl" Control.Monad.Reader + +import Propellor.Types +import Propellor.Attr +import Propellor.Message +import Utility.Monad +import Utility.PartialPrelude +import Utility.Exception +import Utility.Process +import Utility.Tmp +import Utility.SafeCommand +import Utility.Misc + +withPrivData :: PrivDataField -> (String -> Propellor Result) -> Propellor Result +withPrivData field a = maybe missing a =<< liftIO (getPrivData field) + where + missing = do + host <- getHostName + liftIO $ do + warningMessage $ "Missing privdata " ++ show field + putStrLn $ "Fix this by running: propellor --set "++host++" '" ++ show field ++ "'" + return FailedChange + +getPrivData :: PrivDataField -> IO (Maybe String) +getPrivData field = do + m <- catchDefaultIO Nothing $ readish <$> readFile privDataLocal + return $ maybe Nothing (M.lookup field) m + +setPrivData :: HostName -> PrivDataField -> IO () +setPrivData host field = do + putStrLn "Enter private data on stdin; ctrl-D when done:" + value <- chomp <$> hGetContentsStrict stdin + makePrivDataDir + let f = privDataFile host + m <- fromMaybe M.empty . readish <$> gpgDecrypt f + let m' = M.insert field value m + gpgEncrypt f (show m') + putStrLn "Private data set." + void $ boolSystem "git" [Param "add", File f] + where + chomp s + | end s == "\n" = chomp (beginning s) + | otherwise = s + +makePrivDataDir :: IO () +makePrivDataDir = createDirectoryIfMissing False privDataDir + +privDataDir :: FilePath +privDataDir = "privdata" + +privDataFile :: HostName -> FilePath +privDataFile host = privDataDir host ++ ".gpg" + +privDataLocal :: FilePath +privDataLocal = privDataDir "local" + +gpgDecrypt :: FilePath -> IO String +gpgDecrypt f = ifM (doesFileExist f) + ( readProcess "gpg" ["--decrypt", f] + , return "" + ) + +gpgEncrypt :: FilePath -> String -> IO () +gpgEncrypt f s = do + encrypted <- writeReadProcessEnv "gpg" + [ "--default-recipient-self" + , "--armor" + , "--encrypt" + ] + Nothing + (Just $ flip hPutStr s) + Nothing + viaTmp writeFile f encrypted diff --git a/Propellor/Property.hs b/Propellor/Property.hs new file mode 100644 index 0000000..3a3c1cb --- /dev/null +++ b/Propellor/Property.hs @@ -0,0 +1,120 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Property where + +import System.Directory +import Control.Monad +import Data.Monoid +import Control.Monad.IfElse +import "mtl" Control.Monad.Reader + +import Propellor.Types +import Propellor.Types.Attr +import Propellor.Engine +import Utility.Monad + +makeChange :: IO () -> Propellor Result +makeChange a = liftIO a >> return MadeChange + +noChange :: Propellor Result +noChange = return NoChange + +-- | Combines a list of properties, resulting in a single property +-- that when run will run each property in the list in turn, +-- and print out the description of each as it's run. Does not stop +-- on failure; does propigate overall success/failure. +propertyList :: Desc -> [Property] -> Property +propertyList desc ps = Property desc $ ensureProperties ps + +-- | Combines a list of properties, resulting in one property that +-- ensures each in turn, stopping on failure. +combineProperties :: Desc -> [Property] -> Property +combineProperties desc ps = Property desc $ go ps NoChange + where + go [] rs = return rs + go (l:ls) rs = do + r <- ensureProperty l + case r of + FailedChange -> return FailedChange + _ -> go ls (r <> rs) + +-- | Combines together two properties, resulting in one property +-- that ensures the first, and if the first succeeds, ensures the second. +-- The property uses the description of the first property. +before :: Property -> Property -> Property +p1 `before` p2 = Property (propertyDesc p1) $ do + r <- ensureProperty p1 + case r of + FailedChange -> return FailedChange + _ -> ensureProperty p2 + +-- | Makes a perhaps non-idempotent Property be idempotent by using a flag +-- file to indicate whether it has run before. +-- Use with caution. +flagFile :: Property -> FilePath -> Property +flagFile property flagfile = Property (propertyDesc property) $ + go =<< liftIO (doesFileExist flagfile) + where + go True = return NoChange + go False = do + r <- ensureProperty property + when (r == MadeChange) $ liftIO $ + unlessM (doesFileExist flagfile) $ + writeFile flagfile "" + return r + +--- | Whenever a change has to be made for a Property, causes a hook +-- Property to also be run, but not otherwise. +onChange :: Property -> Property -> Property +property `onChange` hook = Property (propertyDesc property) $ do + r <- ensureProperty property + case r of + MadeChange -> do + r' <- ensureProperty hook + return $ r <> r' + _ -> return r + +(==>) :: Desc -> Property -> Property +(==>) = flip describe +infixl 1 ==> + +-- | Makes a Property only be performed when a test succeeds. +check :: IO Bool -> Property -> Property +check c property = Property (propertyDesc property) $ ifM (liftIO c) + ( ensureProperty property + , return NoChange + ) + +boolProperty :: Desc -> IO Bool -> Property +boolProperty desc a = Property desc $ ifM (liftIO a) + ( return MadeChange + , return FailedChange + ) + +-- | Undoes the effect of a property. +revert :: RevertableProperty -> RevertableProperty +revert (RevertableProperty p1 p2) = RevertableProperty p2 p1 + +-- | Starts accumulating the properties of a Host. +-- +-- > host "example.com" +-- > & someproperty +-- > ! oldproperty +-- > & otherproperty +host :: HostName -> Host +host hn = Host [] (\_ -> newAttr hn) + +-- | Adds a property to a Host +-- Can add Properties, RevertableProperties, and AttrProperties +(&) :: IsProp p => Host -> p -> Host +(Host ps as) & p = Host (ps ++ [toProp p]) (getAttr p . as) + +infixl 1 & + +-- | Adds a property to the Host in reverted form. +(!) :: Host -> RevertableProperty -> Host +(Host ps as) ! p = Host (ps ++ [toProp q]) (getAttr q . as) + where + q = revert p + +infixl 1 ! diff --git a/Propellor/Property/Apt.hs b/Propellor/Property/Apt.hs new file mode 100644 index 0000000..4da13a2 --- /dev/null +++ b/Propellor/Property/Apt.hs @@ -0,0 +1,193 @@ +module Propellor.Property.Apt where + +import Data.Maybe +import Control.Applicative +import Data.List +import System.IO +import Control.Monad + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Service as Service +import Propellor.Property.File (Line) + +sourcesList :: FilePath +sourcesList = "/etc/apt/sources.list" + +type Url = String +type Section = String + +showSuite :: DebianSuite -> String +showSuite Stable = "stable" +showSuite Testing = "testing" +showSuite Unstable = "unstable" +showSuite Experimental = "experimental" +showSuite (DebianRelease r) = r + +debLine :: DebianSuite -> Url -> [Section] -> Line +debLine suite mirror sections = unwords $ + ["deb", mirror, showSuite suite] ++ sections + +srcLine :: Line -> Line +srcLine l = case words l of + ("deb":rest) -> unwords $ "deb-src" : rest + _ -> "" + +stdSections :: [Section] +stdSections = ["main", "contrib", "non-free"] + +binandsrc :: String -> DebianSuite -> [Line] +binandsrc url suite = [l, srcLine l] + where + l = debLine suite url stdSections + +debCdn :: DebianSuite -> [Line] +debCdn = binandsrc "http://cdn.debian.net/debian" + +kernelOrg :: DebianSuite -> [Line] +kernelOrg = binandsrc "http://mirrors.kernel.org/debian" + +-- | Only available for Stable and Testing +securityUpdates :: DebianSuite -> [Line] +securityUpdates suite + | suite == Stable || suite == Testing = + let l = "deb http://security.debian.org/ " ++ showSuite suite ++ "/updates " ++ unwords stdSections + in [l, srcLine l] + | otherwise = [] + +-- | Makes sources.list have a standard content using the mirror CDN, +-- with a particular DebianSuite. +-- +-- Since the CDN is sometimes unreliable, also adds backup lines using +-- kernel.org. +stdSourcesList :: DebianSuite -> Property +stdSourcesList suite = setSourcesList + (debCdn suite ++ kernelOrg suite ++ securityUpdates suite) + `describe` ("standard sources.list for " ++ show suite) + +setSourcesList :: [Line] -> Property +setSourcesList ls = sourcesList `File.hasContent` ls `onChange` update + +runApt :: [String] -> Property +runApt ps = cmdProperty' "apt-get" ps noninteractiveEnv + +noninteractiveEnv :: [(String, String)] +noninteractiveEnv = + [ ("DEBIAN_FRONTEND", "noninteractive") + , ("APT_LISTCHANGES_FRONTEND", "none") + ] + +update :: Property +update = runApt ["update"] + `describe` "apt update" + +upgrade :: Property +upgrade = runApt ["-y", "dist-upgrade"] + `describe` "apt dist-upgrade" + +type Package = String + +installed :: [Package] -> Property +installed = installed' ["-y"] + +installed' :: [String] -> [Package] -> Property +installed' params ps = robustly $ check (isInstallable ps) go + `describe` (unwords $ "apt installed":ps) + where + go = runApt $ params ++ ["install"] ++ ps + +-- | Minimal install of package, without recommends. +installedMin :: [Package] -> Property +installedMin = installed' ["--no-install-recommends", "-y"] + +removed :: [Package] -> Property +removed ps = check (or <$> isInstalled' ps) go + `describe` (unwords $ "apt removed":ps) + where + go = runApt $ ["-y", "remove"] ++ ps + +buildDep :: [Package] -> Property +buildDep ps = robustly go + `describe` (unwords $ "apt build-dep":ps) + where + go = runApt $ ["-y", "build-dep"] ++ ps + +-- | Installs the build deps for the source package unpacked +-- in the specifed directory, with a dummy package also +-- installed so that autoRemove won't remove them. +buildDepIn :: FilePath -> Property +buildDepIn dir = go `requires` installedMin ["devscripts", "equivs"] + where + go = cmdProperty' "sh" ["-c", "cd '" ++ dir ++ "' && mk-build-deps debian/control --install --tool 'apt-get -y --no-install-recommends' --remove"] + noninteractiveEnv + +-- | Package installation may fail becuse the archive has changed. +-- Run an update in that case and retry. +robustly :: Property -> Property +robustly p = Property (propertyDesc p) $ do + r <- ensureProperty p + if r == FailedChange + then ensureProperty $ p `requires` update + else return r + +isInstallable :: [Package] -> IO Bool +isInstallable ps = do + l <- isInstalled' ps + return $ any (== False) l && not (null l) + +isInstalled :: Package -> IO Bool +isInstalled p = (== [True]) <$> isInstalled' [p] + +-- | Note that the order of the returned list will not always +-- correspond to the order of the input list. The number of items may +-- even vary. If apt does not know about a package at all, it will not +-- be included in the result list. +isInstalled' :: [Package] -> IO [Bool] +isInstalled' ps = catMaybes . map parse . lines + <$> readProcess "apt-cache" ("policy":ps) + where + parse l + | "Installed: (none)" `isInfixOf` l = Just False + | "Installed: " `isInfixOf` l = Just True + | otherwise = Nothing + +autoRemove :: Property +autoRemove = runApt ["-y", "autoremove"] + `describe` "apt autoremove" + +-- | Enables unattended upgrades. Revert to disable. +unattendedUpgrades :: RevertableProperty +unattendedUpgrades = RevertableProperty enable disable + where + enable = setup True `before` Service.running "cron" + disable = setup False + + setup enabled = (if enabled then installed else removed) ["unattended-upgrades"] + `onChange` reConfigure "unattended-upgrades" + [("unattended-upgrades/enable_auto_updates" , "boolean", v)] + `describe` ("unattended upgrades " ++ v) + where + v + | enabled = "true" + | otherwise = "false" + +-- | Preseeds debconf values and reconfigures the package so it takes +-- effect. +reConfigure :: Package -> [(String, String, String)] -> Property +reConfigure package vals = reconfigure `requires` setselections + `describe` ("reconfigure " ++ package) + where + setselections = Property "preseed" $ makeChange $ + withHandle StdinHandle createProcessSuccess + (proc "debconf-set-selections" []) $ \h -> do + forM_ vals $ \(tmpl, tmpltype, value) -> + hPutStrLn h $ unwords [package, tmpl, tmpltype, value] + hClose h + reconfigure = cmdProperty "dpkg-reconfigure" ["-fnone", package] + +-- | Ensures that a service is installed and running. +-- +-- Assumes that there is a 1:1 mapping between service names and apt +-- package names. +serviceInstalledRunning :: Package -> Property +serviceInstalledRunning svc = Service.running svc `requires` installed [svc] diff --git a/Propellor/Property/Cmd.hs b/Propellor/Property/Cmd.hs new file mode 100644 index 0000000..875c1f9 --- /dev/null +++ b/Propellor/Property/Cmd.hs @@ -0,0 +1,48 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Property.Cmd ( + cmdProperty, + cmdProperty', + scriptProperty, + userScriptProperty, +) where + +import Control.Applicative +import Data.List +import "mtl" Control.Monad.Reader + +import Propellor.Types +import Utility.Monad +import Utility.SafeCommand +import Utility.Env + +-- | A property that can be satisfied by running a command. +-- +-- The command must exit 0 on success. +cmdProperty :: String -> [String] -> Property +cmdProperty cmd params = cmdProperty' cmd params [] + +-- | A property that can be satisfied by running a command, +-- with added environment. +cmdProperty' :: String -> [String] -> [(String, String)] -> Property +cmdProperty' cmd params env = Property desc $ liftIO $ do + env' <- addEntries env <$> getEnvironment + ifM (boolSystemEnv cmd (map Param params) (Just env')) + ( return MadeChange + , return FailedChange + ) + where + desc = unwords $ cmd : params + +-- | A property that can be satisfied by running a series of shell commands. +scriptProperty :: [String] -> Property +scriptProperty script = cmdProperty "sh" ["-c", shellcmd] + where + shellcmd = intercalate " ; " ("set -e" : script) + +-- | A property that can satisfied by running a series of shell commands, +-- as user (cd'd to their home directory). +userScriptProperty :: UserName -> [String] -> Property +userScriptProperty user script = cmdProperty "su" ["-c", shellcmd, user] + where + shellcmd = intercalate " ; " ("set -e" : "cd" : script) diff --git a/Propellor/Property/Cron.hs b/Propellor/Property/Cron.hs new file mode 100644 index 0000000..fa6019e --- /dev/null +++ b/Propellor/Property/Cron.hs @@ -0,0 +1,32 @@ +module Propellor.Property.Cron where + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt + +type CronTimes = String + +-- | Installs a cron job, run as a specificed user, in a particular +--directory. Note that the Desc must be unique, as it is used for the +--cron.d/ filename. +job :: Desc -> CronTimes -> UserName -> FilePath -> String -> Property +job desc times user cddir command = ("/etc/cron.d/" ++ desc) `File.hasContent` + [ "# Generated by propellor" + , "" + , "SHELL=/bin/sh" + , "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin" + , "" + , times ++ "\t" ++ user ++ "\t" ++ "cd " ++ cddir ++ " && " ++ command + ] + `requires` Apt.serviceInstalledRunning "cron" + `describe` ("cronned " ++ desc) + +-- | Installs a cron job, and runs it niced and ioniced. +niceJob :: Desc -> CronTimes -> UserName -> FilePath -> String -> Property +niceJob desc times user cddir command = job desc times user cddir + ("nice ionice -c 3 " ++ command) + `requires` Apt.installed ["util-linux", "moreutils"] + +-- | Installs a cron job to run propellor. +runPropellor :: CronTimes -> Property +runPropellor times = niceJob "propellor" times "root" localdir "chronic make" diff --git a/Propellor/Property/Dns.hs b/Propellor/Property/Dns.hs new file mode 100644 index 0000000..34e790d --- /dev/null +++ b/Propellor/Property/Dns.hs @@ -0,0 +1,63 @@ +module Propellor.Property.Dns where + +import Propellor +import Propellor.Property.File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Service as Service + +namedconf :: FilePath +namedconf = "/etc/bind/named.conf.local" + +data Zone = Zone + { zdomain :: Domain + , ztype :: Type + , zfile :: FilePath + , zmasters :: [IPAddr] + , zconfiglines :: [String] + } + +zoneDesc :: Zone -> String +zoneDesc z = zdomain z ++ " (" ++ show (ztype z) ++ ")" + +type IPAddr = String + +type Domain = String + +data Type = Master | Secondary + deriving (Show, Eq) + +secondary :: Domain -> [IPAddr] -> Zone +secondary domain masters = Zone + { zdomain = domain + , ztype = Secondary + , zfile = "db." ++ domain + , zmasters = masters + , zconfiglines = ["allow-transfer { }"] + } + +zoneStanza :: Zone -> [Line] +zoneStanza z = + [ "// automatically generated by propellor" + , "zone \"" ++ zdomain z ++ "\" {" + , cfgline "type" (if ztype z == Master then "master" else "slave") + , cfgline "file" ("\"" ++ zfile z ++ "\"") + ] ++ + (if null (zmasters z) then [] else mastersblock) ++ + (map (\l -> "\t" ++ l ++ ";") (zconfiglines z)) ++ + [ "};" + , "" + ] + where + cfgline f v = "\t" ++ f ++ " " ++ v ++ ";" + mastersblock = + [ "\tmasters {" ] ++ + (map (\ip -> "\t\t" ++ ip ++ ";") (zmasters z)) ++ + [ "\t};" ] + +-- | Rewrites the whole named.conf.local file to serve the specificed +-- zones. +zones :: [Zone] -> Property +zones zs = hasContent namedconf (concatMap zoneStanza zs) + `describe` ("dns server for zones: " ++ unwords (map zoneDesc zs)) + `requires` Apt.serviceInstalledRunning "bind9" + `onChange` Service.reloaded "bind9" diff --git a/Propellor/Property/Docker.hs b/Propellor/Property/Docker.hs new file mode 100644 index 0000000..d2555ea --- /dev/null +++ b/Propellor/Property/Docker.hs @@ -0,0 +1,462 @@ +{-# LANGUAGE BangPatterns #-} + +-- | Docker support for propellor +-- +-- The existance of a docker container is just another Property of a system, +-- which propellor can set up. See config.hs for an example. + +module Propellor.Property.Docker where + +import Propellor +import Propellor.SimpleSh +import Propellor.Types.Attr +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Docker.Shim as Shim +import Utility.SafeCommand +import Utility.Path + +import Control.Concurrent.Async +import System.Posix.Directory +import System.Posix.Process +import Data.List +import Data.List.Utils + +-- | Configures docker with an authentication file, so that images can be +-- pushed to index.docker.io. +configured :: Property +configured = Property "docker configured" go `requires` installed + where + go = withPrivData DockerAuthentication $ \cfg -> ensureProperty $ + "/root/.dockercfg" `File.hasContent` (lines cfg) + +installed :: Property +installed = Apt.installed ["docker.io"] + +-- | A short descriptive name for a container. +-- Should not contain whitespace or other unusual characters, +-- only [a-zA-Z0-9_-] are allowed +type ContainerName = String + +-- | Starts accumulating the properties of a Docker container. +-- +-- > container "web-server" "debian" +-- > & publish "80:80" +-- > & Apt.installed {"apache2"] +-- > & ... +container :: ContainerName -> Image -> Host +container cn image = Host [] (\_ -> attr) + where + attr = (newAttr (cn2hn cn)) { _dockerImage = Just image } + +cn2hn :: ContainerName -> HostName +cn2hn cn = cn ++ ".docker" + +-- | Ensures that a docker container is set up and running. The container +-- has its own Properties which are handled by running propellor +-- inside the container. +-- +-- Reverting this property ensures that the container is stopped and +-- removed. +docked + :: [Host] + -> ContainerName + -> RevertableProperty +docked hosts cn = RevertableProperty (go "docked" setup) (go "undocked" teardown) + where + go desc a = Property (desc ++ " " ++ cn) $ do + hn <- getHostName + let cid = ContainerId hn cn + ensureProperties [findContainer hosts cid cn $ a cid] + + setup cid (Container image runparams) = + provisionContainer cid + `requires` + runningContainer cid image runparams + `requires` + installed + + teardown cid (Container image _runparams) = + combineProperties ("undocked " ++ fromContainerId cid) + [ stoppedContainer cid + , Property ("cleaned up " ++ fromContainerId cid) $ + liftIO $ report <$> mapM id + [ removeContainer cid + , removeImage image + ] + ] + +findContainer + :: [Host] + -> ContainerId + -> ContainerName + -> (Container -> Property) + -> Property +findContainer hosts cid cn mk = case findHost hosts (cn2hn cn) of + Nothing -> cantfind + Just h -> maybe cantfind mk (mkContainer cid h) + where + cantfind = containerDesc cid $ Property "" $ do + liftIO $ warningMessage $ + "missing definition for docker container \"" ++ cn2hn cn + return FailedChange + +mkContainer :: ContainerId -> Host -> Maybe Container +mkContainer cid@(ContainerId hn _cn) h = Container + <$> _dockerImage attr + <*> pure (map (\a -> a hn) (_dockerRunParams attr)) + where + attr = hostAttr h' + h' = h + -- expose propellor directory inside the container + & volume (localdir++":"++localdir) + -- name the container in a predictable way so we + -- and the user can easily find it later + & name (fromContainerId cid) + +-- | Causes *any* docker images that are not in use by running containers to +-- be deleted. And deletes any containers that propellor has set up +-- before that are not currently running. Does not delete any containers +-- that were not set up using propellor. +-- +-- Generally, should come after the properties for the desired containers. +garbageCollected :: Property +garbageCollected = propertyList "docker garbage collected" + [ gccontainers + , gcimages + ] + where + gccontainers = Property "docker containers garbage collected" $ + liftIO $ report <$> (mapM removeContainer =<< listContainers AllContainers) + gcimages = Property "docker images garbage collected" $ do + liftIO $ report <$> (mapM removeImage =<< listImages) + +data Container = Container Image [RunParam] + +-- | Parameters to pass to `docker run` when creating a container. +type RunParam = String + +-- | A docker image, that can be used to run a container. +type Image = String + +-- | Set custom dns server for container. +dns :: String -> AttrProperty +dns = runProp "dns" + +-- | Set container host name. +hostname :: String -> AttrProperty +hostname = runProp "hostname" + +-- | Set name for container. (Normally done automatically.) +name :: String -> AttrProperty +name = runProp "name" + +-- | Publish a container's port to the host +-- (format: ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort) +publish :: String -> AttrProperty +publish = runProp "publish" + +-- | Username or UID for container. +user :: String -> AttrProperty +user = runProp "user" + +-- | Mount a volume +-- Create a bind mount with: [host-dir]:[container-dir]:[rw|ro] +-- With just a directory, creates a volume in the container. +volume :: String -> AttrProperty +volume = runProp "volume" + +-- | Mount a volume from the specified container into the current +-- container. +volumes_from :: ContainerName -> AttrProperty +volumes_from cn = genProp "volumes-from" $ \hn -> + fromContainerId (ContainerId hn cn) + +-- | Work dir inside the container. +workdir :: String -> AttrProperty +workdir = runProp "workdir" + +-- | Memory limit for container. +--Format: , where unit = b, k, m or g +memory :: String -> AttrProperty +memory = runProp "memory" + +-- | Link with another container on the same host. +link :: ContainerName -> ContainerAlias -> AttrProperty +link linkwith alias = genProp "link" $ \hn -> + fromContainerId (ContainerId hn linkwith) ++ ":" ++ alias + +-- | A short alias for a linked container. +-- Each container has its own alias namespace. +type ContainerAlias = String + +-- | A container is identified by its name, and the host +-- on which it's deployed. +data ContainerId = ContainerId HostName ContainerName + deriving (Eq, Read, Show) + +-- | Two containers with the same ContainerIdent were started from +-- the same base image (possibly a different version though), and +-- with the same RunParams. +data ContainerIdent = ContainerIdent Image HostName ContainerName [RunParam] + deriving (Read, Show, Eq) + +ident2id :: ContainerIdent -> ContainerId +ident2id (ContainerIdent _ hn cn _) = ContainerId hn cn + +toContainerId :: String -> Maybe ContainerId +toContainerId s + | myContainerSuffix `isSuffixOf` s = case separate (== '.') (desuffix s) of + (cn, hn) + | null hn || null cn -> Nothing + | otherwise -> Just $ ContainerId hn cn + | otherwise = Nothing + where + desuffix = reverse . drop len . reverse + len = length myContainerSuffix + +fromContainerId :: ContainerId -> String +fromContainerId (ContainerId hn cn) = cn++"."++hn++myContainerSuffix + +containerHostName :: ContainerId -> HostName +containerHostName (ContainerId _ cn) = cn2hn cn + +myContainerSuffix :: String +myContainerSuffix = ".propellor" + +containerDesc :: ContainerId -> Property -> Property +containerDesc cid p = p `describe` desc + where + desc = "[" ++ fromContainerId cid ++ "] " ++ propertyDesc p + +runningContainer :: ContainerId -> Image -> [RunParam] -> Property +runningContainer cid@(ContainerId hn cn) image runps = containerDesc cid $ Property "running" $ do + l <- liftIO $ listContainers RunningContainers + if cid `elem` l + then do + -- Check if the ident has changed; if so the + -- parameters of the container differ and it must + -- be restarted. + runningident <- liftIO $ getrunningident + if runningident == Just ident + then noChange + else do + void $ liftIO $ stopContainer cid + restartcontainer + else ifM (liftIO $ elem cid <$> listContainers AllContainers) + ( restartcontainer + , go image + ) + where + ident = ContainerIdent image hn cn runps + + restartcontainer = do + oldimage <- liftIO $ fromMaybe image <$> commitContainer cid + void $ liftIO $ removeContainer cid + go oldimage + + getrunningident :: IO (Maybe ContainerIdent) + getrunningident = simpleShClient (namedPipe cid) "cat" [propellorIdent] $ \rs -> do + let !v = extractident rs + return v + + extractident :: [Resp] -> Maybe ContainerIdent + extractident = headMaybe . catMaybes . map readish . catMaybes . map getStdout + + go img = do + liftIO $ do + clearProvisionedFlag cid + createDirectoryIfMissing True (takeDirectory $ identFile cid) + shim <- liftIO $ Shim.setup (localdir "propellor") (localdir shimdir cid) + liftIO $ writeFile (identFile cid) (show ident) + ensureProperty $ boolProperty "run" $ runContainer img + (runps ++ ["-i", "-d", "-t"]) + [shim, "--docker", fromContainerId cid] + +-- | Called when propellor is running inside a docker container. +-- The string should be the container's ContainerId. +-- +-- This process is effectively init inside the container. +-- It even needs to wait on zombie processes! +-- +-- Fork a thread to run the SimpleSh server in the background. +-- In the foreground, run an interactive bash (or sh) shell, +-- so that the user can interact with it when attached to the container. +-- +-- When the system reboots, docker restarts the container, and this is run +-- again. So, to make the necessary services get started on boot, this needs +-- to provision the container then. However, if the container is already +-- being provisioned by the calling propellor, it would be redundant and +-- problimatic to also provisoon it here. +-- +-- The solution is a flag file. If the flag file exists, then the container +-- was already provisioned. So, it must be a reboot, and time to provision +-- again. If the flag file doesn't exist, don't provision here. +chain :: String -> IO () +chain s = case toContainerId s of + Nothing -> error $ "Invalid ContainerId: " ++ s + Just cid -> do + changeWorkingDirectory localdir + writeFile propellorIdent . show =<< readIdentFile cid + -- Run boot provisioning before starting simpleSh, + -- to avoid ever provisioning twice at the same time. + whenM (checkProvisionedFlag cid) $ do + let shim = Shim.file (localdir "propellor") (localdir shimdir cid) + unlessM (boolSystem shim [Param "--continue", Param $ show $ Chain $ containerHostName cid]) $ + warningMessage "Boot provision failed!" + void $ async $ job reapzombies + void $ async $ job $ simpleSh $ namedPipe cid + job $ do + void $ tryIO $ ifM (inPath "bash") + ( boolSystem "bash" [Param "-l"] + , boolSystem "/bin/sh" [] + ) + putStrLn "Container is still running. Press ^P^Q to detach." + where + job = forever . void . tryIO + reapzombies = void $ getAnyProcessStatus True False + +-- | Once a container is running, propellor can be run inside +-- it to provision it. +-- +-- Note that there is a race here, between the simplesh +-- server starting up in the container, and this property +-- being run. So, retry connections to the client for up to +-- 1 minute. +provisionContainer :: ContainerId -> Property +provisionContainer cid = containerDesc cid $ Property "provision" $ liftIO $ do + let shim = Shim.file (localdir "propellor") (localdir shimdir cid) + r <- simpleShClientRetry 60 (namedPipe cid) shim params (go Nothing) + when (r /= FailedChange) $ + setProvisionedFlag cid + return r + where + params = ["--continue", show $ Chain $ containerHostName cid] + + go lastline (v:rest) = case v of + StdoutLine s -> do + debug ["stdout: ", show s] + maybe noop putStrLn lastline + hFlush stdout + go (Just s) rest + StderrLine s -> do + debug ["stderr: ", show s] + maybe noop putStrLn lastline + hFlush stdout + hPutStrLn stderr s + hFlush stderr + go Nothing rest + Done -> ret lastline + go lastline [] = ret lastline + + ret lastline = return $ fromMaybe FailedChange $ + readish =<< lastline + +stopContainer :: ContainerId -> IO Bool +stopContainer cid = boolSystem dockercmd [Param "stop", Param $ fromContainerId cid ] + +stoppedContainer :: ContainerId -> Property +stoppedContainer cid = containerDesc cid $ Property desc $ + ifM (liftIO $ elem cid <$> listContainers RunningContainers) + ( liftIO cleanup `after` ensureProperty + (boolProperty desc $ stopContainer cid) + , return NoChange + ) + where + desc = "stopped" + cleanup = do + nukeFile $ namedPipe cid + nukeFile $ identFile cid + removeDirectoryRecursive $ shimdir cid + clearProvisionedFlag cid + +removeContainer :: ContainerId -> IO Bool +removeContainer cid = catchBoolIO $ + snd <$> processTranscript dockercmd ["rm", fromContainerId cid ] Nothing + +removeImage :: Image -> IO Bool +removeImage image = catchBoolIO $ + snd <$> processTranscript dockercmd ["rmi", image ] Nothing + +runContainer :: Image -> [RunParam] -> [String] -> IO Bool +runContainer image ps cmd = boolSystem dockercmd $ map Param $ + "run" : (ps ++ image : cmd) + +commitContainer :: ContainerId -> IO (Maybe Image) +commitContainer cid = catchMaybeIO $ + takeWhile (/= '\n') + <$> readProcess dockercmd ["commit", fromContainerId cid] + +data ContainerFilter = RunningContainers | AllContainers + deriving (Eq) + +-- | Only lists propellor managed containers. +listContainers :: ContainerFilter -> IO [ContainerId] +listContainers status = + catMaybes . map toContainerId . concat . map (split ",") + . catMaybes . map (lastMaybe . words) . lines + <$> readProcess dockercmd ps + where + ps + | status == AllContainers = baseps ++ ["--all"] + | otherwise = baseps + baseps = ["ps", "--no-trunc"] + +listImages :: IO [Image] +listImages = lines <$> readProcess dockercmd ["images", "--all", "--quiet"] + +runProp :: String -> RunParam -> AttrProperty +runProp field val = AttrProperty prop $ \attr -> + attr { _dockerRunParams = _dockerRunParams attr ++ [\_ -> "--"++param] } + where + param = field++"="++val + prop = Property (param) (return NoChange) + +genProp :: String -> (HostName -> RunParam) -> AttrProperty +genProp field mkval = AttrProperty prop $ \attr -> + attr { _dockerRunParams = _dockerRunParams attr ++ [\hn -> "--"++field++"=" ++ mkval hn] } + where + prop = Property field (return NoChange) + +-- | The ContainerIdent of a container is written to +-- /.propellor-ident inside it. This can be checked to see if +-- the container has the same ident later. +propellorIdent :: FilePath +propellorIdent = "/.propellor-ident" + +-- | Named pipe used for communication with the container. +namedPipe :: ContainerId -> FilePath +namedPipe cid = "docker" fromContainerId cid + +provisionedFlag :: ContainerId -> FilePath +provisionedFlag cid = "docker" fromContainerId cid ++ ".provisioned" + +clearProvisionedFlag :: ContainerId -> IO () +clearProvisionedFlag = nukeFile . provisionedFlag + +setProvisionedFlag :: ContainerId -> IO () +setProvisionedFlag cid = do + createDirectoryIfMissing True (takeDirectory (provisionedFlag cid)) + writeFile (provisionedFlag cid) "1" + +checkProvisionedFlag :: ContainerId -> IO Bool +checkProvisionedFlag = doesFileExist . provisionedFlag + +shimdir :: ContainerId -> FilePath +shimdir cid = "docker" fromContainerId cid ++ ".shim" + +identFile :: ContainerId -> FilePath +identFile cid = "docker" fromContainerId cid ++ ".ident" + +readIdentFile :: ContainerId -> IO ContainerIdent +readIdentFile cid = fromMaybe (error "bad ident in identFile") + . readish <$> readFile (identFile cid) + +dockercmd :: String +dockercmd = "docker.io" + +report :: [Bool] -> Result +report rmed + | or rmed = MadeChange + | otherwise = NoChange + diff --git a/Propellor/Property/Docker/Shim.hs b/Propellor/Property/Docker/Shim.hs new file mode 100644 index 0000000..c2f35d0 --- /dev/null +++ b/Propellor/Property/Docker/Shim.hs @@ -0,0 +1,61 @@ +-- | Support for running propellor, as built outside a docker container, +-- inside the container. +-- +-- Note: This is currently Debian specific, due to glibcLibs. + +module Propellor.Property.Docker.Shim (setup, cleanEnv, file) where + +import Propellor +import Utility.LinuxMkLibs +import Utility.SafeCommand +import Utility.Path +import Utility.FileMode + +import Data.List +import System.Posix.Files + +-- | Sets up a shimmed version of the program, in a directory, and +-- returns its path. +setup :: FilePath -> FilePath -> IO FilePath +setup propellorbin dest = do + createDirectoryIfMissing True dest + + libs <- parseLdd <$> readProcess "ldd" [propellorbin] + glibclibs <- glibcLibs + let libs' = nub $ libs ++ glibclibs + libdirs <- map (dest ++) . nub . catMaybes + <$> mapM (installLib installFile dest) libs' + + let linker = (dest ++) $ + fromMaybe (error "cannot find ld-linux linker") $ + headMaybe $ filter ("ld-linux" `isInfixOf`) libs' + let gconvdir = (dest ++) $ parentDir $ + fromMaybe (error "cannot find gconv directory") $ + headMaybe $ filter ("/gconv/" `isInfixOf`) glibclibs + let linkerparams = ["--library-path", intercalate ":" libdirs ] + let shim = file propellorbin dest + writeFile shim $ unlines + [ "#!/bin/sh" + , "GCONV_PATH=" ++ shellEscape gconvdir + , "export GCONV_PATH" + , "exec " ++ unwords (map shellEscape $ linker : linkerparams) ++ + " " ++ shellEscape propellorbin ++ " \"$@\"" + ] + modifyFileMode shim (addModes executeModes) + return shim + +cleanEnv :: IO () +cleanEnv = void $ unsetEnv "GCONV_PATH" + +file :: FilePath -> FilePath -> FilePath +file propellorbin dest = dest takeFileName propellorbin + +installFile :: FilePath -> FilePath -> IO () +installFile top f = do + createDirectoryIfMissing True destdir + nukeFile dest + createLink f dest `catchIO` (const copy) + where + copy = void $ boolSystem "cp" [Param "-a", Param f, Param dest] + destdir = inTop top $ parentDir f + dest = inTop top f diff --git a/Propellor/Property/File.hs b/Propellor/Property/File.hs new file mode 100644 index 0000000..10dee75 --- /dev/null +++ b/Propellor/Property/File.hs @@ -0,0 +1,70 @@ +module Propellor.Property.File where + +import Propellor + +import System.Posix.Files + +type Line = String + +-- | Replaces all the content of a file. +hasContent :: FilePath -> [Line] -> Property +f `hasContent` newcontent = fileProperty ("replace " ++ f) + (\_oldcontent -> newcontent) f + +-- | Ensures a file has contents that comes from PrivData. +-- Note: Does not do anything with the permissions of the file to prevent +-- it from being seen. +hasPrivContent :: FilePath -> Property +hasPrivContent f = Property ("privcontent " ++ f) $ + withPrivData (PrivFile f) (\v -> ensureProperty $ f `hasContent` lines v) + +-- | Ensures that a line is present in a file, adding it to the end if not. +containsLine :: FilePath -> Line -> Property +f `containsLine` l = fileProperty (f ++ " contains:" ++ l) go f + where + go ls + | l `elem` ls = ls + | otherwise = ls++[l] + +-- | Ensures that a line is not present in a file. +-- Note that the file is ensured to exist, so if it doesn't, an empty +-- file will be written. +lacksLine :: FilePath -> Line -> Property +f `lacksLine` l = fileProperty (f ++ " remove: " ++ l) (filter (/= l)) f + +-- | Removes a file. Does not remove symlinks or non-plain-files. +notPresent :: FilePath -> Property +notPresent f = check (doesFileExist f) $ Property (f ++ " not present") $ + makeChange $ nukeFile f + +fileProperty :: Desc -> ([Line] -> [Line]) -> FilePath -> Property +fileProperty desc a f = Property desc $ go =<< liftIO (doesFileExist f) + where + go True = do + ls <- liftIO $ lines <$> readFile f + let ls' = a ls + if ls' == ls + then noChange + else makeChange $ viaTmp updatefile f (unlines ls') + go False = makeChange $ writeFile f (unlines $ a []) + + -- viaTmp makes the temp file mode 600. + -- Replicate the original file mode before moving it into place. + updatefile f' content = do + writeFile f' content + getFileStatus f >>= setFileMode f' . fileMode + +-- | Ensures a directory exists. +dirExists :: FilePath -> Property +dirExists d = check (not <$> doesDirectoryExist d) $ Property (d ++ " exists") $ + makeChange $ createDirectoryIfMissing True d + +-- | Ensures that a file/dir has the specified owner and group. +ownerGroup :: FilePath -> UserName -> GroupName -> Property +ownerGroup f owner group = Property (f ++ " owner " ++ og) $ do + r <- ensureProperty $ cmdProperty "chown" [og, f] + if r == FailedChange + then return r + else noChange + where + og = owner ++ ":" ++ group diff --git a/Propellor/Property/Git.hs b/Propellor/Property/Git.hs new file mode 100644 index 0000000..c049416 --- /dev/null +++ b/Propellor/Property/Git.hs @@ -0,0 +1,48 @@ +module Propellor.Property.Git where + +import Propellor +import Propellor.Property.File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Service as Service + +import Data.List + +-- | Exports all git repos in a directory (that user nobody can read) +-- using git-daemon, run from inetd. +-- +-- Note that reverting this property does not remove or stop inetd. +daemonRunning :: FilePath -> RevertableProperty +daemonRunning exportdir = RevertableProperty setup unsetup + where + setup = containsLine conf (mkl "tcp4") + `requires` + containsLine conf (mkl "tcp6") + `requires` + dirExists exportdir + `requires` + Apt.serviceInstalledRunning "openbsd-inetd" + `onChange` + Service.running "openbsd-inetd" + `describe` ("git-daemon exporting " ++ exportdir) + unsetup = lacksLine conf (mkl "tcp4") + `requires` + lacksLine conf (mkl "tcp6") + `onChange` + Service.reloaded "openbsd-inetd" + + conf = "/etc/inetd.conf" + + mkl tcpv = intercalate "\t" + [ "git" + , "stream" + , tcpv + , "nowait" + , "nobody" + , "/usr/bin/git" + , "git" + , "daemon" + , "--inetd" + , "--export-all" + , "--base-path=" ++ exportdir + , exportdir + ] diff --git a/Propellor/Property/Hostname.hs b/Propellor/Property/Hostname.hs new file mode 100644 index 0000000..03613ac --- /dev/null +++ b/Propellor/Property/Hostname.hs @@ -0,0 +1,34 @@ +module Propellor.Property.Hostname where + +import Propellor +import qualified Propellor.Property.File as File + +-- | Ensures that the hostname is set to the HostAttr value. +-- Configures both /etc/hostname and the current hostname. +-- +-- When the hostname is a FQDN, also configures /etc/hosts, +-- with an entry for 127.0.1.1, which is standard at least on Debian +-- to set the FDQN (127.0.0.1 is localhost). +sane :: Property +sane = Property ("sane hostname") (ensureProperty . setTo =<< getHostName) + +setTo :: HostName -> Property +setTo hn = combineProperties desc go + `onChange` cmdProperty "hostname" [basehost] + where + desc = "hostname " ++ hn + (basehost, domain) = separate (== '.') hn + + go = catMaybes + [ Just $ "/etc/hostname" `File.hasContent` [basehost] + , if null domain + then Nothing + else Just $ File.fileProperty desc + addhostline "/etc/hosts" + ] + + hostip = "127.0.1.1" + hostline = hostip ++ "\t" ++ hn ++ " " ++ basehost + + addhostline ls = hostline : filter (not . hashostip) ls + hashostip l = headMaybe (words l) == Just hostip diff --git a/Propellor/Property/Network.hs b/Propellor/Property/Network.hs new file mode 100644 index 0000000..6009778 --- /dev/null +++ b/Propellor/Property/Network.hs @@ -0,0 +1,30 @@ +module Propellor.Property.Network where + +import Propellor +import Propellor.Property.File + +interfaces :: FilePath +interfaces = "/etc/network/interfaces" + +-- | 6to4 ipv6 connection, should work anywhere +ipv6to4 :: Property +ipv6to4 = fileProperty "ipv6to4" go interfaces + `onChange` ifUp "sit0" + where + go ls + | all (`elem` ls) stanza = ls + | otherwise = ls ++ stanza + stanza = + [ "# Automatically added by propeller" + , "iface sit0 inet6 static" + , "\taddress 2002:5044:5531::1" + , "\tnetmask 64" + , "\tgateway ::192.88.99.1" + , "auto sit0" + , "# End automatically added by propeller" + ] + +type Interface = String + +ifUp :: Interface -> Property +ifUp iface = cmdProperty "ifup" [iface] diff --git a/Propellor/Property/OpenId.hs b/Propellor/Property/OpenId.hs new file mode 100644 index 0000000..c397bdb --- /dev/null +++ b/Propellor/Property/OpenId.hs @@ -0,0 +1,26 @@ +module Propellor.Property.OpenId where + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Service as Service + +import Data.List + +providerFor :: [UserName] -> String -> Property +providerFor users baseurl = propertyList desc $ + [ Apt.serviceInstalledRunning "apache2" + , Apt.installed ["simpleid"] + `onChange` Service.restarted "apache2" + , File.fileProperty desc + (map setbaseurl) "/etc/simpleid/config.inc" + ] ++ map identfile users + where + identfile u = File.hasPrivContent $ concat + [ "/var/lib/simpleid/identities/", u, ".identity" ] + url = "http://"++baseurl++"/simpleid" + desc = "openid provider " ++ url + setbaseurl l + | "SIMPLEID_BASE_URL" `isInfixOf` l = + "define('SIMPLEID_BASE_URL', '"++url++"');" + | otherwise = l diff --git a/Propellor/Property/Reboot.hs b/Propellor/Property/Reboot.hs new file mode 100644 index 0000000..25e5315 --- /dev/null +++ b/Propellor/Property/Reboot.hs @@ -0,0 +1,7 @@ +module Propellor.Property.Reboot where + +import Propellor + +now :: Property +now = cmdProperty "reboot" [] + `describe` "reboot now" diff --git a/Propellor/Property/Scheduled.hs b/Propellor/Property/Scheduled.hs new file mode 100644 index 0000000..8341765 --- /dev/null +++ b/Propellor/Property/Scheduled.hs @@ -0,0 +1,67 @@ +module Propellor.Property.Scheduled + ( period + , periodParse + , Recurrance(..) + , WeekDay + , MonthDay + , YearDay + ) where + +import Propellor +import Utility.Scheduled + +import Data.Time.Clock +import Data.Time.LocalTime +import qualified Data.Map as M + +-- | Makes a Property only be checked every so often. +-- +-- This uses the description of the Property to keep track of when it was +-- last run. +period :: Property -> Recurrance -> Property +period prop recurrance = Property desc $ do + lasttime <- liftIO $ getLastChecked (propertyDesc prop) + nexttime <- liftIO $ fmap startTime <$> nextTime schedule lasttime + t <- liftIO localNow + if Just t >= nexttime + then do + r <- ensureProperty prop + liftIO $ setLastChecked t (propertyDesc prop) + return r + else noChange + where + schedule = Schedule recurrance AnyTime + desc = propertyDesc prop ++ " (period " ++ fromRecurrance recurrance ++ ")" + +-- | Like period, but parse a human-friendly string. +periodParse :: Property -> String -> Property +periodParse prop s = case toRecurrance s of + Just recurrance -> period prop recurrance + Nothing -> Property "periodParse" $ do + liftIO $ warningMessage $ "failed periodParse: " ++ s + noChange + +lastCheckedFile :: FilePath +lastCheckedFile = localdir ".lastchecked" + +getLastChecked :: Desc -> IO (Maybe LocalTime) +getLastChecked desc = M.lookup desc <$> readLastChecked + +localNow :: IO LocalTime +localNow = do + now <- getCurrentTime + tz <- getTimeZone now + return $ utcToLocalTime tz now + +setLastChecked :: LocalTime -> Desc -> IO () +setLastChecked time desc = do + m <- readLastChecked + writeLastChecked (M.insert desc time m) + +readLastChecked :: IO (M.Map Desc LocalTime) +readLastChecked = fromMaybe M.empty <$> catchDefaultIO Nothing go + where + go = readish <$> readFile lastCheckedFile + +writeLastChecked :: M.Map Desc LocalTime -> IO () +writeLastChecked = writeFile lastCheckedFile . show diff --git a/Propellor/Property/Service.hs b/Propellor/Property/Service.hs new file mode 100644 index 0000000..c6498e5 --- /dev/null +++ b/Propellor/Property/Service.hs @@ -0,0 +1,31 @@ +module Propellor.Property.Service where + +import Propellor +import Utility.SafeCommand + +type ServiceName = String + +-- | Ensures that a service is running. Does not ensure that +-- any package providing that service is installed. See +-- Apt.serviceInstalledRunning +-- +-- Note that due to the general poor state of init scripts, the best +-- we can do is try to start the service, and if it fails, assume +-- this means it's already running. +running :: ServiceName -> Property +running svc = Property ("running " ++ svc) $ do + void $ ensureProperty $ + scriptProperty ["service " ++ shellEscape svc ++ " start >/dev/null 2>&1 || true"] + return NoChange + +restarted :: ServiceName -> Property +restarted svc = Property ("restarted " ++ svc) $ do + void $ ensureProperty $ + scriptProperty ["service " ++ shellEscape svc ++ " restart >/dev/null 2>&1 || true"] + return NoChange + +reloaded :: ServiceName -> Property +reloaded svc = Property ("reloaded " ++ svc) $ do + void $ ensureProperty $ + scriptProperty ["service " ++ shellEscape svc ++ " reload >/dev/null 2>&1 || true"] + return NoChange diff --git a/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs b/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs new file mode 100644 index 0000000..204a9ca --- /dev/null +++ b/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs @@ -0,0 +1,57 @@ +module Propellor.Property.SiteSpecific.GitAnnexBuilder where + +import Propellor +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.User as User +import qualified Propellor.Property.Cron as Cron +import Propellor.Property.Cron (CronTimes) + +builduser :: UserName +builduser = "builder" + +homedir :: FilePath +homedir = "/home/builder" + +gitbuilderdir :: FilePath +gitbuilderdir = homedir "gitbuilder" + +builddir :: FilePath +builddir = gitbuilderdir "build" + +builder :: Architecture -> CronTimes -> Bool -> Property +builder arch crontimes rsyncupload = combineProperties "gitannexbuilder" + [ Apt.stdSourcesList Unstable + , Apt.buildDep ["git-annex"] + , Apt.installed ["git", "rsync", "moreutils", "ca-certificates", + "liblockfile-simple-perl", "cabal-install", "vim", "less"] + , Apt.serviceInstalledRunning "cron" + , User.accountFor builduser + , check (not <$> doesDirectoryExist gitbuilderdir) $ userScriptProperty builduser + [ "git clone git://git.kitenet.net/gitannexbuilder " ++ gitbuilderdir + , "cd " ++ gitbuilderdir + , "git checkout " ++ arch + ] + `describe` "gitbuilder setup" + , check (not <$> doesDirectoryExist builddir) $ userScriptProperty builduser + [ "git clone git://git-annex.branchable.com/ " ++ builddir + ] + , "git-annex source build deps installed" ==> Apt.buildDepIn builddir + , Cron.niceJob "gitannexbuilder" crontimes builduser gitbuilderdir "git pull ; ./autobuild" + -- The builduser account does not have a password set, + -- instead use the password privdata to hold the rsync server + -- password used to upload the built image. + , Property "rsync password" $ do + let f = homedir "rsyncpassword" + if rsyncupload + then withPrivData (Password builduser) $ \p -> do + oldp <- liftIO $ catchDefaultIO "" $ + readFileStrict f + if p /= oldp + then makeChange $ writeFile f p + else noChange + else do + ifM (liftIO $ doesFileExist f) + ( noChange + , makeChange $ writeFile f "no password configured" + ) + ] diff --git a/Propellor/Property/SiteSpecific/GitHome.hs b/Propellor/Property/SiteSpecific/GitHome.hs new file mode 100644 index 0000000..1ba56b9 --- /dev/null +++ b/Propellor/Property/SiteSpecific/GitHome.hs @@ -0,0 +1,36 @@ +module Propellor.Property.SiteSpecific.GitHome where + +import Propellor +import qualified Propellor.Property.Apt as Apt +import Propellor.Property.User +import Utility.SafeCommand + +-- | Clones Joey Hess's git home directory, and runs its fixups script. +installedFor :: UserName -> Property +installedFor user = check (not <$> hasGitDir user) $ + Property ("githome " ++ user) (go =<< liftIO (homedir user)) + `requires` Apt.installed ["git"] + where + go Nothing = noChange + go (Just home) = do + let tmpdir = home "githome" + ensureProperty $ combineProperties "githome setup" + [ userScriptProperty user ["git clone " ++ url ++ " " ++ tmpdir] + , Property "moveout" $ makeChange $ void $ + moveout tmpdir home + , Property "rmdir" $ makeChange $ void $ + catchMaybeIO $ removeDirectory tmpdir + , userScriptProperty user ["rm -rf .aptitude/ .bashrc .profile; bin/mr checkout; bin/fixups"] + ] + moveout tmpdir home = do + fs <- dirContents tmpdir + forM fs $ \f -> boolSystem "mv" [File f, File home] + +url :: String +url = "git://git.kitenet.net/joey/home" + +hasGitDir :: UserName -> IO Bool +hasGitDir user = go =<< homedir user + where + go Nothing = return False + go (Just home) = doesDirectoryExist (home ".git") diff --git a/Propellor/Property/SiteSpecific/JoeySites.hs b/Propellor/Property/SiteSpecific/JoeySites.hs new file mode 100644 index 0000000..4637317 --- /dev/null +++ b/Propellor/Property/SiteSpecific/JoeySites.hs @@ -0,0 +1,23 @@ +-- | Specific configuation for Joey Hess's sites. Probably not useful to +-- others except as an example. + +module Propellor.Property.SiteSpecific.JoeySites where + +import Propellor +import qualified Propellor.Property.Apt as Apt + +oldUseNetShellBox :: Property +oldUseNetShellBox = check (not <$> Apt.isInstalled "oldusenet") $ + propertyList ("olduse.net shellbox") + [ Apt.installed (words "build-essential devscripts debhelper git libncursesw5-dev libpcre3-dev pkg-config bison libicu-dev libidn11-dev libcanlock2-dev libuu-dev ghc libghc-strptime-dev libghc-hamlet-dev libghc-ifelse-dev libghc-hxt-dev libghc-utf8-string-dev libghc-missingh-dev libghc-sha-dev") + `describe` "olduse.net build deps" + , scriptProperty + [ "rm -rf /root/tmp/oldusenet" -- idenpotency + , "git clone git://olduse.net/ /root/tmp/oldusenet/source" + , "cd /root/tmp/oldusenet/source/" + , "dpkg-buildpackage -us -uc" + , "dpkg -i ../oldusenet*.deb || true" + , "apt-get -fy install" -- dependencies + , "rm -rf /root/tmp/oldusenet" + ] `describe` "olduse.net built" + ] diff --git a/Propellor/Property/Ssh.hs b/Propellor/Property/Ssh.hs new file mode 100644 index 0000000..59845f8 --- /dev/null +++ b/Propellor/Property/Ssh.hs @@ -0,0 +1,62 @@ +module Propellor.Property.Ssh ( + setSshdConfig, + permitRootLogin, + passwordAuthentication, + hasAuthorizedKeys, + restartSshd, + uniqueHostKeys +) where + +import Propellor +import qualified Propellor.Property.File as File +import Propellor.Property.User +import Utility.SafeCommand + +sshBool :: Bool -> String +sshBool True = "yes" +sshBool False = "no" + +sshdConfig :: FilePath +sshdConfig = "/etc/ssh/sshd_config" + +setSshdConfig :: String -> Bool -> Property +setSshdConfig setting allowed = combineProperties "sshd config" + [ sshdConfig `File.lacksLine` (sshline $ not allowed) + , sshdConfig `File.containsLine` (sshline allowed) + ] + `onChange` restartSshd + `describe` unwords [ "ssh config:", setting, sshBool allowed ] + where + sshline v = setting ++ " " ++ sshBool v + +permitRootLogin :: Bool -> Property +permitRootLogin = setSshdConfig "PermitRootLogin" + +passwordAuthentication :: Bool -> Property +passwordAuthentication = setSshdConfig "PasswordAuthentication" + +hasAuthorizedKeys :: UserName -> IO Bool +hasAuthorizedKeys = go <=< homedir + where + go Nothing = return False + go (Just home) = not . null <$> catchDefaultIO "" + (readFile $ home ".ssh" "authorized_keys") + +restartSshd :: Property +restartSshd = cmdProperty "service" ["ssh", "restart"] + +-- | Blows away existing host keys and make new ones. +-- Useful for systems installed from an image that might reuse host keys. +-- A flag file is used to only ever do this once. +uniqueHostKeys :: Property +uniqueHostKeys = flagFile prop "/etc/ssh/.unique_host_keys" + `onChange` restartSshd + where + prop = Property "ssh unique host keys" $ do + void $ liftIO $ boolSystem "sh" + [ Param "-c" + , Param "rm -f /etc/ssh/ssh_host_*" + ] + ensureProperty $ + cmdProperty "/var/lib/dpkg/info/openssh-server.postinst" + ["configure"] diff --git a/Propellor/Property/Sudo.hs b/Propellor/Property/Sudo.hs new file mode 100644 index 0000000..66ceb58 --- /dev/null +++ b/Propellor/Property/Sudo.hs @@ -0,0 +1,32 @@ +module Propellor.Property.Sudo where + +import Data.List + +import Propellor +import Propellor.Property.File +import qualified Propellor.Property.Apt as Apt +import Propellor.Property.User + +-- | Allows a user to sudo. If the user has a password, sudo is configured +-- to require it. If not, NOPASSWORD is enabled for the user. +enabledFor :: UserName -> Property +enabledFor user = Property desc go `requires` Apt.installed ["sudo"] + where + go = do + locked <- liftIO $ isLockedPassword user + ensureProperty $ + fileProperty desc + (modify locked . filter (wanted locked)) + "/etc/sudoers" + desc = user ++ " is sudoer" + sudobaseline = user ++ " ALL=(ALL:ALL)" + sudoline True = sudobaseline ++ " NOPASSWD:ALL" + sudoline False = sudobaseline ++ " ALL" + wanted locked l + -- TOOD: Full sudoers file format parse.. + | not (sudobaseline `isPrefixOf` l) = True + | "NOPASSWD" `isInfixOf` l = locked + | otherwise = True + modify locked ls + | sudoline locked `elem` ls = ls + | otherwise = ls ++ [sudoline locked] diff --git a/Propellor/Property/Tor.hs b/Propellor/Property/Tor.hs new file mode 100644 index 0000000..78e35c8 --- /dev/null +++ b/Propellor/Property/Tor.hs @@ -0,0 +1,19 @@ +module Propellor.Property.Tor where + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt + +isBridge :: Property +isBridge = setup `requires` Apt.installed ["tor"] + `describe` "tor bridge" + where + setup = "/etc/tor/torrc" `File.hasContent` + [ "SocksPort 0" + , "ORPort 443" + , "BridgeRelay 1" + , "Exitpolicy reject *:*" + ] `onChange` restartTor + +restartTor :: Property +restartTor = cmdProperty "service" ["tor", "restart"] diff --git a/Propellor/Property/User.hs b/Propellor/Property/User.hs new file mode 100644 index 0000000..9d94883 --- /dev/null +++ b/Propellor/Property/User.hs @@ -0,0 +1,61 @@ +module Propellor.Property.User where + +import System.Posix + +import Propellor + +data Eep = YesReallyDeleteHome + +accountFor :: UserName -> Property +accountFor user = check (isNothing <$> homedir user) $ cmdProperty "adduser" + [ "--disabled-password" + , "--gecos", "" + , user + ] + `describe` ("account for " ++ user) + +-- | Removes user home directory!! Use with caution. +nuked :: UserName -> Eep -> Property +nuked user _ = check (isJust <$> homedir user) $ cmdProperty "userdel" + [ "-r" + , user + ] + `describe` ("nuked user " ++ user) + +-- | Only ensures that the user has some password set. It may or may +-- not be the password from the PrivData. +hasSomePassword :: UserName -> Property +hasSomePassword user = check ((/= HasPassword) <$> getPasswordStatus user) $ + hasPassword user + +hasPassword :: UserName -> Property +hasPassword user = Property (user ++ " has password") $ + withPrivData (Password user) $ \password -> makeChange $ + withHandle StdinHandle createProcessSuccess + (proc "chpasswd" []) $ \h -> do + hPutStrLn h $ user ++ ":" ++ password + hClose h + +lockedPassword :: UserName -> Property +lockedPassword user = check (not <$> isLockedPassword user) $ cmdProperty "passwd" + [ "--lock" + , user + ] + `describe` ("locked " ++ user ++ " password") + +data PasswordStatus = NoPassword | LockedPassword | HasPassword + deriving (Eq) + +getPasswordStatus :: UserName -> IO PasswordStatus +getPasswordStatus user = parse . words <$> readProcess "passwd" ["-S", user] + where + parse (_:"L":_) = LockedPassword + parse (_:"NP":_) = NoPassword + parse (_:"P":_) = HasPassword + parse _ = NoPassword + +isLockedPassword :: UserName -> IO Bool +isLockedPassword user = (== LockedPassword) <$> getPasswordStatus user + +homedir :: UserName -> IO (Maybe FilePath) +homedir user = catchMaybeIO $ homeDirectory <$> getUserEntryForName user diff --git a/Propellor/SimpleSh.hs b/Propellor/SimpleSh.hs new file mode 100644 index 0000000..7e0f19f --- /dev/null +++ b/Propellor/SimpleSh.hs @@ -0,0 +1,97 @@ +-- | Simple server, using a named pipe. Client connects, sends a command, +-- and gets back all the output from the command, in a stream. +-- +-- This is useful for eg, docker. + +module Propellor.SimpleSh where + +import Network.Socket +import Control.Concurrent.Chan +import Control.Concurrent.Async +import System.Process (std_in, std_out, std_err) + +import Propellor +import Utility.FileMode +import Utility.ThreadScheduler + +data Cmd = Cmd String [String] + deriving (Read, Show) + +data Resp = StdoutLine String | StderrLine String | Done + deriving (Read, Show) + +simpleSh :: FilePath -> IO () +simpleSh namedpipe = do + nukeFile namedpipe + let dir = takeDirectory namedpipe + createDirectoryIfMissing True dir + modifyFileMode dir (removeModes otherGroupModes) + s <- socket AF_UNIX Stream defaultProtocol + bindSocket s (SockAddrUnix namedpipe) + listen s 2 + forever $ do + (client, _addr) <- accept s + h <- socketToHandle client ReadWriteMode + hSetBuffering h LineBuffering + maybe noop (run h) . readish =<< hGetLine h + where + run h (Cmd cmd params) = do + let p = (proc cmd params) + { std_in = Inherit + , std_out = CreatePipe + , std_err = CreatePipe + } + (Nothing, Just outh, Just errh, pid) <- createProcess p + chan <- newChan + + let runwriter = do + v <- readChan chan + hPutStrLn h (show v) + case v of + Done -> noop + _ -> runwriter + writer <- async runwriter + + let mkreader t from = maybe noop (const $ mkreader t from) + =<< catchMaybeIO (writeChan chan . t =<< hGetLine from) + void $ concurrently + (mkreader StdoutLine outh) + (mkreader StderrLine errh) + + void $ tryIO $ waitForProcess pid + + writeChan chan Done + + wait writer + + hClose outh + hClose errh + hClose h + +simpleShClient :: FilePath -> String -> [String] -> ([Resp] -> IO a) -> IO a +simpleShClient namedpipe cmd params handler = do + s <- socket AF_UNIX Stream defaultProtocol + connect s (SockAddrUnix namedpipe) + h <- socketToHandle s ReadWriteMode + hSetBuffering h LineBuffering + hPutStrLn h $ show $ Cmd cmd params + resps <- catMaybes . map readish . lines <$> hGetContents h + hClose h `after` handler resps + +simpleShClientRetry :: Int -> FilePath -> String -> [String] -> ([Resp] -> IO a) -> IO a +simpleShClientRetry retries namedpipe cmd params handler = go retries + where + run = simpleShClient namedpipe cmd params handler + go n + | n < 1 = run + | otherwise = do + v <- tryIO run + case v of + Right r -> return r + Left _ -> do + threadDelaySeconds (Seconds 1) + go (n - 1) + +getStdout :: Resp -> Maybe String +getStdout (StdoutLine s) = Just s +getStdout _ = Nothing diff --git a/Propellor/Types.hs b/Propellor/Types.hs new file mode 100644 index 0000000..e6e0212 --- /dev/null +++ b/Propellor/Types.hs @@ -0,0 +1,170 @@ +{-# LANGUAGE PackageImports #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE ExistentialQuantification #-} + +module Propellor.Types + ( Host(..) + , Attr + , HostName + , UserName + , GroupName + , Propellor(..) + , Property(..) + , RevertableProperty(..) + , AttrProperty(..) + , IsProp + , describe + , toProp + , getAttr + , requires + , Desc + , Result(..) + , System(..) + , Distribution(..) + , DebianSuite(..) + , Release + , Architecture + , ActionResult(..) + , CmdLine(..) + , PrivDataField(..) + ) where + +import Data.Monoid +import Control.Applicative +import System.Console.ANSI +import "mtl" Control.Monad.Reader +import "MonadCatchIO-transformers" Control.Monad.CatchIO + +import Propellor.Types.Attr + +data Host = Host [Property] (Attr -> Attr) + +type UserName = String +type GroupName = String + +-- | Propellor's monad provides read-only access to attributes of the +-- system. +newtype Propellor p = Propellor { runWithAttr :: ReaderT Attr IO p } + deriving + ( Monad + , Functor + , Applicative + , MonadReader Attr + , MonadIO + , MonadCatchIO + ) + +-- | The core data type of Propellor, this represents a property +-- that the system should have, and an action to ensure it has the +-- property. +data Property = Property + { propertyDesc :: Desc + -- | must be idempotent; may run repeatedly + , propertySatisfy :: Propellor Result + } + +-- | A property that can be reverted. +data RevertableProperty = RevertableProperty Property Property + +-- | A property that affects the Attr. +data AttrProperty = forall p. IsProp p => AttrProperty p (Attr -> Attr) + +class IsProp p where + -- | Sets description. + describe :: p -> Desc -> p + toProp :: p -> Property + -- | Indicates that the first property can only be satisfied + -- once the second one is. + requires :: p -> Property -> p + getAttr :: p -> (Attr -> Attr) + +instance IsProp Property where + describe p d = p { propertyDesc = d } + toProp p = p + x `requires` y = Property (propertyDesc x) $ do + r <- propertySatisfy y + case r of + FailedChange -> return FailedChange + _ -> propertySatisfy x + getAttr _ = id + +instance IsProp RevertableProperty where + -- | Sets the description of both sides. + describe (RevertableProperty p1 p2) d = + RevertableProperty (describe p1 d) (describe p2 ("not " ++ d)) + toProp (RevertableProperty p1 _) = p1 + (RevertableProperty p1 p2) `requires` y = + RevertableProperty (p1 `requires` y) p2 + getAttr _ = id + +instance IsProp AttrProperty where + describe (AttrProperty p a) d = AttrProperty (describe p d) a + toProp (AttrProperty p _) = toProp p + (AttrProperty p a) `requires` y = AttrProperty (p `requires` y) a + getAttr (AttrProperty _ a) = a + +type Desc = String + +data Result = NoChange | MadeChange | FailedChange + deriving (Read, Show, Eq) + +instance Monoid Result where + mempty = NoChange + + mappend FailedChange _ = FailedChange + mappend _ FailedChange = FailedChange + mappend MadeChange _ = MadeChange + mappend _ MadeChange = MadeChange + mappend NoChange NoChange = NoChange + +-- | High level descritption of a operating system. +data System = System Distribution Architecture + deriving (Show) + +data Distribution + = Debian DebianSuite + | Ubuntu Release + deriving (Show) + +data DebianSuite = Experimental | Unstable | Testing | Stable | DebianRelease Release + deriving (Show, Eq) + +type Release = String + +type Architecture = String + +-- | Results of actions, with color. +class ActionResult a where + getActionResult :: a -> (String, ColorIntensity, Color) + +instance ActionResult Bool where + getActionResult False = ("failed", Vivid, Red) + getActionResult True = ("done", Dull, Green) + +instance ActionResult Result where + getActionResult NoChange = ("ok", Dull, Green) + getActionResult MadeChange = ("done", Vivid, Green) + getActionResult FailedChange = ("failed", Vivid, Red) + +data CmdLine + = Run HostName + | Spin HostName + | Boot HostName + | Set HostName PrivDataField + | AddKey String + | Continue CmdLine + | Chain HostName + | Docker HostName + deriving (Read, Show, Eq) + +-- | Note that removing or changing field names will break the +-- serialized privdata files, so don't do that! +-- It's fine to add new fields. +data PrivDataField + = DockerAuthentication + | SshPrivKey UserName + | Password UserName + | PrivFile FilePath + deriving (Read, Show, Ord, Eq) + + diff --git a/Propellor/Types/Attr.hs b/Propellor/Types/Attr.hs new file mode 100644 index 0000000..c253e32 --- /dev/null +++ b/Propellor/Types/Attr.hs @@ -0,0 +1,36 @@ +module Propellor.Types.Attr where + +import qualified Data.Set as S + +-- | The attributes of a host. For example, its hostname. +data Attr = Attr + { _hostname :: HostName + , _cnames :: S.Set Domain + + , _dockerImage :: Maybe String + , _dockerRunParams :: [HostName -> String] + } + +instance Eq Attr where + x == y = and + [ _hostname x == _hostname y + , _cnames x == _cnames y + + , _dockerImage x == _dockerImage y + , let simpl v = map (\a -> a "") (_dockerRunParams v) + in simpl x == simpl y + ] + +instance Show Attr where + show a = unlines + [ "hostname " ++ _hostname a + , "cnames " ++ show (_cnames a) + , "docker image " ++ show (_dockerImage a) + , "docker run params " ++ show (map (\mk -> mk "") (_dockerRunParams a)) + ] + +newAttr :: HostName -> Attr +newAttr hn = Attr hn S.empty Nothing [] + +type HostName = String +type Domain = String diff --git a/README.md b/README.md new file mode 100644 index 0000000..b870c9e --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +This is a configuration management system using Haskell and Git. + +Propellor enures that the system it's run against satisfies a list of +properties, taking action as necessary when a property is not yet met. + +Propellor is configured via a git repository, which typically lives +in ~/.propellor/. The git repository contains a config.hs file, +and also the entire source code to propellor. + +You typically want to have the repository checked out on a laptop, in order +to make changes and push them out to hosts. Each host will also have a +clone of the repository, and in that clone "make" can be used to build and +run propellor. This can be done by a cron job (which propellor can set up), +or a remote host can be triggered to update by running propellor on your +laptop: propellor --spin $host + +Properties are defined using Haskell. Edit config.hs to get started. +For API documentation, see + +There is no special language as used in puppet, chef, ansible, etc.. just +the full power of Haskell. Hopefully that power can be put to good use in +making declarative properties that are powerful, nicely idempotent, and +easy to adapt to a system's special needs. + +Also avoided is any form of node classification. Ie, which hosts are part +of which classes and share which configuration. It might be nice to use +reclass[1], but then again a host is configured using simply haskell code, +and so it's easy to factor out things like classes of hosts as desired. + +## quick start + +1. Get propellor installed + `cabal install propellor` + or + `apt-get install propellor` +2. Run propellor for the first time. It will set up a `~/.propellor/` git + repository for you. +3. `cd ~/.propellor/`; use git to push the repository to a central + server (github, or your own git server). Configure that central + server as the origin remote of the repository. +4. If you don't have a gpg private key, generate one: `gpg --gen-key` +5. Run: `propellor --add-key $KEYID` +6. Edit `~/.propellor/config.hs`, and add a host you want to manage. + You can start by not adding any properties, or only a few. +7. Pick a host and run: `propellor --spin $HOST` +8. Now you have a simple propellor deployment, but it doesn't do + much to the host yet, besides installing propellor. + + So, edit `~/.propellor/config.hs` to configure the host (maybe + start with a few simple properties), and re-run step 7. + Repeat until happy and move on to the next host. :) +9. To move beyond manually running `propellor --spin` against hosts + when you change their properties, add a property to your hosts + like: `Cron.runPropellor "30 * * * *"` + + Now they'll automatically update every 30 minutes, and you can + `git commit -S` and `git push` changes that affect any number of + hosts. +10. Write some neat new properties and send patches to ! + +## security + +Propellor's security model is that the hosts it's used to deploy are +untrusted, and that the central git repository server is untrusted too. + +The only trusted machine is the laptop where you run `propellor --spin` +to connect to a remote host. And that one only because you have a ssh key +or login password to the host. + +Since the hosts propellor deploys are not trusted by the central git +repository, they have to use git:// or http:// to pull from the central +git repository, rather than ssh://. + +So, to avoid a MITM attack, propellor checks that any commit it fetches +from origin is gpg signed by a trusted gpg key, and refuses to deploy it +otherwise. + +That is only done when privdata/keyring.gpg exists. To set it up: + + gpg --gen-key # only if you don't already have a gpg key + propellor --add-key $MYKEYID + +In order to be secure from the beginning, when `propellor --spin` is used +to bootstrap propellor on a new host, it transfers the local git repositry +to the remote host over ssh. After that, the remote host knows the +gpg key, and will use it to verify git fetches. + +Since the propoellor git repository is public, you can't store +in cleartext private data such as passwords, ssh private keys, etc. + +Instead, `propellor --spin $host` looks for a +`~/.propellor/privdata/$host.gpg` file and if found decrypts it and sends +it to the remote host using ssh. This lets a remote host know its own +private data, without seeing all the rest. + +To securely store private data, use: `propellor --set $host $field` +The field name will be something like 'Password "root"'; see PrivData.hs +for available fields. + +## debugging + +Set `PROPELLOR_DEBUG=1` to make propellor print out all the commands it runs +and any other debug messages that Properties choose to emit. + +[1] http://reclass.pantsfullofunix.net/ diff --git a/Setup.hs b/Setup.hs new file mode 100644 index 0000000..daf5717 --- /dev/null +++ b/Setup.hs @@ -0,0 +1,5 @@ +{- cabal setup file -} + +import Distribution.Simple + +main = defaultMain diff --git a/TODO b/TODO new file mode 100644 index 0000000..a203169 --- /dev/null +++ b/TODO @@ -0,0 +1,20 @@ +* Need a way to run an action when a property changes, but only + run it once for the whole. For example, may want to restart apache, + but only once despite many config changes being made to satisfy + properties. onChange is a poor substitute. +* Currently only Debian and derivatives are supported by most Properties. + This could be improved by making the Distribution of the system part + of its HostAttr. +* Display of docker container properties is a bit wonky. It always + says they are unchanged even when they changed and triggered a + reprovision. +* Should properties be a tree rather than a list? +* Need a way for a dns server host to look at the properties of + the other hosts and generate a zone file. For example, mapping + openid.kitenet.net to a CNAME to clam.kitenet.net, which is where + the docker container for that service is located. Moving containers + to a different host, or duplicating a container on multiple hosts + would then update DNS too +* There is no way for a property of a docker container to require + some property be met outside the container. For example, some servers + need ntp installed for a good date source. diff --git a/Utility/Applicative.hs b/Utility/Applicative.hs new file mode 100644 index 0000000..64400c8 --- /dev/null +++ b/Utility/Applicative.hs @@ -0,0 +1,16 @@ +{- applicative stuff + - + - Copyright 2012 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +module Utility.Applicative where + +{- Like <$> , but supports one level of currying. + - + - foo v = bar <$> action v == foo = bar <$$> action + -} +(<$$>) :: Functor f => (a -> b) -> (c -> f a) -> c -> f b +f <$$> v = fmap f . v +infixr 4 <$$> diff --git a/Utility/Data.hs b/Utility/Data.hs new file mode 100644 index 0000000..3592582 --- /dev/null +++ b/Utility/Data.hs @@ -0,0 +1,17 @@ +{- utilities for simple data types + - + - Copyright 2013 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +module Utility.Data where + +{- First item in the list that is not Nothing. -} +firstJust :: Eq a => [Maybe a] -> Maybe a +firstJust ms = case dropWhile (== Nothing) ms of + [] -> Nothing + (md:_) -> md + +eitherToMaybe :: Either a b -> Maybe b +eitherToMaybe = either (const Nothing) Just diff --git a/Utility/Directory.hs b/Utility/Directory.hs new file mode 100644 index 0000000..f1bcfad --- /dev/null +++ b/Utility/Directory.hs @@ -0,0 +1,135 @@ +{- directory manipulation + - + - Copyright 2011-2014 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.Directory where + +import System.IO.Error +import System.Directory +import Control.Exception (throw) +import Control.Monad +import Control.Monad.IfElse +import System.FilePath +import Control.Applicative +import System.IO.Unsafe (unsafeInterleaveIO) + +import Utility.PosixFiles +import Utility.SafeCommand +import Utility.Tmp +import Utility.Exception +import Utility.Monad +import Utility.Applicative + +dirCruft :: FilePath -> Bool +dirCruft "." = True +dirCruft ".." = True +dirCruft _ = False + +{- Lists the contents of a directory. + - Unlike getDirectoryContents, paths are not relative to the directory. -} +dirContents :: FilePath -> IO [FilePath] +dirContents d = map (d ) . filter (not . dirCruft) <$> getDirectoryContents d + +{- Gets files in a directory, and then its subdirectories, recursively, + - and lazily. + - + - Does not follow symlinks to other subdirectories. + - + - When the directory does not exist, no exception is thrown, + - instead, [] is returned. -} +dirContentsRecursive :: FilePath -> IO [FilePath] +dirContentsRecursive topdir = dirContentsRecursiveSkipping (const False) True topdir + +{- Skips directories whose basenames match the skipdir. -} +dirContentsRecursiveSkipping :: (FilePath -> Bool) -> Bool -> FilePath -> IO [FilePath] +dirContentsRecursiveSkipping skipdir followsubdirsymlinks topdir = go [topdir] + where + go [] = return [] + go (dir:dirs) + | skipdir (takeFileName dir) = go dirs + | otherwise = unsafeInterleaveIO $ do + (files, dirs') <- collect [] [] + =<< catchDefaultIO [] (dirContents dir) + files' <- go (dirs' ++ dirs) + return (files ++ files') + collect files dirs' [] = return (reverse files, reverse dirs') + collect files dirs' (entry:entries) + | dirCruft entry = collect files dirs' entries + | otherwise = do + let skip = collect (entry:files) dirs' entries + let recurse = collect files (entry:dirs') entries + ms <- catchMaybeIO $ getSymbolicLinkStatus entry + case ms of + (Just s) + | isDirectory s -> recurse + | isSymbolicLink s && followsubdirsymlinks -> + ifM (doesDirectoryExist entry) + ( recurse + , skip + ) + _ -> skip + +{- Gets the directory tree from a point, recursively and lazily, + - with leaf directories **first**, skipping any whose basenames + - match the skipdir. Does not follow symlinks. -} +dirTreeRecursiveSkipping :: (FilePath -> Bool) -> FilePath -> IO [FilePath] +dirTreeRecursiveSkipping skipdir topdir = go [] [topdir] + where + go c [] = return c + go c (dir:dirs) + | skipdir (takeFileName dir) = go c dirs + | otherwise = unsafeInterleaveIO $ do + subdirs <- go c + =<< filterM (isDirectory <$$> getSymbolicLinkStatus) + =<< catchDefaultIO [] (dirContents dir) + go (subdirs++[dir]) dirs + +{- Moves one filename to another. + - First tries a rename, but falls back to moving across devices if needed. -} +moveFile :: FilePath -> FilePath -> IO () +moveFile src dest = tryIO (rename src dest) >>= onrename + where + onrename (Right _) = noop + onrename (Left e) + | isPermissionError e = rethrow + | isDoesNotExistError e = rethrow + | otherwise = do + -- copyFile is likely not as optimised as + -- the mv command, so we'll use the latter. + -- But, mv will move into a directory if + -- dest is one, which is not desired. + whenM (isdir dest) rethrow + viaTmp mv dest undefined + where + rethrow = throw e + mv tmp _ = do + ok <- boolSystem "mv" [Param "-f", Param src, Param tmp] + unless ok $ do + -- delete any partial + _ <- tryIO $ removeFile tmp + rethrow + + isdir f = do + r <- tryIO $ getFileStatus f + case r of + (Left _) -> return False + (Right s) -> return $ isDirectory s + +{- Removes a file, which may or may not exist, and does not have to + - be a regular file. + - + - Note that an exception is thrown if the file exists but + - cannot be removed. -} +nukeFile :: FilePath -> IO () +nukeFile file = void $ tryWhenExists go + where +#ifndef mingw32_HOST_OS + go = removeLink file +#else + go = removeFile file +#endif diff --git a/Utility/Env.hs b/Utility/Env.hs new file mode 100644 index 0000000..90ed58f --- /dev/null +++ b/Utility/Env.hs @@ -0,0 +1,81 @@ +{- portable environment variables + - + - Copyright 2013 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.Env where + +#ifdef mingw32_HOST_OS +import Utility.Exception +import Control.Applicative +import Data.Maybe +import qualified System.Environment as E +#else +import qualified System.Posix.Env as PE +#endif + +getEnv :: String -> IO (Maybe String) +#ifndef mingw32_HOST_OS +getEnv = PE.getEnv +#else +getEnv = catchMaybeIO . E.getEnv +#endif + +getEnvDefault :: String -> String -> IO String +#ifndef mingw32_HOST_OS +getEnvDefault = PE.getEnvDefault +#else +getEnvDefault var fallback = fromMaybe fallback <$> getEnv var +#endif + +getEnvironment :: IO [(String, String)] +#ifndef mingw32_HOST_OS +getEnvironment = PE.getEnvironment +#else +getEnvironment = E.getEnvironment +#endif + +{- Returns True if it could successfully set the environment variable. + - + - There is, apparently, no way to do this in Windows. Instead, + - environment varuables must be provided when running a new process. -} +setEnv :: String -> String -> Bool -> IO Bool +#ifndef mingw32_HOST_OS +setEnv var val overwrite = do + PE.setEnv var val overwrite + return True +#else +setEnv _ _ _ = return False +#endif + +{- Returns True if it could successfully unset the environment variable. -} +unsetEnv :: String -> IO Bool +#ifndef mingw32_HOST_OS +unsetEnv var = do + PE.unsetEnv var + return True +#else +unsetEnv _ = return False +#endif + +{- Adds the environment variable to the input environment. If already + - present in the list, removes the old value. + - + - This does not really belong here, but Data.AssocList is for some reason + - buried inside hxt. + -} +addEntry :: Eq k => k -> v -> [(k, v)] -> [(k, v)] +addEntry k v l = ( (k,v) : ) $! delEntry k l + +addEntries :: Eq k => [(k, v)] -> [(k, v)] -> [(k, v)] +addEntries = foldr (.) id . map (uncurry addEntry) . reverse + +delEntry :: Eq k => k -> [(k, v)] -> [(k, v)] +delEntry _ [] = [] +delEntry k (x@(k1,_) : rest) + | k == k1 = rest + | otherwise = ( x : ) $! delEntry k rest diff --git a/Utility/Exception.hs b/Utility/Exception.hs new file mode 100644 index 0000000..cf2c615 --- /dev/null +++ b/Utility/Exception.hs @@ -0,0 +1,59 @@ +{- Simple IO exception handling (and some more) + - + - Copyright 2011-2012 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE ScopedTypeVariables #-} + +module Utility.Exception where + +import Control.Exception +import qualified Control.Exception as E +import Control.Applicative +import Control.Monad +import System.IO.Error (isDoesNotExistError) +import Utility.Data + +{- Catches IO errors and returns a Bool -} +catchBoolIO :: IO Bool -> IO Bool +catchBoolIO a = catchDefaultIO False a + +{- Catches IO errors and returns a Maybe -} +catchMaybeIO :: IO a -> IO (Maybe a) +catchMaybeIO a = catchDefaultIO Nothing $ Just <$> a + +{- Catches IO errors and returns a default value. -} +catchDefaultIO :: a -> IO a -> IO a +catchDefaultIO def a = catchIO a (const $ return def) + +{- Catches IO errors and returns the error message. -} +catchMsgIO :: IO a -> IO (Either String a) +catchMsgIO a = either (Left . show) Right <$> tryIO a + +{- catch specialized for IO errors only -} +catchIO :: IO a -> (IOException -> IO a) -> IO a +catchIO = E.catch + +{- try specialized for IO errors only -} +tryIO :: IO a -> IO (Either IOException a) +tryIO = try + +{- Catches all exceptions except for async exceptions. + - This is often better to use than catching them all, so that + - ThreadKilled and UserInterrupt get through. + -} +catchNonAsync :: IO a -> (SomeException -> IO a) -> IO a +catchNonAsync a onerr = a `catches` + [ Handler (\ (e :: AsyncException) -> throw e) + , Handler (\ (e :: SomeException) -> onerr e) + ] + +tryNonAsync :: IO a -> IO (Either SomeException a) +tryNonAsync a = (Right <$> a) `catchNonAsync` (return . Left) + +{- Catches only DoesNotExist exceptions, and lets all others through. -} +tryWhenExists :: IO a -> IO (Maybe a) +tryWhenExists a = eitherToMaybe <$> + tryJust (guard . isDoesNotExistError) a diff --git a/Utility/FileMode.hs b/Utility/FileMode.hs new file mode 100644 index 0000000..4302f8b --- /dev/null +++ b/Utility/FileMode.hs @@ -0,0 +1,157 @@ +{- File mode utilities. + - + - Copyright 2010-2012 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.FileMode where + +import System.IO +import Control.Monad +import Control.Exception (bracket) +import System.PosixCompat.Types +#ifndef mingw32_HOST_OS +import System.Posix.Files +#endif +import Foreign (complement) + +import Utility.Exception + +{- Applies a conversion function to a file's mode. -} +modifyFileMode :: FilePath -> (FileMode -> FileMode) -> IO () +modifyFileMode f convert = void $ modifyFileMode' f convert +modifyFileMode' :: FilePath -> (FileMode -> FileMode) -> IO FileMode +modifyFileMode' f convert = do + s <- getFileStatus f + let old = fileMode s + let new = convert old + when (new /= old) $ + setFileMode f new + return old + +{- Adds the specified FileModes to the input mode, leaving the rest + - unchanged. -} +addModes :: [FileMode] -> FileMode -> FileMode +addModes ms m = combineModes (m:ms) + +{- Removes the specified FileModes from the input mode. -} +removeModes :: [FileMode] -> FileMode -> FileMode +removeModes ms m = m `intersectFileModes` complement (combineModes ms) + +{- Runs an action after changing a file's mode, then restores the old mode. -} +withModifiedFileMode :: FilePath -> (FileMode -> FileMode) -> IO a -> IO a +withModifiedFileMode file convert a = bracket setup cleanup go + where + setup = modifyFileMode' file convert + cleanup oldmode = modifyFileMode file (const oldmode) + go _ = a + +writeModes :: [FileMode] +writeModes = [ownerWriteMode, groupWriteMode, otherWriteMode] + +readModes :: [FileMode] +readModes = [ownerReadMode, groupReadMode, otherReadMode] + +executeModes :: [FileMode] +executeModes = [ownerExecuteMode, groupExecuteMode, otherExecuteMode] + +otherGroupModes :: [FileMode] +otherGroupModes = + [ groupReadMode, otherReadMode + , groupWriteMode, otherWriteMode + ] + +{- Removes the write bits from a file. -} +preventWrite :: FilePath -> IO () +preventWrite f = modifyFileMode f $ removeModes writeModes + +{- Turns a file's owner write bit back on. -} +allowWrite :: FilePath -> IO () +allowWrite f = modifyFileMode f $ addModes [ownerWriteMode] + +{- Turns a file's owner read bit back on. -} +allowRead :: FilePath -> IO () +allowRead f = modifyFileMode f $ addModes [ownerReadMode] + +{- Allows owner and group to read and write to a file. -} +groupSharedModes :: [FileMode] +groupSharedModes = + [ ownerWriteMode, groupWriteMode + , ownerReadMode, groupReadMode + ] + +groupWriteRead :: FilePath -> IO () +groupWriteRead f = modifyFileMode f $ addModes groupSharedModes + +checkMode :: FileMode -> FileMode -> Bool +checkMode checkfor mode = checkfor `intersectFileModes` mode == checkfor + +{- Checks if a file mode indicates it's a symlink. -} +isSymLink :: FileMode -> Bool +#ifdef mingw32_HOST_OS +isSymLink _ = False +#else +isSymLink = checkMode symbolicLinkMode +#endif + +{- Checks if a file has any executable bits set. -} +isExecutable :: FileMode -> Bool +isExecutable mode = combineModes executeModes `intersectFileModes` mode /= 0 + +{- Runs an action without that pesky umask influencing it, unless the + - passed FileMode is the standard one. -} +noUmask :: FileMode -> IO a -> IO a +#ifndef mingw32_HOST_OS +noUmask mode a + | mode == stdFileMode = a + | otherwise = withUmask nullFileMode a +#else +noUmask _ a = a +#endif + +withUmask :: FileMode -> IO a -> IO a +#ifndef mingw32_HOST_OS +withUmask umask a = bracket setup cleanup go + where + setup = setFileCreationMask umask + cleanup = setFileCreationMask + go _ = a +#else +withUmask _ a = a +#endif + +combineModes :: [FileMode] -> FileMode +combineModes [] = undefined +combineModes [m] = m +combineModes (m:ms) = foldl unionFileModes m ms + +isSticky :: FileMode -> Bool +#ifdef mingw32_HOST_OS +isSticky _ = False +#else +isSticky = checkMode stickyMode + +stickyMode :: FileMode +stickyMode = 512 + +setSticky :: FilePath -> IO () +setSticky f = modifyFileMode f $ addModes [stickyMode] +#endif + +{- Writes a file, ensuring that its modes do not allow it to be read + - or written by anyone other than the current user, + - before any content is written. + - + - When possible, this is done using the umask. + - + - On a filesystem that does not support file permissions, this is the same + - as writeFile. + -} +writeFileProtected :: FilePath -> String -> IO () +writeFileProtected file content = withUmask 0o0077 $ + withFile file WriteMode $ \h -> do + void $ tryIO $ modifyFileMode file $ removeModes otherGroupModes + hPutStr h content diff --git a/Utility/FileSystemEncoding.hs b/Utility/FileSystemEncoding.hs new file mode 100644 index 0000000..690942c --- /dev/null +++ b/Utility/FileSystemEncoding.hs @@ -0,0 +1,132 @@ +{- GHC File system encoding handling. + - + - Copyright 2012-2014 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.FileSystemEncoding ( + fileEncoding, + withFilePath, + md5FilePath, + decodeBS, + decodeW8, + encodeW8, + truncateFilePath, +) where + +import qualified GHC.Foreign as GHC +import qualified GHC.IO.Encoding as Encoding +import Foreign.C +import System.IO +import System.IO.Unsafe +import qualified Data.Hash.MD5 as MD5 +import Data.Word +import Data.Bits.Utils +import qualified Data.ByteString.Lazy as L +#ifdef mingw32_HOST_OS +import qualified Data.ByteString.Lazy.UTF8 as L8 +#endif + +{- Sets a Handle to use the filesystem encoding. This causes data + - written or read from it to be encoded/decoded the same + - as ghc 7.4 does to filenames etc. This special encoding + - allows "arbitrary undecodable bytes to be round-tripped through it". + -} +fileEncoding :: Handle -> IO () +#ifndef mingw32_HOST_OS +fileEncoding h = hSetEncoding h =<< Encoding.getFileSystemEncoding +#else +{- The file system encoding does not work well on Windows, + - and Windows only has utf FilePaths anyway. -} +fileEncoding h = hSetEncoding h Encoding.utf8 +#endif + +{- Marshal a Haskell FilePath into a NUL terminated C string using temporary + - storage. The FilePath is encoded using the filesystem encoding, + - reversing the decoding that should have been done when the FilePath + - was obtained. -} +withFilePath :: FilePath -> (CString -> IO a) -> IO a +withFilePath fp f = Encoding.getFileSystemEncoding + >>= \enc -> GHC.withCString enc fp f + +{- Encodes a FilePath into a String, applying the filesystem encoding. + - + - There are very few things it makes sense to do with such an encoded + - string. It's not a legal filename; it should not be displayed. + - So this function is not exported, but instead used by the few functions + - that can usefully consume it. + - + - This use of unsafePerformIO is belived to be safe; GHC's interface + - only allows doing this conversion with CStrings, and the CString buffer + - is allocated, used, and deallocated within the call, with no side + - effects. + -} +{-# NOINLINE _encodeFilePath #-} +_encodeFilePath :: FilePath -> String +_encodeFilePath fp = unsafePerformIO $ do + enc <- Encoding.getFileSystemEncoding + GHC.withCString enc fp $ GHC.peekCString Encoding.char8 + +{- Encodes a FilePath into a Md5.Str, applying the filesystem encoding. -} +md5FilePath :: FilePath -> MD5.Str +md5FilePath = MD5.Str . _encodeFilePath + +{- Decodes a ByteString into a FilePath, applying the filesystem encoding. -} +decodeBS :: L.ByteString -> FilePath +#ifndef mingw32_HOST_OS +decodeBS = encodeW8 . L.unpack +#else +{- On Windows, we assume that the ByteString is utf-8, since Windows + - only uses unicode for filenames. -} +decodeBS = L8.toString +#endif + +{- Converts a [Word8] to a FilePath, encoding using the filesystem encoding. + - + - w82c produces a String, which may contain Chars that are invalid + - unicode. From there, this is really a simple matter of applying the + - file system encoding, only complicated by GHC's interface to doing so. + -} +{-# NOINLINE encodeW8 #-} +encodeW8 :: [Word8] -> FilePath +encodeW8 w8 = unsafePerformIO $ do + enc <- Encoding.getFileSystemEncoding + GHC.withCString Encoding.char8 (w82s w8) $ GHC.peekCString enc + +{- Useful when you want the actual number of bytes that will be used to + - represent the FilePath on disk. -} +decodeW8 :: FilePath -> [Word8] +decodeW8 = s2w8 . _encodeFilePath + +{- Truncates a FilePath to the given number of bytes (or less), + - as represented on disk. + - + - Avoids returning an invalid part of a unicode byte sequence, at the + - cost of efficiency when running on a large FilePath. + -} +truncateFilePath :: Int -> FilePath -> FilePath +#ifndef mingw32_HOST_OS +truncateFilePath n = go . reverse + where + go f = + let bytes = decodeW8 f + in if length bytes <= n + then reverse f + else go (drop 1 f) +#else +{- On Windows, count the number of bytes used by each utf8 character. -} +truncateFilePath n = reverse . go [] n . L8.fromString + where + go coll cnt bs + | cnt <= 0 = coll + | otherwise = case L8.decode bs of + Just (c, x) | c /= L8.replacement_char -> + let x' = fromIntegral x + in if cnt - x' < 0 + then coll + else go (c:coll) (cnt - x') (L8.drop 1 bs) + _ -> coll +#endif diff --git a/Utility/LinuxMkLibs.hs b/Utility/LinuxMkLibs.hs new file mode 100644 index 0000000..76e6266 --- /dev/null +++ b/Utility/LinuxMkLibs.hs @@ -0,0 +1,61 @@ +{- Linux library copier and binary shimmer + - + - Copyright 2013 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +module Utility.LinuxMkLibs where + +import Control.Applicative +import Data.Maybe +import System.Directory +import Data.List.Utils +import System.Posix.Files +import Data.Char +import Control.Monad.IfElse + +import Utility.PartialPrelude +import Utility.Directory +import Utility.Process +import Utility.Monad +import Utility.Path + +{- Installs a library. If the library is a symlink to another file, + - install the file it links to, and update the symlink to be relative. -} +installLib :: (FilePath -> FilePath -> IO ()) -> FilePath -> FilePath -> IO (Maybe FilePath) +installLib installfile top lib = ifM (doesFileExist lib) + ( do + installfile top lib + checksymlink lib + return $ Just $ parentDir lib + , return Nothing + ) + where + checksymlink f = whenM (isSymbolicLink <$> getSymbolicLinkStatus (inTop top f)) $ do + l <- readSymbolicLink (inTop top f) + let absl = absPathFrom (parentDir f) l + let target = relPathDirToFile (parentDir f) absl + installfile top absl + nukeFile (top ++ f) + createSymbolicLink target (inTop top f) + checksymlink absl + +-- Note that f is not relative, so cannot use +inTop :: FilePath -> FilePath -> FilePath +inTop top f = top ++ f + +{- Parse ldd output, getting all the libraries that the input files + - link to. Note that some of the libraries may not exist + - (eg, linux-vdso.so) -} +parseLdd :: String -> [FilePath] +parseLdd = catMaybes . map (getlib . dropWhile isSpace) . lines + where + getlib l = headMaybe . words =<< lastMaybe (split " => " l) + +{- Get all glibc libs and other support files, including gconv files + - + - XXX Debian specific. -} +glibcLibs :: IO [FilePath] +glibcLibs = lines <$> readProcess "sh" + ["-c", "dpkg -L libc6:$(dpkg --print-architecture) libgcc1:$(dpkg --print-architecture) | egrep '\\.so|gconv'"] diff --git a/Utility/Misc.hs b/Utility/Misc.hs new file mode 100644 index 0000000..9c19df8 --- /dev/null +++ b/Utility/Misc.hs @@ -0,0 +1,148 @@ +{- misc utility functions + - + - Copyright 2010-2011 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.Misc where + +import System.IO +import Control.Monad +import Foreign +import Data.Char +import Data.List +import Control.Applicative +import System.Exit +#ifndef mingw32_HOST_OS +import System.Posix.Process (getAnyProcessStatus) +import Utility.Exception +#endif + +import Utility.FileSystemEncoding +import Utility.Monad + +{- A version of hgetContents that is not lazy. Ensures file is + - all read before it gets closed. -} +hGetContentsStrict :: Handle -> IO String +hGetContentsStrict = hGetContents >=> \s -> length s `seq` return s + +{- A version of readFile that is not lazy. -} +readFileStrict :: FilePath -> IO String +readFileStrict = readFile >=> \s -> length s `seq` return s + +{- Reads a file strictly, and using the FileSystemEncoding, so it will + - never crash on a badly encoded file. -} +readFileStrictAnyEncoding :: FilePath -> IO String +readFileStrictAnyEncoding f = withFile f ReadMode $ \h -> do + fileEncoding h + hClose h `after` hGetContentsStrict h + +{- Writes a file, using the FileSystemEncoding so it will never crash + - on a badly encoded content string. -} +writeFileAnyEncoding :: FilePath -> String -> IO () +writeFileAnyEncoding f content = withFile f WriteMode $ \h -> do + fileEncoding h + hPutStr h content + +{- Like break, but the item matching the condition is not included + - in the second result list. + - + - separate (== ':') "foo:bar" = ("foo", "bar") + - separate (== ':') "foobar" = ("foobar", "") + -} +separate :: (a -> Bool) -> [a] -> ([a], [a]) +separate c l = unbreak $ break c l + where + unbreak r@(a, b) + | null b = r + | otherwise = (a, tail b) + +{- Breaks out the first line. -} +firstLine :: String -> String +firstLine = takeWhile (/= '\n') + +{- Splits a list into segments that are delimited by items matching + - a predicate. (The delimiters are not included in the segments.) + - Segments may be empty. -} +segment :: (a -> Bool) -> [a] -> [[a]] +segment p l = map reverse $ go [] [] l + where + go c r [] = reverse $ c:r + go c r (i:is) + | p i = go [] (c:r) is + | otherwise = go (i:c) r is + +prop_segment_regressionTest :: Bool +prop_segment_regressionTest = all id + -- Even an empty list is a segment. + [ segment (== "--") [] == [[]] + -- There are two segements in this list, even though the first is empty. + , segment (== "--") ["--", "foo", "bar"] == [[],["foo","bar"]] + ] + +{- Includes the delimiters as segments of their own. -} +segmentDelim :: (a -> Bool) -> [a] -> [[a]] +segmentDelim p l = map reverse $ go [] [] l + where + go c r [] = reverse $ c:r + go c r (i:is) + | p i = go [] ([i]:c:r) is + | otherwise = go (i:c) r is + +{- Replaces multiple values in a string. + - + - Takes care to skip over just-replaced values, so that they are not + - mangled. For example, massReplace [("foo", "new foo")] does not + - replace the "new foo" with "new new foo". + -} +massReplace :: [(String, String)] -> String -> String +massReplace vs = go [] vs + where + + go acc _ [] = concat $ reverse acc + go acc [] (c:cs) = go ([c]:acc) vs cs + go acc ((val, replacement):rest) s + | val `isPrefixOf` s = + go (replacement:acc) vs (drop (length val) s) + | otherwise = go acc rest s + +{- Wrapper around hGetBufSome that returns a String. + - + - The null string is returned on eof, otherwise returns whatever + - data is currently available to read from the handle, or waits for + - data to be written to it if none is currently available. + - + - Note on encodings: The normal encoding of the Handle is ignored; + - each byte is converted to a Char. Not unicode clean! + -} +hGetSomeString :: Handle -> Int -> IO String +hGetSomeString h sz = do + fp <- mallocForeignPtrBytes sz + len <- withForeignPtr fp $ \buf -> hGetBufSome h buf sz + map (chr . fromIntegral) <$> withForeignPtr fp (peekbytes len) + where + peekbytes :: Int -> Ptr Word8 -> IO [Word8] + peekbytes len buf = mapM (peekElemOff buf) [0..pred len] + +{- Reaps any zombie git processes. + - + - Warning: Not thread safe. Anything that was expecting to wait + - on a process and get back an exit status is going to be confused + - if this reap gets there first. -} +reapZombies :: IO () +#ifndef mingw32_HOST_OS +reapZombies = do + -- throws an exception when there are no child processes + catchDefaultIO Nothing (getAnyProcessStatus False True) + >>= maybe (return ()) (const reapZombies) + +#else +reapZombies = return () +#endif + +exitBool :: Bool -> IO a +exitBool False = exitFailure +exitBool True = exitSuccess diff --git a/Utility/Monad.hs b/Utility/Monad.hs new file mode 100644 index 0000000..1ba43c5 --- /dev/null +++ b/Utility/Monad.hs @@ -0,0 +1,69 @@ +{- monadic stuff + - + - Copyright 2010-2012 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +module Utility.Monad where + +import Data.Maybe +import Control.Monad + +{- Return the first value from a list, if any, satisfying the given + - predicate -} +firstM :: Monad m => (a -> m Bool) -> [a] -> m (Maybe a) +firstM _ [] = return Nothing +firstM p (x:xs) = ifM (p x) (return $ Just x , firstM p xs) + +{- Runs the action on values from the list until it succeeds, returning + - its result. -} +getM :: Monad m => (a -> m (Maybe b)) -> [a] -> m (Maybe b) +getM _ [] = return Nothing +getM p (x:xs) = maybe (getM p xs) (return . Just) =<< p x + +{- Returns true if any value in the list satisfies the predicate, + - stopping once one is found. -} +anyM :: Monad m => (a -> m Bool) -> [a] -> m Bool +anyM p = liftM isJust . firstM p + +allM :: Monad m => (a -> m Bool) -> [a] -> m Bool +allM _ [] = return True +allM p (x:xs) = p x <&&> allM p xs + +{- Runs an action on values from a list until it succeeds. -} +untilTrue :: Monad m => [a] -> (a -> m Bool) -> m Bool +untilTrue = flip anyM + +{- if with a monadic conditional. -} +ifM :: Monad m => m Bool -> (m a, m a) -> m a +ifM cond (thenclause, elseclause) = do + c <- cond + if c then thenclause else elseclause + +{- short-circuiting monadic || -} +(<||>) :: Monad m => m Bool -> m Bool -> m Bool +ma <||> mb = ifM ma ( return True , mb ) + +{- short-circuiting monadic && -} +(<&&>) :: Monad m => m Bool -> m Bool -> m Bool +ma <&&> mb = ifM ma ( mb , return False ) + +{- Same fixity as && and || -} +infixr 3 <&&> +infixr 2 <||> + +{- Runs an action, passing its value to an observer before returning it. -} +observe :: Monad m => (a -> m b) -> m a -> m a +observe observer a = do + r <- a + _ <- observer r + return r + +{- b `after` a runs first a, then b, and returns the value of a -} +after :: Monad m => m b -> m a -> m a +after = observe . const + +{- do nothing -} +noop :: Monad m => m () +noop = return () diff --git a/Utility/PartialPrelude.hs b/Utility/PartialPrelude.hs new file mode 100644 index 0000000..6efa093 --- /dev/null +++ b/Utility/PartialPrelude.hs @@ -0,0 +1,68 @@ +{- Parts of the Prelude are partial functions, which are a common source of + - bugs. + - + - This exports functions that conflict with the prelude, which avoids + - them being accidentially used. + -} + +module Utility.PartialPrelude where + +import qualified Data.Maybe + +{- read should be avoided, as it throws an error + - Instead, use: readish -} +read :: Read a => String -> a +read = Prelude.read + +{- head is a partial function; head [] is an error + - Instead, use: take 1 or headMaybe -} +head :: [a] -> a +head = Prelude.head + +{- tail is also partial + - Instead, use: drop 1 -} +tail :: [a] -> [a] +tail = Prelude.tail + +{- init too + - Instead, use: beginning -} +init :: [a] -> [a] +init = Prelude.init + +{- last too + - Instead, use: end or lastMaybe -} +last :: [a] -> a +last = Prelude.last + +{- Attempts to read a value from a String. + - + - Ignores leading/trailing whitespace, and throws away any trailing + - text after the part that can be read. + - + - readMaybe is available in Text.Read in new versions of GHC, + - but that one requires the entire string to be consumed. + -} +readish :: Read a => String -> Maybe a +readish s = case reads s of + ((x,_):_) -> Just x + _ -> Nothing + +{- Like head but Nothing on empty list. -} +headMaybe :: [a] -> Maybe a +headMaybe = Data.Maybe.listToMaybe + +{- Like last but Nothing on empty list. -} +lastMaybe :: [a] -> Maybe a +lastMaybe [] = Nothing +lastMaybe v = Just $ Prelude.last v + +{- All but the last element of a list. + - (Like init, but no error on an empty list.) -} +beginning :: [a] -> [a] +beginning [] = [] +beginning l = Prelude.init l + +{- Like last, but no error on an empty list. -} +end :: [a] -> [a] +end [] = [] +end l = [Prelude.last l] diff --git a/Utility/Path.hs b/Utility/Path.hs new file mode 100644 index 0000000..570350d --- /dev/null +++ b/Utility/Path.hs @@ -0,0 +1,293 @@ +{- path manipulation + - + - Copyright 2010-2014 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE PackageImports, CPP #-} + +module Utility.Path where + +import Data.String.Utils +import System.FilePath +import System.Directory +import Data.List +import Data.Maybe +import Data.Char +import Control.Applicative + +#ifdef mingw32_HOST_OS +import qualified System.FilePath.Posix as Posix +#else +import System.Posix.Files +#endif + +import qualified "MissingH" System.Path as MissingH +import Utility.Monad +import Utility.UserInfo + +{- Simplifies a path, removing any ".." or ".", and removing the trailing + - path separator. + - + - On Windows, preserves whichever style of path separator might be used in + - the input FilePaths. This is done because some programs in Windows + - demand a particular path separator -- and which one actually varies! + - + - This does not guarantee that two paths that refer to the same location, + - and are both relative to the same location (or both absolute) will + - yeild the same result. Run both through normalise from System.FilePath + - to ensure that. + -} +simplifyPath :: FilePath -> FilePath +simplifyPath path = dropTrailingPathSeparator $ + joinDrive drive $ joinPath $ norm [] $ splitPath path' + where + (drive, path') = splitDrive path + + norm c [] = reverse c + norm c (p:ps) + | p' == ".." = norm (drop 1 c) ps + | p' == "." = norm c ps + | otherwise = norm (p:c) ps + where + p' = dropTrailingPathSeparator p + +{- Makes a path absolute. + - + - The first parameter is a base directory (ie, the cwd) to use if the path + - is not already absolute. + - + - Does not attempt to deal with edge cases or ensure security with + - untrusted inputs. + -} +absPathFrom :: FilePath -> FilePath -> FilePath +absPathFrom dir path = simplifyPath (combine dir path) + +{- On Windows, this converts the paths to unix-style, in order to run + - MissingH's absNormPath on them. Resulting path will use / separators. -} +absNormPathUnix :: FilePath -> FilePath -> Maybe FilePath +#ifndef mingw32_HOST_OS +absNormPathUnix dir path = MissingH.absNormPath dir path +#else +absNormPathUnix dir path = todos <$> MissingH.absNormPath (fromdos dir) (fromdos path) + where + fromdos = replace "\\" "/" + todos = replace "/" "\\" +#endif + +{- Returns the parent directory of a path. + - + - To allow this to be easily used in loops, which terminate upon reaching the + - top, the parent of / is "" -} +parentDir :: FilePath -> FilePath +parentDir dir + | null dirs = "" + | otherwise = joinDrive drive (join s $ init dirs) + where + -- on Unix, the drive will be "/" when the dir is absolute, otherwise "" + (drive, path) = splitDrive dir + dirs = filter (not . null) $ split s path + s = [pathSeparator] + +prop_parentDir_basics :: FilePath -> Bool +prop_parentDir_basics dir + | null dir = True + | dir == "/" = parentDir dir == "" + | otherwise = p /= dir + where + p = parentDir dir + +{- Checks if the first FilePath is, or could be said to contain the second. + - For example, "foo/" contains "foo/bar". Also, "foo", "./foo", "foo/" etc + - are all equivilant. + -} +dirContains :: FilePath -> FilePath -> Bool +dirContains a b = a == b || a' == b' || (addTrailingPathSeparator a') `isPrefixOf` b' + where + a' = norm a + b' = norm b + norm = normalise . simplifyPath + +{- Converts a filename into an absolute path. + - + - Unlike Directory.canonicalizePath, this does not require the path + - already exists. -} +absPath :: FilePath -> IO FilePath +absPath file = do + cwd <- getCurrentDirectory + return $ absPathFrom cwd file + +{- Constructs a relative path from the CWD to a file. + - + - For example, assuming CWD is /tmp/foo/bar: + - relPathCwdToFile "/tmp/foo" == ".." + - relPathCwdToFile "/tmp/foo/bar" == "" + -} +relPathCwdToFile :: FilePath -> IO FilePath +relPathCwdToFile f = relPathDirToFile <$> getCurrentDirectory <*> absPath f + +{- Constructs a relative path from a directory to a file. + - + - Both must be absolute, and cannot contain .. etc. (eg use absPath first). + -} +relPathDirToFile :: FilePath -> FilePath -> FilePath +relPathDirToFile from to = join s $ dotdots ++ uncommon + where + s = [pathSeparator] + pfrom = split s from + pto = split s to + common = map fst $ takeWhile same $ zip pfrom pto + same (c,d) = c == d + uncommon = drop numcommon pto + dotdots = replicate (length pfrom - numcommon) ".." + numcommon = length common + +prop_relPathDirToFile_basics :: FilePath -> FilePath -> Bool +prop_relPathDirToFile_basics from to + | from == to = null r + | otherwise = not (null r) + where + r = relPathDirToFile from to + +prop_relPathDirToFile_regressionTest :: Bool +prop_relPathDirToFile_regressionTest = same_dir_shortcurcuits_at_difference + where + {- Two paths have the same directory component at the same + - location, but it's not really the same directory. + - Code used to get this wrong. -} + same_dir_shortcurcuits_at_difference = + relPathDirToFile (joinPath [pathSeparator : "tmp", "r", "lll", "xxx", "yyy", "18"]) + (joinPath [pathSeparator : "tmp", "r", ".git", "annex", "objects", "18", "gk", "SHA256-foo", "SHA256-foo"]) + == joinPath ["..", "..", "..", "..", ".git", "annex", "objects", "18", "gk", "SHA256-foo", "SHA256-foo"] + +{- Given an original list of paths, and an expanded list derived from it, + - generates a list of lists, where each sublist corresponds to one of the + - original paths. When the original path is a directory, any items + - in the expanded list that are contained in that directory will appear in + - its segment. + -} +segmentPaths :: [FilePath] -> [FilePath] -> [[FilePath]] +segmentPaths [] new = [new] +segmentPaths [_] new = [new] -- optimisation +segmentPaths (l:ls) new = [found] ++ segmentPaths ls rest + where + (found, rest)=partition (l `dirContains`) new + +{- This assumes that it's cheaper to call segmentPaths on the result, + - than it would be to run the action separately with each path. In + - the case of git file list commands, that assumption tends to hold. + -} +runSegmentPaths :: ([FilePath] -> IO [FilePath]) -> [FilePath] -> IO [[FilePath]] +runSegmentPaths a paths = segmentPaths paths <$> a paths + +{- Converts paths in the home directory to use ~/ -} +relHome :: FilePath -> IO String +relHome path = do + home <- myHomeDir + return $ if dirContains home path + then "~/" ++ relPathDirToFile home path + else path + +{- Checks if a command is available in PATH. + - + - The command may be fully-qualified, in which case, this succeeds as + - long as it exists. -} +inPath :: String -> IO Bool +inPath command = isJust <$> searchPath command + +{- Finds a command in PATH and returns the full path to it. + - + - The command may be fully qualified already, in which case it will + - be returned if it exists. + -} +searchPath :: String -> IO (Maybe FilePath) +searchPath command + | isAbsolute command = check command + | otherwise = getSearchPath >>= getM indir + where + indir d = check $ d command + check f = firstM doesFileExist +#ifdef mingw32_HOST_OS + [f, f ++ ".exe"] +#else + [f] +#endif + +{- Checks if a filename is a unix dotfile. All files inside dotdirs + - count as dotfiles. -} +dotfile :: FilePath -> Bool +dotfile file + | f == "." = False + | f == ".." = False + | f == "" = False + | otherwise = "." `isPrefixOf` f || dotfile (takeDirectory file) + where + f = takeFileName file + +{- Converts a DOS style path to a Cygwin style path. Only on Windows. + - Any trailing '\' is preserved as a trailing '/' -} +toCygPath :: FilePath -> FilePath +#ifndef mingw32_HOST_OS +toCygPath = id +#else +toCygPath p + | null drive = recombine parts + | otherwise = recombine $ "/cygdrive" : driveletter drive : parts + where + (drive, p') = splitDrive p + parts = splitDirectories p' + driveletter = map toLower . takeWhile (/= ':') + recombine = fixtrailing . Posix.joinPath + fixtrailing s + | hasTrailingPathSeparator p = Posix.addTrailingPathSeparator s + | otherwise = s +#endif + +{- Maximum size to use for a file in a specified directory. + - + - Many systems have a 255 byte limit to the name of a file, + - so that's taken as the max if the system has a larger limit, or has no + - limit. + -} +fileNameLengthLimit :: FilePath -> IO Int +#ifdef mingw32_HOST_OS +fileNameLengthLimit _ = return 255 +#else +fileNameLengthLimit dir = do + l <- fromIntegral <$> getPathVar dir FileNameLimit + if l <= 0 + then return 255 + else return $ minimum [l, 255] + where +#endif + +{- Given a string that we'd like to use as the basis for FilePath, but that + - was provided by a third party and is not to be trusted, returns the closest + - sane FilePath. + - + - All spaces and punctuation and other wacky stuff are replaced + - with '_', except for '.' "../" will thus turn into ".._", which is safe. + -} +sanitizeFilePath :: String -> FilePath +sanitizeFilePath = map sanitize + where + sanitize c + | c == '.' = c + | isSpace c || isPunctuation c || isSymbol c || isControl c || c == '/' = '_' + | otherwise = c + +{- Similar to splitExtensions, but knows that some things in FilePaths + - after a dot are too long to be extensions. -} +splitShortExtensions :: FilePath -> (FilePath, [String]) +splitShortExtensions = splitShortExtensions' 5 -- enough for ".jpeg" +splitShortExtensions' :: Int -> FilePath -> (FilePath, [String]) +splitShortExtensions' maxextension = go [] + where + go c f + | len > 0 && len <= maxextension && not (null base) = + go (ext:c) base + | otherwise = (f, c) + where + (base, ext) = splitExtension f + len = length ext diff --git a/Utility/PosixFiles.hs b/Utility/PosixFiles.hs new file mode 100644 index 0000000..23edc25 --- /dev/null +++ b/Utility/PosixFiles.hs @@ -0,0 +1,33 @@ +{- POSIX files (and compatablity wrappers). + - + - This is like System.PosixCompat.Files, except with a fixed rename. + - + - Copyright 2014 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.PosixFiles ( + module X, + rename +) where + +import System.PosixCompat.Files as X hiding (rename) + +#ifndef mingw32_HOST_OS +import System.Posix.Files (rename) +#else +import qualified System.Win32.File as Win32 +#endif + +{- System.PosixCompat.Files.rename on Windows calls renameFile, + - so cannot rename directories. + - + - Instead, use Win32 moveFile, which can. It needs to be told to overwrite + - any existing file. -} +#ifdef mingw32_HOST_OS +rename :: FilePath -> FilePath -> IO () +rename src dest = Win32.moveFileEx src dest Win32.mOVEFILE_REPLACE_EXISTING +#endif diff --git a/Utility/Process.hs b/Utility/Process.hs new file mode 100644 index 0000000..1945e4b --- /dev/null +++ b/Utility/Process.hs @@ -0,0 +1,360 @@ +{- System.Process enhancements, including additional ways of running + - processes, and logging. + - + - Copyright 2012 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP, Rank2Types #-} + +module Utility.Process ( + module X, + CreateProcess, + StdHandle(..), + readProcess, + readProcessEnv, + writeReadProcessEnv, + forceSuccessProcess, + checkSuccessProcess, + ignoreFailureProcess, + createProcessSuccess, + createProcessChecked, + createBackgroundProcess, + processTranscript, + processTranscript', + withHandle, + withBothHandles, + withQuietOutput, + createProcess, + startInteractiveProcess, + stdinHandle, + stdoutHandle, + stderrHandle, + devNull, +) where + +import qualified System.Process +import System.Process as X hiding (CreateProcess(..), createProcess, runInteractiveProcess, readProcess, readProcessWithExitCode, system, rawSystem, runInteractiveCommand, runProcess) +import System.Process hiding (createProcess, readProcess) +import System.Exit +import System.IO +import System.Log.Logger +import Control.Concurrent +import qualified Control.Exception as E +import Control.Monad +#ifndef mingw32_HOST_OS +import System.Posix.IO +#else +import Control.Applicative +#endif +import Data.Maybe + +import Utility.Misc +import Utility.Exception + +type CreateProcessRunner = forall a. CreateProcess -> ((Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> IO a) -> IO a + +data StdHandle = StdinHandle | StdoutHandle | StderrHandle + deriving (Eq) + +{- Normally, when reading from a process, it does not need to be fed any + - standard input. -} +readProcess :: FilePath -> [String] -> IO String +readProcess cmd args = readProcessEnv cmd args Nothing + +readProcessEnv :: FilePath -> [String] -> Maybe [(String, String)] -> IO String +readProcessEnv cmd args environ = + withHandle StdoutHandle createProcessSuccess p $ \h -> do + output <- hGetContentsStrict h + hClose h + return output + where + p = (proc cmd args) + { std_out = CreatePipe + , env = environ + } + +{- Runs an action to write to a process on its stdin, + - returns its output, and also allows specifying the environment. + -} +writeReadProcessEnv + :: FilePath + -> [String] + -> Maybe [(String, String)] + -> (Maybe (Handle -> IO ())) + -> (Maybe (Handle -> IO ())) + -> IO String +writeReadProcessEnv cmd args environ writestdin adjusthandle = do + (Just inh, Just outh, _, pid) <- createProcess p + + maybe (return ()) (\a -> a inh) adjusthandle + maybe (return ()) (\a -> a outh) adjusthandle + + -- fork off a thread to start consuming the output + output <- hGetContents outh + outMVar <- newEmptyMVar + _ <- forkIO $ E.evaluate (length output) >> putMVar outMVar () + + -- now write and flush any input + maybe (return ()) (\a -> a inh >> hFlush inh) writestdin + hClose inh -- done with stdin + + -- wait on the output + takeMVar outMVar + hClose outh + + -- wait on the process + forceSuccessProcess p pid + + return output + + where + p = (proc cmd args) + { std_in = CreatePipe + , std_out = CreatePipe + , std_err = Inherit + , env = environ + } + +{- Waits for a ProcessHandle, and throws an IOError if the process + - did not exit successfully. -} +forceSuccessProcess :: CreateProcess -> ProcessHandle -> IO () +forceSuccessProcess p pid = do + code <- waitForProcess pid + case code of + ExitSuccess -> return () + ExitFailure n -> fail $ showCmd p ++ " exited " ++ show n + +{- Waits for a ProcessHandle and returns True if it exited successfully. + - Note that using this with createProcessChecked will throw away + - the Bool, and is only useful to ignore the exit code of a process, + - while still waiting for it. -} +checkSuccessProcess :: ProcessHandle -> IO Bool +checkSuccessProcess pid = do + code <- waitForProcess pid + return $ code == ExitSuccess + +ignoreFailureProcess :: ProcessHandle -> IO Bool +ignoreFailureProcess pid = do + void $ waitForProcess pid + return True + +{- Runs createProcess, then an action on its handles, and then + - forceSuccessProcess. -} +createProcessSuccess :: CreateProcessRunner +createProcessSuccess p a = createProcessChecked (forceSuccessProcess p) p a + +{- Runs createProcess, then an action on its handles, and then + - a checker action on its exit code, which must wait for the process. -} +createProcessChecked :: (ProcessHandle -> IO b) -> CreateProcessRunner +createProcessChecked checker p a = do + t@(_, _, _, pid) <- createProcess p + r <- tryNonAsync $ a t + _ <- checker pid + either E.throw return r + +{- Leaves the process running, suitable for lazy streaming. + - Note: Zombies will result, and must be waited on. -} +createBackgroundProcess :: CreateProcessRunner +createBackgroundProcess p a = a =<< createProcess p + +{- Runs a process, optionally feeding it some input, and + - returns a transcript combining its stdout and stderr, and + - whether it succeeded or failed. -} +processTranscript :: String -> [String] -> (Maybe String) -> IO (String, Bool) +processTranscript cmd opts input = processTranscript' cmd opts Nothing input + +processTranscript' :: String -> [String] -> Maybe [(String, String)] -> (Maybe String) -> IO (String, Bool) +#ifndef mingw32_HOST_OS +{- This implementation interleves stdout and stderr in exactly the order + - the process writes them. -} +processTranscript' cmd opts environ input = do + (readf, writef) <- createPipe + readh <- fdToHandle readf + writeh <- fdToHandle writef + p@(_, _, _, pid) <- createProcess $ + (proc cmd opts) + { std_in = if isJust input then CreatePipe else Inherit + , std_out = UseHandle writeh + , std_err = UseHandle writeh + , env = environ + } + hClose writeh + + get <- mkreader readh + + -- now write and flush any input + case input of + Just s -> do + let inh = stdinHandle p + unless (null s) $ do + hPutStr inh s + hFlush inh + hClose inh + Nothing -> return () + + transcript <- get + + ok <- checkSuccessProcess pid + return (transcript, ok) +#else +{- This implementation for Windows puts stderr after stdout. -} +processTranscript' cmd opts environ input = do + p@(_, _, _, pid) <- createProcess $ + (proc cmd opts) + { std_in = if isJust input then CreatePipe else Inherit + , std_out = CreatePipe + , std_err = CreatePipe + , env = environ + } + + getout <- mkreader (stdoutHandle p) + geterr <- mkreader (stderrHandle p) + + case input of + Just s -> do + let inh = stdinHandle p + unless (null s) $ do + hPutStr inh s + hFlush inh + hClose inh + Nothing -> return () + + transcript <- (++) <$> getout <*> geterr + ok <- checkSuccessProcess pid + return (transcript, ok) +#endif + where + mkreader h = do + s <- hGetContents h + v <- newEmptyMVar + void $ forkIO $ do + void $ E.evaluate (length s) + putMVar v () + return $ do + takeMVar v + return s + +{- Runs a CreateProcessRunner, on a CreateProcess structure, that + - is adjusted to pipe only from/to a single StdHandle, and passes + - the resulting Handle to an action. -} +withHandle + :: StdHandle + -> CreateProcessRunner + -> CreateProcess + -> (Handle -> IO a) + -> IO a +withHandle h creator p a = creator p' $ a . select + where + base = p + { std_in = Inherit + , std_out = Inherit + , std_err = Inherit + } + (select, p') + | h == StdinHandle = + (stdinHandle, base { std_in = CreatePipe }) + | h == StdoutHandle = + (stdoutHandle, base { std_out = CreatePipe }) + | h == StderrHandle = + (stderrHandle, base { std_err = CreatePipe }) + +{- Like withHandle, but passes (stdin, stdout) handles to the action. -} +withBothHandles + :: CreateProcessRunner + -> CreateProcess + -> ((Handle, Handle) -> IO a) + -> IO a +withBothHandles creator p a = creator p' $ a . bothHandles + where + p' = p + { std_in = CreatePipe + , std_out = CreatePipe + , std_err = Inherit + } + +{- Forces the CreateProcessRunner to run quietly; + - both stdout and stderr are discarded. -} +withQuietOutput + :: CreateProcessRunner + -> CreateProcess + -> IO () +withQuietOutput creator p = withFile devNull WriteMode $ \nullh -> do + let p' = p + { std_out = UseHandle nullh + , std_err = UseHandle nullh + } + creator p' $ const $ return () + +devNull :: FilePath +#ifndef mingw32_HOST_OS +devNull = "/dev/null" +#else +devNull = "NUL" +#endif + +{- Extract a desired handle from createProcess's tuple. + - These partial functions are safe as long as createProcess is run + - with appropriate parameters to set up the desired handle. + - Get it wrong and the runtime crash will always happen, so should be + - easily noticed. -} +type HandleExtractor = (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> Handle +stdinHandle :: HandleExtractor +stdinHandle (Just h, _, _, _) = h +stdinHandle _ = error "expected stdinHandle" +stdoutHandle :: HandleExtractor +stdoutHandle (_, Just h, _, _) = h +stdoutHandle _ = error "expected stdoutHandle" +stderrHandle :: HandleExtractor +stderrHandle (_, _, Just h, _) = h +stderrHandle _ = error "expected stderrHandle" +bothHandles :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> (Handle, Handle) +bothHandles (Just hin, Just hout, _, _) = (hin, hout) +bothHandles _ = error "expected bothHandles" + +{- Debugging trace for a CreateProcess. -} +debugProcess :: CreateProcess -> IO () +debugProcess p = do + debugM "Utility.Process" $ unwords + [ action ++ ":" + , showCmd p + ] + where + action + | piped (std_in p) && piped (std_out p) = "chat" + | piped (std_in p) = "feed" + | piped (std_out p) = "read" + | otherwise = "call" + piped Inherit = False + piped _ = True + +{- Shows the command that a CreateProcess will run. -} +showCmd :: CreateProcess -> String +showCmd = go . cmdspec + where + go (ShellCommand s) = s + go (RawCommand c ps) = c ++ " " ++ show ps + +{- Starts an interactive process. Unlike runInteractiveProcess in + - System.Process, stderr is inherited. -} +startInteractiveProcess + :: FilePath + -> [String] + -> Maybe [(String, String)] + -> IO (ProcessHandle, Handle, Handle) +startInteractiveProcess cmd args environ = do + let p = (proc cmd args) + { std_in = CreatePipe + , std_out = CreatePipe + , std_err = Inherit + , env = environ + } + (Just from, Just to, _, pid) <- createProcess p + return (pid, to, from) + +{- Wrapper around System.Process function that does debug logging. -} +createProcess :: CreateProcess -> IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) +createProcess p = do + debugProcess p + System.Process.createProcess p diff --git a/Utility/QuickCheck.hs b/Utility/QuickCheck.hs new file mode 100644 index 0000000..7f7234c --- /dev/null +++ b/Utility/QuickCheck.hs @@ -0,0 +1,52 @@ +{- QuickCheck with additional instances + - + - Copyright 2012-2014 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# OPTIONS_GHC -fno-warn-orphans #-} +{-# LANGUAGE TypeSynonymInstances #-} + +module Utility.QuickCheck + ( module X + , module Utility.QuickCheck + ) where + +import Test.QuickCheck as X +import Data.Time.Clock.POSIX +import System.Posix.Types +import qualified Data.Map as M +import qualified Data.Set as S +import Control.Applicative + +instance (Arbitrary k, Arbitrary v, Eq k, Ord k) => Arbitrary (M.Map k v) where + arbitrary = M.fromList <$> arbitrary + +instance (Arbitrary v, Eq v, Ord v) => Arbitrary (S.Set v) where + arbitrary = S.fromList <$> arbitrary + +{- Times before the epoch are excluded. -} +instance Arbitrary POSIXTime where + arbitrary = fromInteger <$> nonNegative arbitrarySizedIntegral + +instance Arbitrary EpochTime where + arbitrary = fromInteger <$> nonNegative arbitrarySizedIntegral + +{- Pids are never negative, or 0. -} +instance Arbitrary ProcessID where + arbitrary = arbitrarySizedBoundedIntegral `suchThat` (> 0) + +{- Inodes are never negative. -} +instance Arbitrary FileID where + arbitrary = nonNegative arbitrarySizedIntegral + +{- File sizes are never negative. -} +instance Arbitrary FileOffset where + arbitrary = nonNegative arbitrarySizedIntegral + +nonNegative :: (Num a, Ord a) => Gen a -> Gen a +nonNegative g = g `suchThat` (>= 0) + +positive :: (Num a, Ord a) => Gen a -> Gen a +positive g = g `suchThat` (> 0) diff --git a/Utility/SafeCommand.hs b/Utility/SafeCommand.hs new file mode 100644 index 0000000..c8318ec --- /dev/null +++ b/Utility/SafeCommand.hs @@ -0,0 +1,120 @@ +{- safely running shell commands + - + - Copyright 2010-2013 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +module Utility.SafeCommand where + +import System.Exit +import Utility.Process +import System.Process (env) +import Data.String.Utils +import Control.Applicative +import System.FilePath +import Data.Char + +{- A type for parameters passed to a shell command. A command can + - be passed either some Params (multiple parameters can be included, + - whitespace-separated, or a single Param (for when parameters contain + - whitespace), or a File. + -} +data CommandParam = Params String | Param String | File FilePath + deriving (Eq, Show, Ord) + +{- Used to pass a list of CommandParams to a function that runs + - a command and expects Strings. -} +toCommand :: [CommandParam] -> [String] +toCommand = concatMap unwrap + where + unwrap (Param s) = [s] + unwrap (Params s) = filter (not . null) (split " " s) + -- Files that start with a non-alphanumeric that is not a path + -- separator are modified to avoid the command interpreting them as + -- options or other special constructs. + unwrap (File s@(h:_)) + | isAlphaNum h || h `elem` pathseps = [s] + | otherwise = ["./" ++ s] + unwrap (File s) = [s] + -- '/' is explicitly included because it's an alternative + -- path separator on Windows. + pathseps = pathSeparator:"./" + +{- Run a system command, and returns True or False + - if it succeeded or failed. + -} +boolSystem :: FilePath -> [CommandParam] -> IO Bool +boolSystem command params = boolSystemEnv command params Nothing + +boolSystemEnv :: FilePath -> [CommandParam] -> Maybe [(String, String)] -> IO Bool +boolSystemEnv command params environ = dispatch <$> safeSystemEnv command params environ + where + dispatch ExitSuccess = True + dispatch _ = False + +{- Runs a system command, returning the exit status. -} +safeSystem :: FilePath -> [CommandParam] -> IO ExitCode +safeSystem command params = safeSystemEnv command params Nothing + +safeSystemEnv :: FilePath -> [CommandParam] -> Maybe [(String, String)] -> IO ExitCode +safeSystemEnv command params environ = do + (_, _, _, pid) <- createProcess (proc command $ toCommand params) + { env = environ } + waitForProcess pid + +{- Wraps a shell command line inside sh -c, allowing it to be run in a + - login shell that may not support POSIX shell, eg csh. -} +shellWrap :: String -> String +shellWrap cmdline = "sh -c " ++ shellEscape cmdline + +{- Escapes a filename or other parameter to be safely able to be exposed to + - the shell. + - + - This method works for POSIX shells, as well as other shells like csh. + -} +shellEscape :: String -> String +shellEscape f = "'" ++ escaped ++ "'" + where + -- replace ' with '"'"' + escaped = join "'\"'\"'" $ split "'" f + +{- Unescapes a set of shellEscaped words or filenames. -} +shellUnEscape :: String -> [String] +shellUnEscape [] = [] +shellUnEscape s = word : shellUnEscape rest + where + (word, rest) = findword "" s + findword w [] = (w, "") + findword w (c:cs) + | c == ' ' = (w, cs) + | c == '\'' = inquote c w cs + | c == '"' = inquote c w cs + | otherwise = findword (w++[c]) cs + inquote _ w [] = (w, "") + inquote q w (c:cs) + | c == q = findword w cs + | otherwise = inquote q (w++[c]) cs + +{- For quickcheck. -} +prop_idempotent_shellEscape :: String -> Bool +prop_idempotent_shellEscape s = [s] == (shellUnEscape . shellEscape) s +prop_idempotent_shellEscape_multiword :: [String] -> Bool +prop_idempotent_shellEscape_multiword s = s == (shellUnEscape . unwords . map shellEscape) s + +{- Segements a list of filenames into groups that are all below the manximum + - command-line length limit. Does not preserve order. -} +segmentXargs :: [FilePath] -> [[FilePath]] +segmentXargs l = go l [] 0 [] + where + go [] c _ r = c:r + go (f:fs) c accumlen r + | len < maxlen && newlen > maxlen = go (f:fs) [] 0 (c:r) + | otherwise = go fs (f:c) newlen r + where + len = length f + newlen = accumlen + len + + {- 10k of filenames per command, well under Linux's 20k limit; + - allows room for other parameters etc. -} + maxlen = 10240 diff --git a/Utility/Scheduled.hs b/Utility/Scheduled.hs new file mode 100644 index 0000000..11e3b56 --- /dev/null +++ b/Utility/Scheduled.hs @@ -0,0 +1,358 @@ +{- scheduled activities + - + - Copyright 2013 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +module Utility.Scheduled ( + Schedule(..), + Recurrance(..), + ScheduledTime(..), + NextTime(..), + WeekDay, + MonthDay, + YearDay, + nextTime, + startTime, + fromSchedule, + fromScheduledTime, + toScheduledTime, + fromRecurrance, + toRecurrance, + toSchedule, + parseSchedule, + prop_schedule_roundtrips +) where + +import Utility.Data +import Utility.QuickCheck +import Utility.PartialPrelude +import Utility.Misc + +import Control.Applicative +import Data.List +import Data.Time.Clock +import Data.Time.LocalTime +import Data.Time.Calendar +import Data.Time.Calendar.WeekDate +import Data.Time.Calendar.OrdinalDate +import Data.Tuple.Utils +import Data.Char + +{- Some sort of scheduled event. -} +data Schedule = Schedule Recurrance ScheduledTime + deriving (Eq, Read, Show, Ord) + +data Recurrance + = Daily + | Weekly (Maybe WeekDay) + | Monthly (Maybe MonthDay) + | Yearly (Maybe YearDay) + | Divisible Int Recurrance + -- ^ Days, Weeks, or Months of the year evenly divisible by a number. + -- (Divisible Year is years evenly divisible by a number.) + deriving (Eq, Read, Show, Ord) + +type WeekDay = Int +type MonthDay = Int +type YearDay = Int + +data ScheduledTime + = AnyTime + | SpecificTime Hour Minute + deriving (Eq, Read, Show, Ord) + +type Hour = Int +type Minute = Int + +{- Next time a Schedule should take effect. The NextTimeWindow is used + - when a Schedule is allowed to start at some point within the window. -} +data NextTime + = NextTimeExactly LocalTime + | NextTimeWindow LocalTime LocalTime + deriving (Eq, Read, Show) + +startTime :: NextTime -> LocalTime +startTime (NextTimeExactly t) = t +startTime (NextTimeWindow t _) = t + +nextTime :: Schedule -> Maybe LocalTime -> IO (Maybe NextTime) +nextTime schedule lasttime = do + now <- getCurrentTime + tz <- getTimeZone now + return $ calcNextTime schedule lasttime $ utcToLocalTime tz now + +{- Calculate the next time that fits a Schedule, based on the + - last time it occurred, and the current time. -} +calcNextTime :: Schedule -> Maybe LocalTime -> LocalTime -> Maybe NextTime +calcNextTime (Schedule recurrance scheduledtime) lasttime currenttime + | scheduledtime == AnyTime = do + next <- findfromtoday True + return $ case next of + NextTimeWindow _ _ -> next + NextTimeExactly t -> window (localDay t) (localDay t) + | otherwise = NextTimeExactly . startTime <$> findfromtoday False + where + findfromtoday anytime = findfrom recurrance afterday today + where + today = localDay currenttime + afterday = sameaslastday || toolatetoday + toolatetoday = not anytime && localTimeOfDay currenttime >= nexttime + sameaslastday = lastday == Just today + lastday = localDay <$> lasttime + nexttime = case scheduledtime of + AnyTime -> TimeOfDay 0 0 0 + SpecificTime h m -> TimeOfDay h m 0 + exactly d = NextTimeExactly $ LocalTime d nexttime + window startd endd = NextTimeWindow + (LocalTime startd nexttime) + (LocalTime endd (TimeOfDay 23 59 0)) + findfrom r afterday day = case r of + Daily + | afterday -> Just $ exactly $ addDays 1 day + | otherwise -> Just $ exactly day + Weekly Nothing + | afterday -> skip 1 + | otherwise -> case (wday <$> lastday, wday day) of + (Nothing, _) -> Just $ window day (addDays 6 day) + (Just old, curr) + | old == curr -> Just $ window day (addDays 6 day) + | otherwise -> skip 1 + Monthly Nothing + | afterday -> skip 1 + | maybe True (\old -> mnum day > mday old && mday day >= (mday old `mod` minmday)) lastday -> + -- Window only covers current month, + -- in case there is a Divisible requirement. + Just $ window day (endOfMonth day) + | otherwise -> skip 1 + Yearly Nothing + | afterday -> skip 1 + | maybe True (\old -> ynum day > ynum old && yday day >= (yday old `mod` minyday)) lastday -> + Just $ window day (endOfYear day) + | otherwise -> skip 1 + Weekly (Just w) + | w < 0 || w > maxwday -> Nothing + | w == wday day -> if afterday + then Just $ exactly $ addDays 7 day + else Just $ exactly day + | otherwise -> Just $ exactly $ + addDays (fromIntegral $ (w - wday day) `mod` 7) day + Monthly (Just m) + | m < 0 || m > maxmday -> Nothing + -- TODO can be done more efficiently than recursing + | m == mday day -> if afterday + then skip 1 + else Just $ exactly day + | otherwise -> skip 1 + Yearly (Just y) + | y < 0 || y > maxyday -> Nothing + | y == yday day -> if afterday + then skip 365 + else Just $ exactly day + | otherwise -> skip 1 + Divisible n r'@Daily -> handlediv n r' yday (Just maxyday) + Divisible n r'@(Weekly _) -> handlediv n r' wnum (Just maxwnum) + Divisible n r'@(Monthly _) -> handlediv n r' mnum (Just maxmnum) + Divisible n r'@(Yearly _) -> handlediv n r' ynum Nothing + Divisible _ r'@(Divisible _ _) -> findfrom r' afterday day + where + skip n = findfrom r False (addDays n day) + handlediv n r' getval mmax + | n > 0 && maybe True (n <=) mmax = + findfromwhere r' (divisible n . getval) afterday day + | otherwise = Nothing + findfromwhere r p afterday day + | maybe True (p . getday) next = next + | otherwise = maybe Nothing (findfromwhere r p True . getday) next + where + next = findfrom r afterday day + getday = localDay . startTime + divisible n v = v `rem` n == 0 + +endOfMonth :: Day -> Day +endOfMonth day = + let (y,m,_d) = toGregorian day + in fromGregorian y m (gregorianMonthLength y m) + +endOfYear :: Day -> Day +endOfYear day = + let (y,_m,_d) = toGregorian day + in endOfMonth (fromGregorian y maxmnum 1) + +-- extracting various quantities from a Day +wday :: Day -> Int +wday = thd3 . toWeekDate +wnum :: Day -> Int +wnum = snd3 . toWeekDate +mday :: Day -> Int +mday = thd3 . toGregorian +mnum :: Day -> Int +mnum = snd3 . toGregorian +yday :: Day -> Int +yday = snd . toOrdinalDate +ynum :: Day -> Int +ynum = fromIntegral . fst . toOrdinalDate + +{- Calendar max and mins. -} +maxyday :: Int +maxyday = 366 -- with leap days +minyday :: Int +minyday = 365 +maxwnum :: Int +maxwnum = 53 -- some years have more than 52 +maxmday :: Int +maxmday = 31 +minmday :: Int +minmday = 28 +maxmnum :: Int +maxmnum = 12 +maxwday :: Int +maxwday = 7 + +fromRecurrance :: Recurrance -> String +fromRecurrance (Divisible n r) = + fromRecurrance' (++ "s divisible by " ++ show n) r +fromRecurrance r = fromRecurrance' ("every " ++) r + +fromRecurrance' :: (String -> String) -> Recurrance -> String +fromRecurrance' a Daily = a "day" +fromRecurrance' a (Weekly n) = onday n (a "week") +fromRecurrance' a (Monthly n) = onday n (a "month") +fromRecurrance' a (Yearly n) = onday n (a "year") +fromRecurrance' a (Divisible _n r) = fromRecurrance' a r -- not used + +onday :: Maybe Int -> String -> String +onday (Just n) s = "on day " ++ show n ++ " of " ++ s +onday Nothing s = s + +toRecurrance :: String -> Maybe Recurrance +toRecurrance s = case words s of + ("every":"day":[]) -> Just Daily + ("on":"day":sd:"of":"every":something:[]) -> withday sd something + ("every":something:[]) -> noday something + ("days":"divisible":"by":sn:[]) -> + Divisible <$> getdivisor sn <*> pure Daily + ("on":"day":sd:"of":something:"divisible":"by":sn:[]) -> + Divisible + <$> getdivisor sn + <*> withday sd something + ("every":something:"divisible":"by":sn:[]) -> + Divisible + <$> getdivisor sn + <*> noday something + (something:"divisible":"by":sn:[]) -> + Divisible + <$> getdivisor sn + <*> noday something + _ -> Nothing + where + constructor "week" = Just Weekly + constructor "month" = Just Monthly + constructor "year" = Just Yearly + constructor u + | "s" `isSuffixOf` u = constructor $ reverse $ drop 1 $ reverse u + | otherwise = Nothing + withday sd u = do + c <- constructor u + d <- readish sd + Just $ c (Just d) + noday u = do + c <- constructor u + Just $ c Nothing + getdivisor sn = do + n <- readish sn + if n > 0 + then Just n + else Nothing + +fromScheduledTime :: ScheduledTime -> String +fromScheduledTime AnyTime = "any time" +fromScheduledTime (SpecificTime h m) = + show h' ++ (if m > 0 then ":" ++ pad 2 (show m) else "") ++ " " ++ ampm + where + pad n s = take (n - length s) (repeat '0') ++ s + (h', ampm) + | h == 0 = (12, "AM") + | h < 12 = (h, "AM") + | h == 12 = (h, "PM") + | otherwise = (h - 12, "PM") + +toScheduledTime :: String -> Maybe ScheduledTime +toScheduledTime "any time" = Just AnyTime +toScheduledTime v = case words v of + (s:ampm:[]) + | map toUpper ampm == "AM" -> + go s h0 + | map toUpper ampm == "PM" -> + go s (\h -> (h0 h) + 12) + | otherwise -> Nothing + (s:[]) -> go s id + _ -> Nothing + where + h0 h + | h == 12 = 0 + | otherwise = h + go :: String -> (Int -> Int) -> Maybe ScheduledTime + go s adjust = + let (h, m) = separate (== ':') s + in SpecificTime + <$> (adjust <$> readish h) + <*> if null m then Just 0 else readish m + +fromSchedule :: Schedule -> String +fromSchedule (Schedule recurrance scheduledtime) = unwords + [ fromRecurrance recurrance + , "at" + , fromScheduledTime scheduledtime + ] + +toSchedule :: String -> Maybe Schedule +toSchedule = eitherToMaybe . parseSchedule + +parseSchedule :: String -> Either String Schedule +parseSchedule s = do + r <- maybe (Left $ "bad recurrance: " ++ recurrance) Right + (toRecurrance recurrance) + t <- maybe (Left $ "bad time of day: " ++ scheduledtime) Right + (toScheduledTime scheduledtime) + Right $ Schedule r t + where + (rws, tws) = separate (== "at") (words s) + recurrance = unwords rws + scheduledtime = unwords tws + +instance Arbitrary Schedule where + arbitrary = Schedule <$> arbitrary <*> arbitrary + +instance Arbitrary ScheduledTime where + arbitrary = oneof + [ pure AnyTime + , SpecificTime + <$> choose (0, 23) + <*> choose (1, 59) + ] + +instance Arbitrary Recurrance where + arbitrary = oneof + [ pure Daily + , Weekly <$> arbday + , Monthly <$> arbday + , Yearly <$> arbday + , Divisible + <$> positive arbitrary + <*> oneof -- no nested Divisibles + [ pure Daily + , Weekly <$> arbday + , Monthly <$> arbday + , Yearly <$> arbday + ] + ] + where + arbday = oneof + [ Just <$> nonNegative arbitrary + , pure Nothing + ] + +prop_schedule_roundtrips :: Schedule -> Bool +prop_schedule_roundtrips s = toSchedule (fromSchedule s) == Just s diff --git a/Utility/ThreadScheduler.hs b/Utility/ThreadScheduler.hs new file mode 100644 index 0000000..9d4cfd0 --- /dev/null +++ b/Utility/ThreadScheduler.hs @@ -0,0 +1,73 @@ +{- thread scheduling + - + - Copyright 2012, 2013 Joey Hess + - Copyright 2011 Bas van Dijk & Roel van Dijk + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.ThreadScheduler where + +import Control.Monad +import Control.Monad.IfElse +import System.Posix.IO +import Control.Concurrent +#ifndef mingw32_HOST_OS +import System.Posix.Signals +#ifndef __ANDROID__ +import System.Posix.Terminal +#endif +#endif + +newtype Seconds = Seconds { fromSeconds :: Int } + deriving (Eq, Ord, Show) + +type Microseconds = Integer + +{- Runs an action repeatedly forever, sleeping at least the specified number + - of seconds in between. -} +runEvery :: Seconds -> IO a -> IO a +runEvery n a = forever $ do + threadDelaySeconds n + a + +threadDelaySeconds :: Seconds -> IO () +threadDelaySeconds (Seconds n) = unboundDelay (fromIntegral n * oneSecond) + +{- Like threadDelay, but not bounded by an Int. + - + - There is no guarantee that the thread will be rescheduled promptly when the + - delay has expired, but the thread will never continue to run earlier than + - specified. + - + - Taken from the unbounded-delay package to avoid a dependency for 4 lines + - of code. + -} +unboundDelay :: Microseconds -> IO () +unboundDelay time = do + let maxWait = min time $ toInteger (maxBound :: Int) + threadDelay $ fromInteger maxWait + when (maxWait /= time) $ unboundDelay (time - maxWait) + +{- Pauses the main thread, letting children run until program termination. -} +waitForTermination :: IO () +waitForTermination = do +#ifdef mingw32_HOST_OS + runEvery (Seconds 600) $ + void getLine +#else + lock <- newEmptyMVar + let check sig = void $ + installHandler sig (CatchOnce $ putMVar lock ()) Nothing + check softwareTermination +#ifndef __ANDROID__ + whenM (queryTerminal stdInput) $ + check keyboardSignal +#endif + takeMVar lock +#endif + +oneSecond :: Microseconds +oneSecond = 1000000 diff --git a/Utility/Tmp.hs b/Utility/Tmp.hs new file mode 100644 index 0000000..f46e1a5 --- /dev/null +++ b/Utility/Tmp.hs @@ -0,0 +1,100 @@ +{- Temporary files and directories. + - + - Copyright 2010-2013 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.Tmp where + +import Control.Exception (bracket) +import System.IO +import System.Directory +import Control.Monad.IfElse +import System.FilePath + +import Utility.Exception +import Utility.FileSystemEncoding +import Utility.PosixFiles + +type Template = String + +{- Runs an action like writeFile, writing to a temp file first and + - then moving it into place. The temp file is stored in the same + - directory as the final file to avoid cross-device renames. -} +viaTmp :: (FilePath -> String -> IO ()) -> FilePath -> String -> IO () +viaTmp a file content = do + let (dir, base) = splitFileName file + createDirectoryIfMissing True dir + (tmpfile, handle) <- openTempFile dir (base ++ ".tmp") + hClose handle + a tmpfile content + rename tmpfile file + +{- Runs an action with a tmp file located in the system's tmp directory + - (or in "." if there is none) then removes the file. -} +withTmpFile :: Template -> (FilePath -> Handle -> IO a) -> IO a +withTmpFile template a = do + tmpdir <- catchDefaultIO "." getTemporaryDirectory + withTmpFileIn tmpdir template a + +{- Runs an action with a tmp file located in the specified directory, + - then removes the file. -} +withTmpFileIn :: FilePath -> Template -> (FilePath -> Handle -> IO a) -> IO a +withTmpFileIn tmpdir template a = bracket create remove use + where + create = openTempFile tmpdir template + remove (name, handle) = do + hClose handle + catchBoolIO (removeFile name >> return True) + use (name, handle) = a name handle + +{- Runs an action with a tmp directory located within the system's tmp + - directory (or within "." if there is none), then removes the tmp + - directory and all its contents. -} +withTmpDir :: Template -> (FilePath -> IO a) -> IO a +withTmpDir template a = do + tmpdir <- catchDefaultIO "." getTemporaryDirectory + withTmpDirIn tmpdir template a + +{- Runs an action with a tmp directory located within a specified directory, + - then removes the tmp directory and all its contents. -} +withTmpDirIn :: FilePath -> Template -> (FilePath -> IO a) -> IO a +withTmpDirIn tmpdir template = bracket create remove + where + remove d = whenM (doesDirectoryExist d) $ do +#if mingw32_HOST_OS + -- Windows will often refuse to delete a file + -- after a process has just written to it and exited. + -- Because it's crap, presumably. So, ignore failure + -- to delete the temp directory. + _ <- tryIO $ removeDirectoryRecursive d + return () +#else + removeDirectoryRecursive d +#endif + create = do + createDirectoryIfMissing True tmpdir + makenewdir (tmpdir template) (0 :: Int) + makenewdir t n = do + let dir = t ++ "." ++ show n + either (const $ makenewdir t $ n + 1) (const $ return dir) + =<< tryIO (createDirectory dir) + +{- It's not safe to use a FilePath of an existing file as the template + - for openTempFile, because if the FilePath is really long, the tmpfile + - will be longer, and may exceed the maximum filename length. + - + - This generates a template that is never too long. + - (Well, it allocates 20 characters for use in making a unique temp file, + - anyway, which is enough for the current implementation and any + - likely implementation.) + -} +relatedTemplate :: FilePath -> FilePath +relatedTemplate f + | len > 20 = truncateFilePath (len - 20) f + | otherwise = f + where + len = length f diff --git a/Utility/UserInfo.hs b/Utility/UserInfo.hs new file mode 100644 index 0000000..9c3bfd4 --- /dev/null +++ b/Utility/UserInfo.hs @@ -0,0 +1,55 @@ +{- user info + - + - Copyright 2012 Joey Hess + - + - Licensed under the GNU GPL version 3 or higher. + -} + +{-# LANGUAGE CPP #-} + +module Utility.UserInfo ( + myHomeDir, + myUserName, + myUserGecos, +) where + +import Control.Applicative +import System.PosixCompat + +import Utility.Env + +{- Current user's home directory. + - + - getpwent will fail on LDAP or NIS, so use HOME if set. -} +myHomeDir :: IO FilePath +myHomeDir = myVal env homeDirectory + where +#ifndef mingw32_HOST_OS + env = ["HOME"] +#else + env = ["USERPROFILE", "HOME"] -- HOME is used in Cygwin +#endif + +{- Current user's user name. -} +myUserName :: IO String +myUserName = myVal env userName + where +#ifndef mingw32_HOST_OS + env = ["USER", "LOGNAME"] +#else + env = ["USERNAME", "USER", "LOGNAME"] +#endif + +myUserGecos :: IO String +#ifdef __ANDROID__ +myUserGecos = return "" -- userGecos crashes on Android +#else +myUserGecos = myVal [] userGecos +#endif + +myVal :: [String] -> (UserEntry -> String) -> IO String +myVal envvars extract = maybe (extract <$> getpwent) return =<< check envvars + where + check [] = return Nothing + check (v:vs) = maybe (check vs) (return . Just) =<< getEnv v + getpwent = getUserEntryForID =<< getEffectiveUserID diff --git a/config-joey.hs b/config-joey.hs new file mode 100644 index 0000000..cd0583f --- /dev/null +++ b/config-joey.hs @@ -0,0 +1,202 @@ +-- | This is the live config file used by propellor's author. + +import Propellor +import Propellor.CmdLine +import Propellor.Property.Scheduled +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Network as Network +import qualified Propellor.Property.Ssh as Ssh +import qualified Propellor.Property.Cron as Cron +import qualified Propellor.Property.Sudo as Sudo +import qualified Propellor.Property.User as User +import qualified Propellor.Property.Hostname as Hostname +--import qualified Propellor.Property.Reboot as Reboot +import qualified Propellor.Property.Tor as Tor +import qualified Propellor.Property.Dns as Dns +import qualified Propellor.Property.OpenId as OpenId +import qualified Propellor.Property.Docker as Docker +import qualified Propellor.Property.Git as Git +import qualified Propellor.Property.SiteSpecific.GitHome as GitHome +import qualified Propellor.Property.SiteSpecific.GitAnnexBuilder as GitAnnexBuilder +import qualified Propellor.Property.SiteSpecific.JoeySites as JoeySites + +hosts :: [Host] +hosts = + -- My laptop + [ host "darkstar.kitenet.net" + & Docker.configured + & Apt.buildDep ["git-annex"] `period` Daily + + -- Nothing super-important lives here. + , standardSystem "clam.kitenet.net" Unstable + & cleanCloudAtCost + & Apt.unattendedUpgrades + & Network.ipv6to4 + & Tor.isBridge + & Docker.configured + & cname "shell.olduse.net" + & JoeySites.oldUseNetShellBox + + & cname "openid.kitenet.net" + & Docker.docked hosts "openid-provider" + `requires` Apt.installed ["ntp"] + + & cname "ancient.kitenet.net" + & Docker.docked hosts "ancient-kitenet" + + & Docker.garbageCollected `period` Daily + & Apt.installed ["git-annex", "mtr", "screen"] + + -- Orca is the main git-annex build box. + , standardSystem "orca.kitenet.net" Unstable + & Hostname.sane + & Apt.unattendedUpgrades + & Docker.configured + & Docker.docked hosts "amd64-git-annex-builder" + & Docker.docked hosts "i386-git-annex-builder" + ! Docker.docked hosts "armel-git-annex-builder-companion" + ! Docker.docked hosts "armel-git-annex-builder" + & Docker.garbageCollected `period` Daily + & Apt.buildDep ["git-annex"] `period` Daily + + -- Important stuff that needs not too much memory or CPU. + , standardSystem "diatom.kitenet.net" Stable + & Hostname.sane + & Apt.unattendedUpgrades + & Apt.serviceInstalledRunning "ntp" + & Dns.zones myDnsSecondary + & Apt.serviceInstalledRunning "apache2" + & Apt.installed ["git", "git-annex", "rsync"] + & Apt.buildDep ["git-annex"] `period` Daily + & Git.daemonRunning "/srv/git" + & File.ownerGroup "/srv/git" "joey" "joey" + -- git repos restore (how?) + -- family annex needs family members to have accounts, + -- ssh host key etc.. finesse? + -- (also should upgrade git-annex-shell for it..) + -- kgb installation and setup + -- ssh keys for branchable and github repo hooks + -- gitweb + -- downloads.kitenet.net setup (including ssh key to turtle) + + -------------------------------------------------------------------- + -- Docker Containers ----------------------------------- \o/ ----- + -------------------------------------------------------------------- + + -- Simple web server, publishing the outside host's /var/www + , standardContainer "webserver" Stable "amd64" + & Docker.publish "8080:80" + & Docker.volume "/var/www:/var/www" + & Apt.serviceInstalledRunning "apache2" + + -- My own openid provider. Uses php, so containerized for security + -- and administrative sanity. + , standardContainer "openid-provider" Stable "amd64" + & Docker.publish "8081:80" + & OpenId.providerFor ["joey", "liw"] + "openid.kitenet.net:8081" + + , standardContainer "ancient-kitenet" Stable "amd64" + & Docker.publish "1994:80" + & Apt.serviceInstalledRunning "apache2" + & Apt.installed ["git"] + & scriptProperty + [ "cd /var/" + , "rm -rf www" + , "git clone git://git.kitenet.net/kitewiki www" + , "cd www" + , "git checkout remotes/origin/old-kitenet.net" + ] `flagFile` "/var/www/blastfromthepast.html" + + -- git-annex autobuilder containers + , gitAnnexBuilder "amd64" 15 + , gitAnnexBuilder "i386" 45 + -- armel builder has a companion container that run amd64 and + -- runs the build first to get TH splices. They share a home + -- directory, and need to have the same versions of all haskell + -- libraries installed. + , Docker.container "armel-git-annex-builder-companion" + (image $ System (Debian Unstable) "amd64") + & Docker.volume GitAnnexBuilder.homedir + & Apt.unattendedUpgrades + , Docker.container "armel-git-annex-builder" + (image $ System (Debian Unstable) "armel") + & Docker.link "armel-git-annex-builder-companion" "companion" + & Docker.volumes_from "armel-git-annex-builder-companion" +-- & GitAnnexBuilder.builder "armel" "15 * * * *" True + & Apt.unattendedUpgrades + ] + +gitAnnexBuilder :: Architecture -> Int -> Host +gitAnnexBuilder arch buildminute = Docker.container (arch ++ "-git-annex-builder") + (image $ System (Debian Unstable) arch) + & GitAnnexBuilder.builder arch (show buildminute ++ " * * * *") True + & Apt.unattendedUpgrades + +-- This is my standard system setup. +standardSystem :: HostName -> DebianSuite -> Host +standardSystem hn suite = host hn + & Apt.stdSourcesList suite `onChange` Apt.upgrade + & Apt.installed ["etckeeper"] + & Apt.installed ["ssh"] + & GitHome.installedFor "root" + & User.hasSomePassword "root" + -- Harden the system, but only once root's authorized_keys + -- is safely in place. + & check (Ssh.hasAuthorizedKeys "root") + (Ssh.passwordAuthentication False) + & User.accountFor "joey" + & User.hasSomePassword "joey" + & Sudo.enabledFor "joey" + & GitHome.installedFor "joey" + & Apt.installed ["vim", "screen", "less"] + & Cron.runPropellor "30 * * * *" + -- I use postfix, or no MTA. + & Apt.removed ["exim4", "exim4-daemon-light", "exim4-config", "exim4-base"] + `onChange` Apt.autoRemove + +-- This is my standard container setup, featuring automatic upgrades. +standardContainer :: Docker.ContainerName -> DebianSuite -> Architecture -> Host +standardContainer name suite arch = Docker.container name (image system) + & Apt.stdSourcesList suite + & Apt.unattendedUpgrades + where + system = System (Debian suite) arch + +-- | Docker images I prefer to use. +image :: System -> Docker.Image +image (System (Debian Unstable) arch) = "joeyh/debian-unstable-" ++ arch +image (System (Debian Stable) arch) = "joeyh/debian-stable-" ++ arch +image _ = "debian-stable-official" -- does not currently exist! + +-- Clean up a system as installed by cloudatcost.com +cleanCloudAtCost :: Property +cleanCloudAtCost = propertyList "cloudatcost cleanup" + [ Hostname.sane + , Ssh.uniqueHostKeys + , "worked around grub/lvm boot bug #743126" ==> + "/etc/default/grub" `File.containsLine` "GRUB_DISABLE_LINUX_UUID=true" + `onChange` cmdProperty "update-grub" [] + `onChange` cmdProperty "update-initramfs" ["-u"] + , combineProperties "nuked cloudatcost cruft" + [ File.notPresent "/etc/rc.local" + , File.notPresent "/etc/init.d/S97-setup.sh" + , User.nuked "user" User.YesReallyDeleteHome + ] + ] + +myDnsSecondary :: [Dns.Zone] +myDnsSecondary = + [ Dns.secondary "kitenet.net" master + , Dns.secondary "joeyh.name" master + , Dns.secondary "ikiwiki.info" master + , Dns.secondary "olduse.net" master + , Dns.secondary "branchable.com" branchablemaster + ] + where + master = ["80.68.85.49", "2001:41c8:125:49::10"] -- wren + branchablemaster = ["66.228.46.55", "2600:3c03::f03c:91ff:fedf:c0e5"] + +main :: IO () +main = defaultMain hosts --, Docker.containerProperties container] diff --git a/config-simple.hs b/config-simple.hs new file mode 100644 index 0000000..23a760c --- /dev/null +++ b/config-simple.hs @@ -0,0 +1,47 @@ +-- | This is the main configuration file for Propellor, and is used to build +-- the propellor program. + +import Propellor +import Propellor.CmdLine +import Propellor.Property.Scheduled +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Network as Network +--import qualified Propellor.Property.Ssh as Ssh +import qualified Propellor.Property.Cron as Cron +--import qualified Propellor.Property.Sudo as Sudo +import qualified Propellor.Property.User as User +--import qualified Propellor.Property.Hostname as Hostname +--import qualified Propellor.Property.Reboot as Reboot +--import qualified Propellor.Property.Tor as Tor +import qualified Propellor.Property.Docker as Docker + +-- The hosts propellor knows about. +-- Edit this to configure propellor! +hosts :: [Host] +hosts = + [ host "mybox.example.com" + & Apt.stdSourcesList Unstable + `onChange` Apt.upgrade + & Apt.unattendedUpgrades + & Apt.installed ["etckeeper"] + & Apt.installed ["ssh"] + & User.hasSomePassword "root" + & Network.ipv6to4 + & File.dirExists "/var/www" + & Docker.docked hosts "webserver" + & Docker.garbageCollected `period` Daily + & Cron.runPropellor "30 * * * *" + + -- A generic webserver in a Docker container. + , Docker.container "webserver" "joeyh/debian-unstable" + & Docker.publish "80:80" + & Docker.volume "/var/www:/var/www" + & Apt.serviceInstalledRunning "apache2" + + -- add more hosts here... + --, host "foo.example.com" = ... + ] + +main :: IO () +main = defaultMain hosts diff --git a/config.hs b/config.hs new file mode 120000 index 0000000..ec31372 --- /dev/null +++ b/config.hs @@ -0,0 +1 @@ +config-simple.hs \ No newline at end of file diff --git a/debian/README.Debian b/debian/README.Debian new file mode 100644 index 0000000..e32a0ee --- /dev/null +++ b/debian/README.Debian @@ -0,0 +1,7 @@ +The Debian package of propellor ships its full source code because +propellor is configured by rebuilding it, and embraces modification of any +of the source code. + +/usr/bin/propellor is a wrapper which will set up a propellor git +repository in ~/.propellor/, and run ~/.propellor/propellor if it exists. +Edit ~/.propellor/config.hs to configure it. diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..c6ea2eb --- /dev/null +++ b/debian/changelog @@ -0,0 +1,57 @@ +propellor (0.3.0) unstable; urgency=medium + + * ipv6to4: Ensure interface is brought up automatically on boot. + * Enabling unattended upgrades now ensures that cron is installed and + running to perform them. + * Properties can be scheduled to only be checked after a given time period. + * Fix bootstrapping of dependencies. + * Fix compilation on Debian stable. + * Include security updates in sources.list for stable and testing. + * Use ssh connection caching, especially when bootstrapping. + * Properties now run in a Propellor monad, which provides access to + attributes of the host. + + -- Joey Hess Fri, 11 Apr 2014 01:19:05 -0400 + +propellor (0.2.3) unstable; urgency=medium + + * docker: Fix laziness bug that caused running containers to be + unnecessarily stopped and committed. + * Add locking so only one propellor can run at a time on a host. + * docker: When running as effective init inside container, wait on zombies. + * docker: Added support for configuring shared volumes and linked + containers. + + -- Joey Hess Tue, 08 Apr 2014 02:07:37 -0400 + +propellor (0.2.2) unstable; urgency=medium + + * Now supports provisioning docker containers with architecture/libraries + that do not match the host. + * Fixed a bug that caused file modes to be set to 600 when propellor + modified the file (did not affect newly created files). + + -- Joey Hess Fri, 04 Apr 2014 01:07:32 -0400 + +propellor (0.2.1) unstable; urgency=medium + + * First release with Debian package. + + -- Joey Hess Thu, 03 Apr 2014 01:43:14 -0400 + +propellor (0.2.0) unstable; urgency=low + + * Added support for provisioning Docker containers. + * Bootstrap deployment now pushes the git repo to the remote host + over ssh, securely. + * propellor --add-key configures a gpg key, and makes propellor refuse + to pull commits from git repositories not signed with that key. + This allows propellor to be securely used with public, non-encrypted + git repositories without the possibility of MITM. + * Added support for type-safe reversions. Only some properties can be + reverted; the type checker will tell you if you try something that won't + work. + * New syntactic sugar for building a list of properties, including + revertable properties. + + -- Joey Hess Wed, 02 Apr 2014 13:57:42 -0400 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +9 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..bfdc588 --- /dev/null +++ b/debian/control @@ -0,0 +1,40 @@ +Source: propellor +Section: admin +Priority: optional +Build-Depends: + debhelper (>= 9), + ghc (>= 7.4), + cabal-install, + libghc-async-dev, + libghc-missingh-dev, + libghc-hslogger-dev, + libghc-unix-compat-dev, + libghc-ansi-terminal-dev, + libghc-ifelse-dev, + libghc-mtl-dev, + libghc-monadcatchio-transformers-dev, +Maintainer: Joey Hess +Standards-Version: 3.9.5 +Vcs-Git: git://git.kitenet.net/propellor +Homepage: http://joeyh.name/code/propellor/ + +Package: propellor +Architecture: any +Section: admin +Depends: ${misc:Depends}, ${shlibs:Depends}, + ghc (>= 7.4), + cabal-install, + libghc-async-dev, + libghc-missingh-dev, + libghc-hslogger-dev, + libghc-unix-compat-dev, + libghc-ansi-terminal-dev, + libghc-ifelse-dev, + libghc-mtl-dev, + libghc-monadcatchio-transformers-dev, + git, +Description: property-based host configuration management in haskell + Propellor enures that the system it's run in satisfies a list of + properties, taking action as necessary when a property is not yet met. + . + It is configured using haskell. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..690a9af --- /dev/null +++ b/debian/copyright @@ -0,0 +1,11 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Source: native package + +Files: * +Copyright: © 2010-2014 Joey Hess +License: GPL-3+ + +License: GPL-3+ + The full text of version 3 of the GPL is distributed as GPL in + this package's source, or in /usr/share/common-licenses/GPL-3 on + Debian systems. diff --git a/debian/lintian-overrides b/debian/lintian-overrides new file mode 100644 index 0000000..9071fe0 --- /dev/null +++ b/debian/lintian-overrides @@ -0,0 +1,3 @@ +# These files are used in a git repository that propellor sets up. +propellor: package-contains-vcs-control-file usr/src/propellor/.gitignore +propellor: extra-license-file usr/src/propellor/GPL diff --git a/debian/propellor.1 b/debian/propellor.1 new file mode 100644 index 0000000..3ee3bf4 --- /dev/null +++ b/debian/propellor.1 @@ -0,0 +1,15 @@ +.\" -*- nroff -*- +.TH propellor 1 "Commands" +.SH NAME +propellor \- property-based host configuration management in haskell +.SH SYNOPSIS +.B propellor [options] host +.SH DESCRIPTION +.I propellor +is a property-based host configuration management program written +and configured in haskell. +.PP +The first time you run propellor, it will set up a ~/.propellor/ +repository. Edit ~/.propellor/config.hs to configure it. +.SH AUTHOR +Joey Hess diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..dafe10f --- /dev/null +++ b/debian/rules @@ -0,0 +1,14 @@ +#!/usr/bin/make -f + +# Avoid using cabal, as it writes to $HOME +export CABAL=./Setup + +%: + dh $@ + +override_dh_auto_build: + $(MAKE) build +override_dh_installdocs: + dh_installdocs README.md TODO +override_dh_installman: + dh_installman debian/propellor.1 diff --git a/privdata/clam.kitenet.net.gpg b/privdata/clam.kitenet.net.gpg new file mode 100644 index 0000000..69d8f12 --- /dev/null +++ b/privdata/clam.kitenet.net.gpg @@ -0,0 +1,25 @@ +-----BEGIN PGP MESSAGE----- +Version: GnuPG v1 + +hQIMA7ODiaEXBlRZARAAuRttWmrr3tFgQnbnaQpWxiAQToL94e0SctFiYqiEGRNa +D63/ZaBhBkvKSx57+SyOloqfBaeWM63vd4Yacocypl2zOjC4aEN7/MKyQRl+xhmk +EwQ4kFfJ3dmYrgXt7NAdIarjHsK5/Bv7PGVIrcwD3zqV+FUyuxt2L2ETG61kYo+m +xNWl1NCvHDZ1QOfvw4ldBo7+LO2odzoZAxBF0ZgQFqo/r/6RZaqFNJRLdVTLERTq +E4igjtgfq6blrpyeupKpFu6oy8/7WeBXthnyoduftk+aBTkXWzb+i30zIzNNsc4+ +GE68a5tM0XE8nGwKp4yz0AZHhEYzv+BZXI7HQMAZ+m0srVn637SDHeAgOBU8NjrA +SbZt0ubQ28Qaux7C7awLJ5SjvlQyLT61jLaN6SMcpeLmgkjRVN+eiVOE/qmXzhHv +AobUwJgBOktiN6+WtRcxq7WduNf6Jtxw8UB5gVWiEeg6o+29ZBfIKVMT/Jly4rTO +M13HbmSVzwdGcUL1D7Gf3oY2R7eS4VR8ShCQmF8aB8TXdsw4mo71HnUa7u5N4hCP +jLtJG24+f39TWWRjMQjtFXi5hkep4OG5CBViWdCWOjlfn4Kmr5zCXaunkO9cgDAd +s8UZdmALu2MPoVdcVm+KLq2JQi1jBWEqRu5krx/nSi+eRRX2/y95CKPEPqZoU+rS +wM0BzlW+pEDc7aFlcYCrWTiwO0BWT2iBmbse9/r2NyJPpuFf7GOMI2v65jXQ+avy +1r69zPdAXNgJ19Gid/q1CXCYnYLLVHqigd8XNs12ANaVvkOnBi3gAf309SIPJtCa +uFVBxNasLTMQ3Ta7v7TLa0PopdBuFqfcy9d3BBiOKqokvhWFJobaG/WhF85ercRJ +F8lse9fgo5xfrDoCFk7u9rzhHl8xKLl24thKFTDzwm+yuzXOoLq8+Km/xYuzQXZK +JCjPvIUDaCCc1E/Yeoc3RafAiOuNwnjHW15TRdlohmgXzYlTCYF491WVKQfpL2Sd +VO8Uar094M1d52Rv8/1HCTBKJ0hnK259l4dguzw4sl2BcrFPBz9SJ0f6V/eAHE0h +la5QtLdwDDRI2giMXKfmzRiRA/5kBW01YaK7tt0om6L7Ri4Rs3JAhVgjcWDtH6fI +w807PpsIHaK8r3yDJoeqUnDYOsImuNgdctQkeroPsFYmV3fu5Hb5tYDkKzm5lE0z +C6mz09PD0M5hsnqmZXaw +=UFa1 +-----END PGP MESSAGE----- diff --git a/privdata/darkstar.kitenet.net.gpg b/privdata/darkstar.kitenet.net.gpg new file mode 100644 index 0000000..9a6de1c --- /dev/null +++ b/privdata/darkstar.kitenet.net.gpg @@ -0,0 +1,22 @@ +-----BEGIN PGP MESSAGE----- +Version: GnuPG v1 + +hQIMA7ODiaEXBlRZAQ/9HdpfvTbfOnyqLlEK1WC9QO3HrF9w9yrEH8hCrVFJ/86r +xHK62+7I6wrV2W1UAHRx1b4H9qEkbD8+MAmjB2JYVmJUqvdzNv1jhsWwPpAcTQN1 +RVWR95Auc2rjXXSiZRudLaWdxZdDBg5PWApH5+NW5grtNRKsTbYB1/No2iYJvDuv +WcbBkuFyEa0WbRiqUaUIyO9XAGyj4hqVDQSXH2Gzei8oB3PZh9+Lwv7i05lvSup+ +dtbtEsEdDiJbCTzIakV6vEQT1BDVMpe6jRQbv7c+LXLeM65Tpl+2hnTPSTy1zcr0 +bjfkFa6A75sHmIf0WGKAZj+jmNchp4AMdjmoMiXkHacDsBw623NgiMgzUnfWVkFm +BIrdk5AGBi50nqPxwtY7nWd0cbApvNvT1zlx8MlRBSZQ2zcijo5AjiCwb+eLLVhv +6oiKqpYGC1XpdNFFsaKHnHBCgsPIIetwx4ng0+lvRgBO+DEQ4RvvdKMhy/3nXrpz +NVdr/gG+HMBW1BjyCd9ArmTtSITQWDT8vnLmyFbc0aJ88c2rEjv2BpXmhKjxEoEn +IMxc3/9cLrVVRocnlq7YvKDZpfuwjgDs86D3e03Up7hQZhLU4+r8Wq7azxk3wE06 +lAQIS0OwCe75EZvVWYHwhZ3vEoBE/TeqeaRyhKpofFS5GvtIJsZBjenmRcdOJTPS +wDQB/c3XkjuIrJErMBx/KrNQc2mAjcUpvW4+Ukj5vtpusi3qmSfsyaVJ4ZS9SwVv +7RPqLsH5Iz3Ga6u4of/mg+iG/wqJPJy2A9A/XOnsNVCVR3a+NxjPqevEjW1Pr6RL +SOMQSK6OuwuT1H13M1Z7R6dbg+pCcbc+hek9/6KzeZS9q4Di7aqq7+XeDr4c51+Q +2ojS4DG0/vAJmOO+E8ZatGiwdI8kmELrzAF8zzGz+ZujXSuiPXVd2kw/JdfUaTRq +KrtNhiGWWM44YWS43TYuYCoVgokrdVXzsZyKyhHzgXKCits3R5+QcUgUx2vESuOs ++FdM8fAd +=a0dr +-----END PGP MESSAGE----- diff --git a/privdata/diatom.kitenet.net.gpg b/privdata/diatom.kitenet.net.gpg new file mode 100644 index 0000000..7c36ab2 --- /dev/null +++ b/privdata/diatom.kitenet.net.gpg @@ -0,0 +1,19 @@ +-----BEGIN PGP MESSAGE----- +Version: GnuPG v1 + +hQIMA7ODiaEXBlRZAQ//Qsi46/S4X9qWNSCqFUuUOdoKnuOro0SIKfR19Z0SlseL +AH5cPWUX2eIFA3tzku5Psm8enxGc2jyMhfS5KQkVMLoV/SdgLTEfbsF2TkOGUIFf +AMEt+HOPercftwzU+KnwyNJ6kfCinlgmehLwAHLvD8HfzsL9lD59dJGkYQ61cDZ8 +NQSOJwbLVzlXGoMjUcQ6ihmg7gOEGptO7F+p4oamOYwpzibaFGX2BsczMRDcjlGY +B+ufxINqj2bV17lHchNs/Je8uF5Owe+5zoK2cf6TTCdtlIcWjuw6YIMUPWHhIx3C +DCrEFS/rOJCyY+M8CwIfqS0JTJVNIKJfhP8LbbaoyRyXB2XF2eLM1bQ25p//fpav ++MRQ/0SqnGXYV7ZQE/a+/dESi8/u2yua1m1DBwXzAp468pCTaZCm9gwV+D9Ggsbr +uCU5K/cTa7wPyzfYtki0jkM+R1uk1HqWuHHt0/CD1VnDM3Zrj2JVkoE+pR1LhiSH +qKj8/zF935QmGrCUUjo+1bBn20BDiiFPiiPo4KN3At2uK4qQo1F0c+JUQUHGKV9r +O/c4v0dhPj/Qq5kSp5higO8n2Afv68wAfCWBkBo6SpCS7nuR7xvLWD7pWBTS/0BG +BcL4recUTckQHPo+VUNMYlSNeUhnlv/2TK7/qsfPMYTi0Xu/Fr+bnKn3QOPbgITS +cgHrplzueGhsVhhy+Cpn31FptA7txwcAWuWcZmT7ych0APt/PdkZ1CdeQ3gQop0p +BXaUlY7N4PacFyrC8Jha4p8THbbmfg6zTwaPggH8HonOIL5iA2yZz78uvZwqUd5i +QD0LMQZ3ZgNiqlwLxA8e6heSNA== +=V6He +-----END PGP MESSAGE----- diff --git a/privdata/keyring.gpg b/privdata/keyring.gpg new file mode 100644 index 0000000000000000000000000000000000000000..01dd24e7fa08ddb0dadffe568558b00038c25a2a GIT binary patch literal 113014 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 literal 0 HcmV?d00001 diff --git a/privdata/orca.kitenet.net.gpg b/privdata/orca.kitenet.net.gpg new file mode 100644 index 0000000..c1a2e9f --- /dev/null +++ b/privdata/orca.kitenet.net.gpg @@ -0,0 +1,22 @@ +-----BEGIN PGP MESSAGE----- +Version: GnuPG v1 + +hQIMA7ODiaEXBlRZARAAvqd3qX/p4dXrvDxK49gUGydT2/47k9f3BQQTWDtG1uUq +3QBbJbBAx2LXyRtfsioxDgMx6hdg/pHSjrcIsdd6SeaOzU9NJ8TQe2OsnSg6SY2h +GCc4bxFcMnyOWpWkr0FcuQ6uiGZvStYq7HPMPdeRR2BETkU4ONVgdZOo1QiUU+85 +AM/slTKRLp7syX00aFZVXQydSAekvTaJgwbo6n4pdPhDq+ztUsrwhFKzveOvJAKe +36tjzaqN/XUa3v1X7eqZUwAw2lwPro02jYnkYTGtl1SPd2iFNcOb1GO9rCq0lKjH +pqqkhFSMKZcvvgghZgUga6HnLo/IHSP7lzCxmsznMy5ns2Qrh64Z9vf40LElILPY +/hFN4Bsi5DTFgSsxydS8EL7H2MY3hUgWuBxo5Xj0e/3txv87QGMPM6PDW7OzMOl0 +1qB8pqe7oCnBq+yyd0ftdrhbMtz5JsifFN4/KLlAm9XOzysX0GylZ9Iy3QKbLQUp +hQBXX8XE2mCCbOwpzC9Z1eMUksL6YOiSIz/EVwLbqr6AulicNxTf488gJGj+vf6D +ihFj477BYQPkZ3S6nIEyKi6r/vLZkLMgwni0axBD9yzoVk0O/e4WAJMyJWhVXRzF +OQipN+vnp6HlqwBuUTezFzdwtimy0phBLd5x22qN2WooAaUExXpHgnc/M6WmqRrS +wF4BIvJBD5gLq9GKT5bdENpO1+W5zj4af5fT7LSgobiCSgpjz1/mbfN5QVBUB2z1 +FqQVv7gN1AIbcorx1ke4BOwpvZA3iaU+9Cd51ME04x75uSyFc7Xb7wtcGPymEgXI +X7ZO1mtJJ48BY1vYN3ER0h+MK/d27v0JASFfCwuLSA8M8FAoQLPpEG/7qiAxoQtP +EshdoeZZhK0bsG2+Uf1ixNnRy1/SazrUXTo/e+IVN/BOL7qINjkI+2hPGz3r2gLP +EavegXtJ5RGdqvBD+C4ph85bOvjOlR8klZ1nGnlAnGu1OEYv8zv/yJ6dq6/HaLkB +p8MqZXY1qH0ywoPnkW34TN83k9YncyS4Bj2gNN2iggU+/LQViitsVxLkQ9sxdjlS +=usce +-----END PGP MESSAGE----- diff --git a/propellor.cabal b/propellor.cabal new file mode 100644 index 0000000..5497cc6 --- /dev/null +++ b/propellor.cabal @@ -0,0 +1,125 @@ +Name: propellor +Version: 0.3.0 +Cabal-Version: >= 1.6 +License: GPL +Maintainer: Joey Hess +Author: Joey Hess +Stability: Stable +Copyright: 2014 Joey Hess +License-File: GPL +Build-Type: Simple +Homepage: http://joeyh.name/code/propellor/ +Category: Utility +Extra-Source-Files: + README.md + TODO + CHANGELOG + Makefile + config-simple.hs + config-joey.hs + debian/changelog + debian/README.Debian + debian/propellor.1 + debian/compat + debian/control + debian/copyright + debian/rules + debian/lintian-overrides + .gitignore +Synopsis: property-based host configuration management in haskell +Description: + Propellor enures that the system it's run in satisfies a list of + properties, taking action as necessary when a property is not yet met. + . + It is configured using haskell. + +Executable propellor + Main-Is: propellor.hs + GHC-Options: -Wall + Build-Depends: MissingH, directory, filepath, base >= 4.5, base < 5, + IfElse, process, bytestring, hslogger, unix-compat, ansi-terminal, + containers, network, async, time, QuickCheck, mtl, + MonadCatchIO-transformers + + if (! os(windows)) + Build-Depends: unix + +Executable config + Main-Is: config.hs + GHC-Options: -Wall -threaded + Build-Depends: MissingH, directory, filepath, base >= 4.5, base < 5, + IfElse, process, bytestring, hslogger, unix-compat, ansi-terminal, + containers, network, async, time, QuickCheck, mtl, + MonadCatchIO-transformers + + if (! os(windows)) + Build-Depends: unix + +Library + GHC-Options: -Wall + Build-Depends: MissingH, directory, filepath, base >= 4.5, base < 5, + IfElse, process, bytestring, hslogger, unix-compat, ansi-terminal, + containers, network, async, time, QuickCheck, mtl, + MonadCatchIO-transformers + + if (! os(windows)) + Build-Depends: unix + + Exposed-Modules: + Propellor + Propellor.Property + Propellor.Property.Apt + Propellor.Property.Cmd + Propellor.Property.Hostname + Propellor.Property.Cron + Propellor.Property.Dns + Propellor.Property.Docker + Propellor.Property.File + Propellor.Property.Git + Propellor.Property.Network + Propellor.Property.OpenId + Propellor.Property.Reboot + Propellor.Property.Scheduled + Propellor.Property.Service + Propellor.Property.Ssh + Propellor.Property.Sudo + Propellor.Property.Tor + Propellor.Property.User + Propellor.Property.SiteSpecific.GitHome + Propellor.Property.SiteSpecific.JoeySites + Propellor.Property.SiteSpecific.GitAnnexBuilder + Propellor.Attr + Propellor.Message + Propellor.PrivData + Propellor.Engine + Propellor.Exception + Propellor.Types + Other-Modules: + Propellor.Types.Attr + Propellor.CmdLine + Propellor.SimpleSh + Propellor.Property.Docker.Shim + Utility.Applicative + Utility.Data + Utility.Directory + Utility.Env + Utility.Exception + Utility.FileMode + Utility.FileSystemEncoding + Utility.LinuxMkLibs + Utility.Misc + Utility.Monad + Utility.Path + Utility.PartialPrelude + Utility.PosixFiles + Utility.Process + Utility.SafeCommand + Utility.Scheduled + Utility.ThreadScheduler + Utility.Tmp + Utility.UserInfo + Utility.QuickCheck + +source-repository head + type: git + location: git://git.kitenet.net/propellor.git diff --git a/propellor.hs b/propellor.hs new file mode 100644 index 0000000..e4653f3 --- /dev/null +++ b/propellor.hs @@ -0,0 +1,91 @@ +-- | Wrapper program for propellor distribution. +-- +-- Distributions should install this program into PATH. +-- (Cabal builds it as dict/build/propellor. +-- +-- This is not the propellor main program (that's config.hs) +-- +-- This installs propellor's source into ~/.propellor, +-- uses it to build the real propellor program (if not already built), +-- and runs it. +-- +-- The source is either copied from /usr/src/propellor, or is cloned from +-- git over the network. + +import Utility.UserInfo +import Utility.Monad +import Utility.Process +import Utility.SafeCommand +import Utility.Directory + +import Control.Monad +import Control.Monad.IfElse +import System.Directory +import System.FilePath +import System.Environment (getArgs) +import System.Exit +import System.Posix.Directory + +srcdir :: FilePath +srcdir = "/usr/src/propellor" + +-- Using the github mirror of the main propellor repo because +-- it is accessible over https for better security. +srcrepo :: String +srcrepo = "https://github.com/joeyh/propellor.git" + +main :: IO () +main = do + args <- getArgs + home <- myHomeDir + let propellordir = home ".propellor" + let propellorbin = propellordir "propellor" + wrapper args propellordir propellorbin + +wrapper :: [String] -> FilePath -> FilePath -> IO () +wrapper args propellordir propellorbin = do + unlessM (doesDirectoryExist propellordir) $ + makeRepo + buildruncfg + where + chain = do + (_, _, _, pid) <- createProcess (proc propellorbin args) + exitWith =<< waitForProcess pid + makeRepo = do + putStrLn $ "Setting up your propellor repo in " ++ propellordir + putStrLn "" + ifM (doesDirectoryExist srcdir) + ( do + void $ boolSystem "cp" [Param "-a", File srcdir, File propellordir] + changeWorkingDirectory propellordir + void $ boolSystem "git" [Param "init"] + void $ boolSystem "git" [Param "add", Param "."] + setuprepo True + , do + void $ boolSystem "git" [Param "clone", Param srcrepo, File propellordir] + void $ boolSystem "git" [Param "remote", Param "rm", Param "origin"] + setuprepo False + ) + setuprepo fromsrcdir = do + changeWorkingDirectory propellordir + whenM (doesDirectoryExist "privdata") $ + mapM_ nukeFile =<< dirContents "privdata" + void $ boolSystem "git" [Param "commit", Param "--allow-empty", Param "--quiet", Param "-m", Param "setting up propellor git repository"] + void $ boolSystem "git" [Param "remote", Param "add", Param "upstream", Param srcrepo] + -- Connect synthetic git repo with upstream history so + -- merging with upstream will work going forward. + -- Note -s outs is used to avoid getting any divergent + -- changes from upstream. + when fromsrcdir $ do + void $ boolSystem "git" [Param "fetch", Param "upstream"] + version <- readProcess "dpkg-query" ["--showformat", "${Version}", "--show", "propellor"] + void $ boolSystem "git" [Param "merge", Param "-s", Param "ours", Param version] + buildruncfg = do + changeWorkingDirectory propellordir + ifM (boolSystem "make" [Param "build"]) + ( do + putStrLn "" + putStrLn "" + chain + , error "Propellor build failed." + )