├── .coveragerc ├── .dockerignore ├── .gitignore ├── .gitmodules ├── .reloadignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── doc └── screenshot.png ├── docker-compose-example.yml ├── docker-compose.yml ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 16709bf9085_.py │ ├── 16d40b17caf_.py │ ├── 31850461ed3_.py │ ├── 365f1188e96_.py │ ├── 400aaaf88e5_.py │ ├── 4ccccdd6b0_.py │ └── 813ee19614_.py ├── puffin.py ├── puffin ├── __init__.py ├── core │ ├── __init__.py │ ├── analytics.py │ ├── applications.py │ ├── backup.py │ ├── compose.py │ ├── config.py │ ├── db.py │ ├── db_tables.py │ ├── docker.py │ ├── machine.py │ ├── mail.py │ ├── network.py │ ├── queue.py │ ├── security.py │ └── stats.py ├── gui │ ├── __init__.py │ ├── forms.py │ └── view.py ├── static │ ├── images │ │ ├── favicon.png │ │ ├── logo.png │ │ ├── new_application.png │ │ ├── puffin-scaled.png │ │ └── puffin.png │ ├── scripts │ │ └── puffin.js │ └── styles │ │ └── puffin.css ├── templates │ ├── about.html │ ├── application.html │ ├── application_backup.html │ ├── application_settings.html │ ├── applications.html │ ├── base.html │ ├── index.html │ ├── macros.html │ ├── mail │ │ ├── new_user.txt │ │ └── test.txt │ ├── profile.html │ └── security │ │ ├── change_password.html │ │ ├── login_user.html │ │ ├── register_user.html │ │ └── send_confirmation.html └── util │ └── __init__.py ├── pytest.ini ├── requirements.txt └── test ├── __init__.py └── unit_test ├── __init__.py └── test_dummy.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = 3 | puffin/* 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | env 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,vim,tags 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # IPython Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv/ 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | 97 | ### Vim ### 98 | # swap 99 | [._]*.s[a-w][a-z] 100 | [._]s[a-w][a-z] 101 | # session 102 | Session.vim 103 | # temporary 104 | .netrwhist 105 | *~ 106 | # auto-generated tag files 107 | tags 108 | 109 | 110 | ### Tags ### 111 | # Ignore tags created by etags, ctags, gtags (GNU global) and cscope 112 | TAGS 113 | .TAGS 114 | !TAGS/ 115 | tags 116 | .tags 117 | !tags/ 118 | gtags.files 119 | GTAGS 120 | GRTAGS 121 | GPATH 122 | GSYMS 123 | cscope.files 124 | cscope.out 125 | cscope.in.out 126 | cscope.po.out 127 | 128 | 129 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "apps"] 2 | path = apps 3 | url = git@github.com:puffinrocks/apps 4 | -------------------------------------------------------------------------------- /.reloadignore: -------------------------------------------------------------------------------- 1 | .*\.sw.* 2 | \d{4} 3 | .*\.pyc 4 | .*\.pyc\..* 5 | .*\.log 6 | .*\.git/.* 7 | .*~ 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | git: 4 | submodules: false 5 | 6 | services: 7 | - docker 8 | 9 | before_install: 10 | - sed -i 's/git@github.com:/https:\/\/github.com\//' .gitmodules 11 | - git submodule update --init 12 | - sed -i 's/git@github.com:/https:\/\/github.com\//' apps/.gitmodules 13 | - git submodule update --init --recursive 14 | 15 | script: 16 | - docker-compose run -d --no-deps puffindb 17 | - docker-compose run --no-deps puffin test 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.7 2 | 3 | * Add app: Mastodon 4 | * Add app: Nextcloud 5 | * Allow uploads of over 2M in proxy 6 | * Remove Forum for simplicity 7 | 8 | 0.6 9 | 10 | * Add apps: Tiny Tiny RSS, wallabag, Piwik 11 | * Fix look of various pages on mobile 12 | * Support application version to prepare for application upgrade 13 | * Application stats and list commands 14 | * Simplify deployment 15 | * Update various apps and fix various bugs 16 | * Manual migration: Run 'app init_running' command to update table with currently running apps 17 | 18 | 0.5 19 | 20 | * Convert docker-compose.yml files to version 2, new networking, volumes 21 | * Email support in all Apps 22 | * Get rid of App manifest.yml file, use README.md instead 23 | * Create puffinrocks organisation on Github on Dockerhub, move all projects there 24 | * Separate Apps into independent repositories - allows automatic image generation and maintenance 25 | * Dnsmasq for easier development 26 | * Always use docker images instead of building 27 | * Update example Compose file 28 | * puffin.rocks usability improvements (simple stats, OpenGraph) 29 | * Various bugfixes 30 | 31 | 0.4 32 | 33 | * Add Flarum forum app, configure as Puffin forum 34 | * Application screenshots 35 | * List of user applications 36 | * Allow to disable registration and emails 37 | * Always create default Puffin user 38 | * Single and multiple Puffin installation config and description 39 | * Rewrite user and update developer documentation 40 | 41 | 0.3 42 | 43 | * Application settings, custom domain 44 | * User profile, change password 45 | * Allow building dependencies to use optimized images 46 | * Preserve volumes between restarts via data-only containers 47 | * Add Gogs Git hosting app 48 | * Handle signals via dumb-init, graceful shutdown 49 | * Application presentation, website, subtitle 50 | * Start, stop and other icons 51 | * Main menu links 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | There are many ways you can contribute to Puffin project, I hope that everyone will find something for themselves. 4 | 5 | ## Discussion and Contact 6 | 7 | If you would like to discuss any subject related to Puffin please create an [issue on GitHub](https://github.com/puffinrocks/puffin/issues). 8 | 9 | If you'd like to contact us directly, please send an email to [puffin@puffin.rocks](mailto:puffin@puffin.rocks). 10 | 11 | ## Issues 12 | 13 | If you find an bug in Puffin, or would like to propose an enhancement, 14 | please report it via [GitHub Issues](https://github.com/puffinrocks/puffin/issues). 15 | 16 | ## Adding new applications to the catalog 17 | 18 | You are very welcome to submit your own or your favourite application to the Puffin catalog. See [apps](https://github.com/puffinrocks/apps) 19 | for more details. 20 | 21 | ## Development 22 | 23 | For core project organisation we use [Trello](https://trello.com/b/ov1cHTtu). 24 | If you'd like to contribute code, feel free to open a [Github Issue](https://github.com/puffinrocks/puffin/issues) 25 | and send us a pull request. 26 | 27 | ## Running your own Puffin 28 | 29 | You can run your own Puffin for yourself and your friends. 30 | 31 | First you need to do is to is to install Docker on your server - the easiest way to 32 | achieve that is via [Docker Machine](README.md#docker-machine). 33 | Next you need to run Puffin [Production Deployment](README.md#production-deployment). 34 | 35 | If you run into any trouble, please feel free to create an [issue on GitHub](https://github.com/puffinrocks/puffin/issues). 36 | 37 | Keep in mind the software is in early alpha development stage, so for the moment it should be used for 38 | experimentation only. 39 | 40 | ## Donations 41 | 42 | If you'd like to donate money to Puffin to help us maintain our infrastructure and fund the development, 43 | click on the below button: 44 | 45 | [![Gratipay](https://img.shields.io/gratipay/loomchild.svg)](https://gratipay.com/~loomchild/) 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6.5 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt /usr/src/app/ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . /usr/src/app 9 | 10 | ENV PYTHONUNBUFFERED=1 11 | ENTRYPOINT ["python3", "puffin.py"] 12 | CMD ["up"] 13 | 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Puffin 2 | [![Build Status](https://travis-ci.org/puffinrocks/puffin.svg?branch=master)](https://travis-ci.org/puffinrocks/puffin) 3 | 4 | **NOTE**: The project is not maintained anymore, the code is here for historical / fork purposes. 5 | 6 | ## Introduction 7 | 8 | The goal of the project is to allow average, tech-oriented user to run web applications with ease. 9 | The idea is to create an easy to host, technology agnostic private cloud. 10 | The ultimate aim is to achieve greater decentralization of web services, such as social networks, 11 | file sharing, blog or email. 12 | 13 | While many other tools are looking at containers as a way to run massive 14 | applications, Puffin concentrates on lightweight ones, each serving just a handful of people. 15 | 16 | You can chose to host the applications on Puffin managed platform or on your own server. 17 | 18 | ## Demo 19 | 20 | Live demo platform is available at [puffin.rocks](http://puffin.rocks) 21 | 22 | [![Puffin Front Page](/doc/screenshot.png?raw=true)](http://puffin.rocks) 23 | 24 | ## Architecture 25 | 26 | Puffin consists of two main components - application catalog and interface that provides 27 | means to run the applications. Any of them can be used independently - you 28 | can run the applications from the catalog directly, and you can use the 29 | interface to run your own applications that are not present in the catalog. 30 | 31 | ## Technology 32 | 33 | Puffin is based on [Docker](https://www.docker.com/) containers and 34 | for orchestration is uses [Docker Compose](https://docs.docker.com/compose/). 35 | 36 | Software is written in [Python 3](https://www.python.org/), 37 | using [Flask](http://flask.pocoo.org/) web microframework. 38 | [PosttgreSQL](http://www.postgresql.org/) database is used to store the data. 39 | [Nginx](http://nginx.org/) is used as a reverse proxy. 40 | 41 | ## Deployment 42 | 43 | ### Local deployment 44 | 45 | #### Set-up DNS 46 | 47 | To access installed applications from localhost you need to set-up local DNS. 48 | There are many alternative solutions to this problem, the simplest one is to 49 | add the following lines at the top of your /etc/resolv.conf file: 50 | 51 | nameserver 127.0.0.1 52 | options ndots:0 53 | 54 | Which can be done by executing the following command as root: 55 | 56 | echo -e "nameserver 127.0.0.1\noptions ndots:0\n$(cat /etc/resolv.conf)" > /etc/resolv.conf 57 | 58 | Make sure that you disable your other local DNS server, such as dnsmasq, 59 | before running Puffin. 60 | 61 | #### Clone git repository 62 | 63 | Puffin application catalog is stored as git submodules. When cloning the repo 64 | make sure to use --recursive option: 65 | 66 | git clone --recursive git@github.com:puffinrocks/puffin.git 67 | 68 | Or if you have already cloned the repo then update the submodules in it: 69 | 70 | git submodule update --init --recursive 71 | 72 | #### Run Puffin 73 | 74 | Clone the repository and use [Docker Compose](https://docs.docker.com/compose/): 75 | 76 | docker-compose up 77 | 78 | Go to [http://puffin.localhost](http://puffin.localhost) to access Puffin. 79 | Log In as user "puffin", password "puffin". 80 | Emails sent from Puffin are accessible via embedded Mailhog server at 81 | [http://mailhog.localhost](http://mailhog.localhost). 82 | 83 | If [http://puffin.localhost](http://puffin.localhost) is not accessible you can 84 | try connecting to Puffin via a port: [http://localhost:8080](http://localhost:8080). 85 | However, without DNS configured correctly, you won't be able to access the apps. 86 | 87 | Puffin server is automatically reloaded on every code change thanks 88 | to [reload](https://github.com/loomchild/reload). 89 | To rebuild the code after making more substantial change, such as modifying 90 | dependencies, run: 91 | 92 | docker-compose build 93 | 94 | Puffin contains several convenience commands to upgrade the database, 95 | manage users, execute internal shell, etc. To get a complete list, run: 96 | 97 | docker-compose run puffin --help 98 | 99 | ### Production deployment 100 | 101 | #### Configuration 102 | 103 | To deploy Puffin for private needs, for a single user or a limited number of users, 104 | use [docker-compose-example.yml](./docker-compose-example.yml) file as a basis: 105 | 106 | cp docker-compose-example.yml docker-compose-production.yml 107 | 108 | You need to change SERVER_NAME and VIRTUAL_HOST variables to point to your domain. 109 | You also need to set SECRET_KEY variable to a random value. 110 | 111 | For a full list of configuration options see [puffin/core/config.py](puffin/core/config.py). 112 | 113 | #### Email 114 | 115 | To send emails from Puffin and the applications you need to configure few environment variables 116 | before starting Puffin. It's probably easiest to register to an external email service to avoid 117 | being classified as spammer. The variables are (not all are obligatory, see 118 | [puffin/core/config.py](puffin/core/config.py) for more details): 119 | 120 | MAIL_SERVER 121 | MAIL_PORT 122 | MAIL_USE_TLS 123 | MAIL_USE_SSL 124 | MAIL_USERNAME 125 | MAIL_PASSWORD 126 | MAIL_DEFAULT_SENDER 127 | MAIL_SUPPRESS_SEND 128 | 129 | #### Set-up DNS 130 | 131 | On public server you need to configure wildacard DNS record to point to your 132 | root domain and all its subdomains. 133 | 134 | #### Docker Machine 135 | 136 | If you would like to deploy Puffin on a remote server, Docker Machine comes in handy. 137 | You can easily install Docker [in the cloud](https://docs.docker.com/machine/get-started-cloud/) 138 | or on [your own server](http://loomchild.net/2015/09/20/your-own-docker-machine/). 139 | 140 | To instruct Docker to interact with remote server run: 141 | 142 | eval "$(docker-machine env [machine-name])" 143 | 144 | #### Run Puffin 145 | 146 | Finally you can run Puffin: 147 | 148 | docker-compose -f docker-compose-production.yml up -d 149 | 150 | #### Configure users 151 | 152 | Initially only "puffin" user with "puffin" password will be created - make 153 | sure to change the password before exposing puffin to the outside world. 154 | Later you can either allow other users to register themselves on your platform 155 | (via SECURITY_REGISTERABLE=True config setting) or create them manually: 156 | 157 | docker-compose run puffin user create [login] 158 | 159 | (The password will be the same as login, so it should be changed as soon as 160 | possible.) 161 | 162 | #### Clustering 163 | 164 | Clustering is currently not supported, but you may run apps on a separate 165 | machine than Puffin server itself. To achieve that take a look on MACHINE\_\* options. 166 | You also won't need network sections in your docker-compose file, 167 | since the networks will be created automatically on the remote machine. 168 | 169 | #### Application Update & Backup 170 | 171 | Application versions are regularly updated. In order to assure than new version doesn't 172 | corrupt the data, an automatic backup of all volumes is performed on every application restart. 173 | 174 | # Contributing 175 | 176 | See [CONTRIBUTING.md](CONTRIBUTING.md). 177 | 178 | # Changelog 179 | 180 | See [CHANGELOG.md](CHANGELOG.md). 181 | 182 | # License 183 | 184 | AGPL, see [LICENSE.txt](LICENSE.txt) for details. 185 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/doc/screenshot.png -------------------------------------------------------------------------------- /docker-compose-example.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | puffin: 5 | image: puffinrocks/puffin 6 | volumes: 7 | - /var/run/docker.sock:/var/run/docker.sock 8 | depends_on: 9 | - puffindb 10 | ports: 11 | - "8080:8080" 12 | environment: 13 | - SERVER_NAME= 14 | - VIRTUAL_HOST= 15 | - SECRET_KEY= 16 | - SECURITY_REGISTERABLE=True 17 | - MAIL_SUPPRESS_SEND=False 18 | - MAIL_SERVER= 19 | - MAIL_PORT= 20 | - MAIL_USERNAME= 21 | - MAIL_PASSWORD= 22 | - MAIL_DEFAULT_SENDER=Puffin 23 | - MAIL_USE_TLS=False 24 | - MAIL_USE_SSL=False 25 | networks: 26 | - front 27 | - back 28 | - default 29 | 30 | puffindb: 31 | image: postgres 32 | ports: 33 | - 5432 34 | networks: 35 | back: 36 | 37 | networks: 38 | 39 | front: 40 | back: 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | 5 | puffin: 6 | build: . 7 | image: puffinrocks/puffin 8 | volumes: 9 | - /var/run/docker.sock:/var/run/docker.sock 10 | - .:/usr/src/app 11 | depends_on: 12 | - puffindb 13 | - dns 14 | - mailhog 15 | command: ["up", "-r"] 16 | environment: 17 | - VIRTUAL_HOST=puffin.localhost 18 | - VIRTUAL_PORT=8080 19 | ports: 20 | - "8080:8080" 21 | networks: 22 | - front 23 | - back 24 | - default 25 | 26 | puffindb: 27 | image: postgres 28 | ports: 29 | - 5432 30 | networks: 31 | back: 32 | 33 | dns: 34 | image: puffinrocks/dns 35 | ports: 36 | - 53:53/udp 37 | networks: 38 | back: 39 | tmpfs: 40 | - /run 41 | - /tmp 42 | 43 | dnsfix: 44 | image: sequenceiq/alpine-dig 45 | entrypoint: ["/bin/sh", "-c", "echo nameserver `dig +short dns` > /etc/resolv.conf"] 46 | network_mode: "service:puffin" 47 | 48 | mailhog: 49 | image: mailhog/mailhog 50 | ports: 51 | - "8025:8025" 52 | - 1025 53 | environment: 54 | - VIRTUAL_HOST=mailhog.localhost 55 | - VIRTUAL_PORT=8025 56 | networks: 57 | front: 58 | back: 59 | 60 | networks: 61 | 62 | front: 63 | back: 64 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /migrations/versions/16709bf9085_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 16709bf9085 4 | Revises: None 5 | Create Date: 2015-11-30 15:59:14.995676 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '16709bf9085' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('user', 20 | sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), 21 | sa.Column('name', sa.String(length=128), nullable=False), 22 | sa.Column('email', sa.String(length=256), nullable=False), 23 | sa.Column('password', sa.String(length=256), nullable=True), 24 | sa.Column('active', sa.Boolean(), nullable=True), 25 | sa.Column('confirmed_at', sa.DateTime(), nullable=True), 26 | sa.PrimaryKeyConstraint('user_id') 27 | ) 28 | op.create_index('idx_user_email', 'user', ['email'], unique=True) 29 | ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_index('idx_user_email', table_name='user') 35 | op.drop_table('user') 36 | ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /migrations/versions/16d40b17caf_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 16d40b17caf 4 | Revises: 813ee19614 5 | Create Date: 2015-12-03 13:24:37.083578 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '16d40b17caf' 11 | down_revision = '813ee19614' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('app_installation', sa.Column('status', sa.Integer(), nullable=False)) 20 | op.create_index('idx_app_installation_user_id_app_id', 'app_installation', ['user_id', 'app_id'], unique=True) 21 | ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_index('idx_app_installation_user_id_app_id', table_name='app_installation') 27 | op.drop_column('app_installation', 'status') 28 | ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/31850461ed3_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 31850461ed3 4 | Revises: 365f1188e96 5 | Create Date: 2016-02-12 23:50:58.951032 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '31850461ed3' 11 | down_revision = '365f1188e96' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('application_settings', 20 | sa.Column('application_settings_id', postgresql.UUID(as_uuid=True), nullable=False), 21 | sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), 22 | sa.Column('application_id', sa.String(length=64), nullable=False), 23 | sa.Column('settings', postgresql.JSON(), nullable=False), 24 | sa.PrimaryKeyConstraint('application_settings_id') 25 | ) 26 | op.create_index('idx_application_settings', 'application_settings', ['user_id', 'application_id'], unique=True) 27 | ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_index('idx_application_settings', table_name='application_settings') 33 | op.drop_table('application_settings') 34 | ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/versions/365f1188e96_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 365f1188e96 4 | Revises: 4ccccdd6b0 5 | Create Date: 2015-12-04 15:51:49.247979 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '365f1188e96' 11 | down_revision = '4ccccdd6b0' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.drop_table('app_installation') 20 | ### end Alembic commands ### 21 | 22 | 23 | def downgrade(): 24 | ### commands auto generated by Alembic - please adjust! ### 25 | op.create_table('app_installation', 26 | sa.Column('app_installation_id', postgresql.UUID(), autoincrement=False, nullable=False), 27 | sa.Column('version_id', sa.INTEGER(), autoincrement=False, nullable=False), 28 | sa.Column('user_id', postgresql.UUID(), autoincrement=False, nullable=False), 29 | sa.Column('app_id', sa.VARCHAR(length=128), autoincrement=False, nullable=True), 30 | sa.Column('status_id', sa.INTEGER(), autoincrement=False, nullable=False), 31 | sa.ForeignKeyConstraint(['user_id'], ['user.user_id'], name='app_installation_user_id_fkey'), 32 | sa.PrimaryKeyConstraint('app_installation_id', name='app_installation_pkey') 33 | ) 34 | ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/versions/400aaaf88e5_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 400aaaf88e5 4 | Revises: 16709bf9085 5 | Create Date: 2015-12-01 23:31:27.438694 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '400aaaf88e5' 11 | down_revision = '16709bf9085' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('user', sa.Column('login', sa.String(length=32), nullable=False)) 20 | op.create_index('idx_user_login', 'user', ['login'], unique=True) 21 | ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_index('idx_user_login', table_name='user') 27 | op.drop_column('user', 'login') 28 | ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/4ccccdd6b0_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 4ccccdd6b0 4 | Revises: 16d40b17caf 5 | Create Date: 2015-12-03 14:04:34.407265 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '4ccccdd6b0' 11 | down_revision = '16d40b17caf' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('app_installation', sa.Column('status_id', sa.Integer(), nullable=False)) 20 | op.drop_column('app_installation', 'status') 21 | ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | ### commands auto generated by Alembic - please adjust! ### 26 | op.add_column('app_installation', sa.Column('status', sa.INTEGER(), autoincrement=False, nullable=False)) 27 | op.drop_column('app_installation', 'status_id') 28 | ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/813ee19614_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 813ee19614 4 | Revises: 400aaaf88e5 5 | Create Date: 2015-12-03 12:05:25.680915 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '813ee19614' 11 | down_revision = '400aaaf88e5' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('app_installation', 20 | sa.Column('app_installation_id', postgresql.UUID(as_uuid=True), nullable=False), 21 | sa.Column('version_id', sa.Integer(), nullable=False), 22 | sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), 23 | sa.Column('app_id', sa.String(length=128), nullable=True), 24 | sa.ForeignKeyConstraint(['user_id'], ['user.user_id'], ), 25 | sa.PrimaryKeyConstraint('app_installation_id') 26 | ) 27 | ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table('app_installation') 33 | ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /puffin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | import sys 5 | 6 | import waitress 7 | import reload as reload_module 8 | import flask_script 9 | import flask_migrate 10 | import pytest 11 | 12 | from puffin import app 13 | from puffin import core 14 | from puffin.core import db 15 | from puffin.core import queue 16 | from puffin.core import mail 17 | from puffin.core import security 18 | from puffin.core import docker 19 | from puffin.core import applications 20 | from puffin.core import machine 21 | from puffin.core import compose 22 | from puffin.core import network 23 | from puffin.core import backup as backup_module 24 | 25 | 26 | manager = flask_script.Manager(app, with_default_commands=False) 27 | 28 | 29 | @manager.command 30 | @manager.option("-r", "--reload", action="store_true", 31 | help="Reload the server if any file changes") 32 | def server(reload=False): 33 | "Run the server" 34 | if reload: 35 | reload_module.reload_me("server") 36 | else: 37 | waitress.serve(app, host=app.config["HOST"], port=app.config["PORT"], 38 | threads=app.config["THREADS"]) 39 | 40 | 41 | def make_shell_context(): 42 | return dict(app=app, db=db.db, queue=queue, mail=mail, 43 | security=security, docker=docker, applications=applications, 44 | machine=machine, compose=compose, network=network) 45 | 46 | manager.add_command("shell", flask_script.Shell(make_context=make_shell_context)) 47 | 48 | 49 | @flask_migrate.MigrateCommand.command 50 | def create(): 51 | "Create the database" 52 | db_create() 53 | 54 | def db_create(): 55 | name = app.config["DB_NAME"] 56 | create_database(name) 57 | create_database(name + "_test") 58 | 59 | def create_database(name): 60 | if db.create(name): 61 | print("Created {} database".format(name)) 62 | 63 | manager.add_command('db', flask_migrate.MigrateCommand) 64 | 65 | 66 | machine = flask_script.Manager(usage="Perform hosting server operations") 67 | 68 | @machine.command 69 | def network(): 70 | "Create Docker networks" 71 | machine_network() 72 | 73 | def machine_network(): 74 | if docker.create_networks(): 75 | print("Created Docker networks on machine") 76 | 77 | @machine.command 78 | def volume(): 79 | "Create Docker volumes" 80 | machine_volume() 81 | 82 | def machine_volume(): 83 | if docker.create_volumes(): 84 | print("Created Docker volumes on machine") 85 | 86 | 87 | @machine.command 88 | def proxy(): 89 | "Install Docker proxy" 90 | machine_proxy() 91 | 92 | def machine_proxy(): 93 | if docker.install_proxy(): 94 | print("Installed Docker proxy on machine") 95 | 96 | @machine.command 97 | def mail(): 98 | "Install Docker mail" 99 | machine_mail() 100 | 101 | def machine_mail(): 102 | if docker.install_mail(): 103 | print("Installed Docker mail on machine") 104 | 105 | manager.add_command("machine", machine) 106 | 107 | 108 | user = flask_script.Manager(usage="Manage users") 109 | 110 | @user.command 111 | def create(login): 112 | "Create a user" 113 | user_create(login) 114 | 115 | def user_create(login): 116 | user = security.get_user(login) 117 | if not user: 118 | security.create_user(login) 119 | else: 120 | print("User {} already exists".format(login)) 121 | 122 | @user.command 123 | def activate(login): 124 | "Activate a user" 125 | user_activate(login) 126 | 127 | def user_activate(login): 128 | if not security.activate_user(login): 129 | print("User {} is already active".format(login)) 130 | 131 | @user.command 132 | def deactivate(login): 133 | "Deactivate a user" 134 | user_deactivate(login) 135 | 136 | def user_deactivate(login): 137 | if not security.deactivate_user(login): 138 | print("User {} is already not active".format(login)) 139 | 140 | @user.command 141 | def list(): 142 | "List all users" 143 | user_list() 144 | 145 | def user_list(): 146 | users = security.get_all_users() 147 | line_format = "{:<14} {:<26} {:<20} {!s:<6} {!s:<9}" 148 | print(line_format.format("Login", "Email", "Name", "Active", "Confirmed")) 149 | print("-" * 79) 150 | for user in users: 151 | print(line_format.format(user.login, user.email, user.name, 152 | user.active, user.confirmed)) 153 | 154 | manager.add_command("user", user) 155 | 156 | 157 | application = flask_script.Manager(usage="Manage apps") 158 | 159 | @application.command 160 | def list(): 161 | "List all apps" 162 | app_list() 163 | 164 | def app_list(): 165 | started_applications = applications.get_all_started_applications() 166 | running_applications = docker.get_all_running_applications() 167 | all_applications = sorted(started_applications.union(running_applications), 168 | key=lambda a: (a[0].login, a[1].application_id)) 169 | 170 | line_format = "{:<14} {:<20} {:<3} {:<30}" 171 | print(line_format.format("User", "Application", "Run", "Domain")) 172 | print("-" * 79) 173 | for application in all_applications: 174 | 175 | running = "Y" if application in running_applications else "N" 176 | 177 | user = application[0] 178 | app = application[1] 179 | domain = applications.get_application_domain(user, app) 180 | if domain: 181 | domain = "http://" + domain 182 | 183 | print(line_format.format(user.login, app.application_id, running, domain)) 184 | 185 | @application.command 186 | def init_running(): 187 | "Initialize currently running applications" 188 | app_init_running() 189 | 190 | def app_init_running(): 191 | running_applications = docker.get_all_running_applications() 192 | for (user, application) in running_applications: 193 | if not applications.get_application_started(user, application): 194 | print("Marking user: {}, application: {} as started" 195 | .format(user.login, application.application_id)) 196 | applications.set_application_started(user, application, True) 197 | 198 | @application.command 199 | def start(user, application): 200 | "Start an application" 201 | app_start(user, application) 202 | 203 | def app_start(user_login, application_id): 204 | user = get_existing_user(user_login) 205 | application = get_existing_application(application_id) 206 | client = docker.get_client() 207 | docker.create_application(client, user, application, False) 208 | 209 | @application.command 210 | def stop(user, application): 211 | "Stop an application" 212 | app_stop(user, application) 213 | 214 | def app_stop(user_login, application_id): 215 | user = get_existing_user(user_login) 216 | application = get_existing_application(application_id) 217 | client = docker.get_client() 218 | docker.delete_application(client, user, application, False) 219 | 220 | @application.command 221 | def backup(user, application): 222 | "Backup application" 223 | app_backup(user, application) 224 | 225 | def app_backup(user_login, application_id): 226 | user = get_existing_user(user_login) 227 | application = get_existing_application(application_id) 228 | backup_module.backup(user, application) 229 | 230 | @application.command 231 | def restore(user, application, name): 232 | "Restore application from backup with given name" 233 | app_restore(user, application, name) 234 | 235 | def app_restore(user_login, application_id, backup_name): 236 | user = get_existing_user(user_login) 237 | application = get_existing_application(application_id) 238 | backups = backup_module.restore(user, application, backup_name) 239 | 240 | @application.command 241 | def backups(user, application): 242 | "List application backups" 243 | app_backups(user, application) 244 | 245 | def app_backups(user_login, application_id): 246 | user = get_existing_user(user_login) 247 | application = get_existing_application(application_id) 248 | backups = backup_module.list(user, application) 249 | for backup in backups: 250 | print(backup) 251 | 252 | manager.add_command("app", application) 253 | 254 | 255 | @manager.command 256 | @manager.option("-c", "--coverage", action="store_true", 257 | help="Report test code coverage") 258 | def test(coverage=False): 259 | "Run automated tests" 260 | args = [] 261 | 262 | if coverage: 263 | args.append("--cov=.") 264 | 265 | pytest.main(args) 266 | 267 | 268 | @manager.command 269 | def wait(): 270 | "Wait until dependencies are up" 271 | time.sleep(6) 272 | 273 | 274 | def init(): 275 | "Initialize Puffin dependencies" 276 | wait() 277 | db_create() 278 | flask_migrate.upgrade() 279 | user_create("puffin") 280 | machine_network() 281 | machine_volume() 282 | machine_proxy() 283 | machine_mail() 284 | 285 | 286 | @manager.command 287 | @manager.option("-r", "--reload", action="store_true", 288 | help="Reload the server if any file changes") 289 | def up(reload=False): 290 | "Initialize Puffin dependencies and run the server" 291 | init() 292 | server(reload) 293 | 294 | 295 | def get_existing_user(user_login): 296 | user = security.get_user(user_login) 297 | if not user: 298 | raise Exception("User {} does not exist".format(user_login)) 299 | return user 300 | 301 | def get_existing_application(application_id): 302 | application = applications.get_application(application_id) 303 | if not application: 304 | raise Exception("Application {} does not exist".format(application_id)) 305 | return application 306 | 307 | 308 | if __name__ == "__main__": 309 | core.init() 310 | manager.run() 311 | 312 | -------------------------------------------------------------------------------- /puffin/__init__.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import flask_bootstrap 3 | 4 | app = flask.Flask(__name__) 5 | 6 | flask_bootstrap.Bootstrap(app) 7 | app.config['BOOTSTRAP_SERVE_LOCAL'] = True 8 | 9 | from . import gui 10 | 11 | 12 | -------------------------------------------------------------------------------- /puffin/core/__init__.py: -------------------------------------------------------------------------------- 1 | from . import config 2 | from . import db 3 | from . import mail 4 | from . import queue 5 | from . import security 6 | from . import applications 7 | from . import machine 8 | from . import compose 9 | from . import network 10 | from . import docker 11 | from . import backup 12 | from . import stats 13 | from . import analytics 14 | from . import db_tables 15 | 16 | 17 | def init(): 18 | config.init() 19 | db.init() 20 | queue.init() 21 | mail.init() 22 | security.init() 23 | applications.init() 24 | machine.init() 25 | compose.init() 26 | network.init() 27 | docker.init() 28 | backup.init() 29 | stats.init() 30 | analytics.init() 31 | db_tables.init() 32 | -------------------------------------------------------------------------------- /puffin/core/analytics.py: -------------------------------------------------------------------------------- 1 | import flask_analytics 2 | 3 | from puffin import app 4 | 5 | 6 | analytics = None 7 | 8 | def init(): 9 | global analytics 10 | if app.config['ANALYTICS_PIWIK_BASE_URL']: 11 | app.config['ANALYTICS'] = {} 12 | app.config['ANALYTICS']['PIWIK'] = {} 13 | app.config['ANALYTICS']['PIWIK']['BASE_URL'] = app.config['ANALYTICS_PIWIK_BASE_URL'] 14 | app.config['ANALYTICS']['PIWIK']['SITE_ID'] = app.config['ANALYTICS_PIWIK_SITE_ID'] 15 | analytics = flask_analytics.Analytics(app) 16 | -------------------------------------------------------------------------------- /puffin/core/applications.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import os.path 4 | import cachetools 5 | import enum 6 | import re 7 | 8 | import yaml 9 | import bleach 10 | import flaskext.markdown 11 | import flask_bleach 12 | 13 | from puffin import app 14 | from .db import db, update_model_with_json 15 | from .. import util 16 | from . import security 17 | 18 | 19 | APPLICATION_HOME = os.path.join(util.HOME, "apps") 20 | 21 | # Name separator between user and application, also check apps/_proxy 22 | APPLICATION_SEPARATOR = "xxxx" 23 | 24 | SUBDOMAIN_PATTERN = re.compile('(?:VIRTUAL_HOST|LETSENCRYPT_HOST)_([A-Za-z0-9]+)') 25 | 26 | application_cache = cachetools.TTLCache(maxsize=1, ttl=120) 27 | 28 | 29 | class Application: 30 | 31 | def __init__(self, application_id): 32 | self.application_id = application_id 33 | self.path = os.path.join(APPLICATION_HOME, self.application_id) 34 | 35 | readme = "" 36 | if os.path.isfile(os.path.join(self.path, "README.md")): 37 | with open(os.path.join(self.path, "README.md")) as readme_file: 38 | readme = readme_file.read() 39 | 40 | readme_lines = readme.split('\n', 2) 41 | self.name = re.sub(r'\s*#\s*', '', readme_lines[0]) if len(readme) > 0 else application_id 42 | self.subtitle = readme_lines[1].strip().strip("_") if len(readme_lines) > 1 else "" 43 | self.description = "\n".join(readme_lines[1:]) if len(readme_lines) > 1 else "" 44 | self.description = re.sub(r'([a-z0-9]+(/[a-z0-9-_]+)*\.(png|jpg))', '/media/' + application_id + r'/\1', self.description) 45 | 46 | self.compose = os.path.join(self.path, "docker-compose.yml") 47 | 48 | # add prefix to trick ad blockers 49 | self.logo = 'image-' + os.path.join(self.application_id, "logo.png") 50 | 51 | compose_data = {} 52 | with open(self.compose) as compose_file: 53 | compose_data = yaml.safe_load(compose_file) 54 | 55 | self.main_image = util.safe_get(compose_data, "services", "main", "image") 56 | if not self.main_image: 57 | raise Exception("Missing main image in docker-compose.yml for application: " + application_id) 58 | 59 | # Retrieve all volumes except external ones 60 | self.volumes = [v[0] for v in compose_data.get("volumes", {}).items() 61 | if not (v[1] and "external" in v[1])] 62 | 63 | subdomains = set() 64 | for service in compose_data["services"].values(): 65 | for env in service.get("environment", []): 66 | m = SUBDOMAIN_PATTERN.search(env) 67 | if m: 68 | subdomains.add(m.group(1).lower()) 69 | self.subdomains = list(subdomains) 70 | 71 | def __eq__(self, other): 72 | return self.application_id == other.application_id 73 | 74 | def __hash__(self): 75 | return hash(self.application_id) 76 | 77 | 78 | class ApplicationStatus(enum.Enum): 79 | DELETED = 0 80 | CREATED = 10 81 | UPDATING = 20 82 | ERROR = 90 83 | 84 | 85 | class ApplicationSettings: 86 | 87 | def __init__(self, user_id, application_id, settings): 88 | self.user_id = user_id 89 | self.application_id = application_id 90 | self.settings = settings 91 | 92 | self.user = security.get_user(self.user_id) 93 | self.application = get_application(self.application_id) 94 | 95 | def default(self, key): 96 | if key == "domain": 97 | return "{}.{}.{}".format(self.application.application_id, 98 | self.user.login, app.config["SERVER_NAME_FULL"]) 99 | elif key in ("https", "started"): 100 | return False 101 | else: 102 | return None 103 | 104 | def reset(self, key): 105 | self.settings.pop(key, None) 106 | 107 | def __setitem__(self, key, value): 108 | if value != self.default(key): 109 | self.settings[key] = value 110 | else: 111 | self.reset(key) 112 | 113 | def __getitem__(self, key): 114 | return self.settings.get(key, self.default(key)) 115 | 116 | 117 | def init(): 118 | flaskext.markdown.Markdown(app) 119 | app.config['BLEACH_ALLOWED_TAGS'] = bleach.ALLOWED_TAGS + ["p", "h1", "h2", "h3", "h4", "h5", "h6", "img"] 120 | app.config['BLEACH_ALLOWED_ATTRIBUTES'] = dict(bleach.ALLOWED_ATTRIBUTES, img=["src"]) 121 | flask_bleach.Bleach(app) 122 | 123 | def get_application(application_id): 124 | applications = get_applications() 125 | return applications.get(application_id) 126 | 127 | def get_application_list(): 128 | applications = get_applications().values() 129 | 130 | # Filter private applications 131 | applications = (a for a in applications if not a.application_id.startswith("_")) 132 | 133 | # Sort alphabetically 134 | applications = sorted(applications, key=lambda a: a.name.lower()) 135 | 136 | return applications 137 | 138 | @cachetools.cached(application_cache) 139 | def get_applications(): 140 | applications = {} 141 | for application_id in os.listdir(APPLICATION_HOME): 142 | application = load_application(application_id) 143 | if application: 144 | applications[application_id] = application 145 | return applications 146 | 147 | def load_application(application_id): 148 | if application_id.startswith("."): 149 | return None 150 | 151 | path = os.path.join(APPLICATION_HOME, application_id) 152 | if not os.path.isdir(path): 153 | return None 154 | 155 | application = Application(application_id) 156 | return application 157 | 158 | def get_default_application_domain(user, application): 159 | return application.application_id + "." + user.login + "." + app.config["SERVER_NAME_FULL"] 160 | 161 | def get_application_domain(user, application): 162 | default_domain = get_default_application_domain(user, application) 163 | application_settings = \ 164 | get_application_settings(user.user_id, application.application_id) 165 | domain = application_settings.settings.get("domain", default_domain) 166 | return domain 167 | 168 | def get_application_https(user, application): 169 | return get_application_settings(user.user_id, application.application_id)\ 170 | .settings.get("https", False) 171 | 172 | def get_application_started(user, application): 173 | application_settings = \ 174 | get_application_settings(user.user_id, application.application_id) 175 | started = application_settings.settings.get("started", False) 176 | return started 177 | 178 | def set_application_started(user, application, started): 179 | application_settings = \ 180 | get_application_settings(user.user_id, application.application_id) 181 | if started: 182 | application_settings.settings["started"] = True 183 | else: 184 | application_settings.settings.pop("started", None) 185 | update_application_settings(application_settings) 186 | 187 | def get_all_started_applications(): 188 | applications = get_applications() 189 | users = {u.user_id : u for u in security.get_all_users()} 190 | 191 | all_application_settings = db.session.query(ApplicationSettings).all() 192 | started_application_settings = \ 193 | [s for s in all_application_settings if s.settings.get("started")] 194 | 195 | started_applications = [] 196 | for application_settings in started_application_settings: 197 | 198 | application = applications.get(application_settings.application_id) 199 | user = users.get(application_settings.user_id) 200 | 201 | if not application or not user: 202 | continue 203 | 204 | started_applications.append((user, application)) 205 | 206 | return set(started_applications) 207 | 208 | def get_application_name(user, application): 209 | name = user.login + APPLICATION_SEPARATOR 210 | if application: 211 | name += application.application_id 212 | # docker-compose sanitizes project name, see https://github.com/docker/compose/issues/2119 213 | return re.sub(r'[^a-z0-9]', '', name.lower()) 214 | 215 | def get_user_application_id(container_name): 216 | return container_name.split(APPLICATION_SEPARATOR, 1) 217 | 218 | def get_application_settings(user_id, application_id): 219 | application_settings = db.session.query(ApplicationSettings).filter_by( 220 | user_id=user_id, application_id=application_id).first() 221 | if application_settings == None: 222 | application_settings = ApplicationSettings(user_id, application_id, {}) 223 | return application_settings 224 | 225 | def update_application_settings(application_settings): 226 | if application_settings.settings: 227 | update_model_with_json(application_settings) 228 | db.session.commit() 229 | elif application_settings.application_settings_id: 230 | db.session.delete(application_settings) 231 | db.session.commit() 232 | -------------------------------------------------------------------------------- /puffin/core/backup.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from puffin import app 4 | from . import docker 5 | from . import security 6 | from . import applications 7 | 8 | 9 | def init(): 10 | pass 11 | 12 | def backup(user, application): 13 | client = docker.get_client() 14 | 15 | application_status = docker.get_application_status(client, user, application) 16 | if application_status not in (applications.ApplicationStatus.DELETED, applications.ApplicationStatus.UPDATING): 17 | raise Exception("Can't backup running application, user: {}, application: {}" 18 | .format(user.login, application.application_id)) 19 | 20 | backup_name = get_timestamp() 21 | 22 | admin = security.get_admin() 23 | backup_application = applications.get_application("_backup") 24 | 25 | for volume in application.volumes: 26 | full_volume = get_full_volume(user, application, volume) 27 | full_archive = get_full_archive(user, application, volume, backup_name) 28 | output = docker.run_service(admin, backup_application, "backup", 29 | BACKUP="puffin_backup", VOLUME=full_volume, ARCHIVE=full_archive) 30 | if output: 31 | print(output) 32 | 33 | def restore(user, application, backup_name): 34 | client = docker.get_client() 35 | 36 | if docker.get_application_status(client, user, application) != applications.ApplicationStatus.DELETED: 37 | raise Exception("Can't restore running application, user: {}, application: {}" 38 | .format(user.login, application.application_id)) 39 | 40 | admin = security.get_admin() 41 | backup_application = applications.get_application("_backup") 42 | 43 | for volume in application.volumes: 44 | full_volume = get_full_volume(user, application, volume) 45 | full_archive = get_full_archive(user, application, volume, backup_name) 46 | output = docker.run_service(admin, backup_application, "restore", 47 | BACKUP="puffin_backup", VOLUME=full_volume, ARCHIVE=full_archive) 48 | if output: 49 | print(output) 50 | 51 | def list(user, application): 52 | admin = security.get_admin() 53 | backup_application = applications.get_application("_backup") 54 | application_name = applications.get_application_name(user, application) 55 | 56 | backups = docker.run_service(admin, backup_application, "list", 57 | BACKUP="puffin_backup", VOLUME="puffin_backup", ARCHIVE=application_name) 58 | 59 | return sorted(backups.split(), reverse=True) 60 | 61 | def delete_old(): 62 | pass 63 | 64 | 65 | def get_full_volume(user, application, volume): 66 | application_name = applications.get_application_name(user, application) 67 | return application_name + "_" + volume 68 | 69 | def get_full_archive(user, application, volume, backup_name): 70 | application_name = applications.get_application_name(user, application) 71 | return application_name + "/" + backup_name + "/" + volume 72 | 73 | def get_timestamp(): 74 | return datetime.datetime.today().strftime("%Y-%m-%d_%H:%M:%S") 75 | -------------------------------------------------------------------------------- /puffin/core/compose.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from puffin import app 5 | from . import applications 6 | from . import machine as machine_module 7 | from . import security 8 | 9 | 10 | def init(): 11 | pass 12 | 13 | def compose_start(machine, user, application, **environment): 14 | return compose_run(machine, user, application, "up", "-d", **environment) 15 | 16 | def compose_stop(machine, user, application): 17 | return compose_run(machine, user, application, "down") 18 | 19 | def compose_run(machine, user, application, *arguments, **environment): 20 | name = applications.get_application_name(user, application) 21 | args = ["docker-compose", "-f", application.compose, "-p", name] 22 | args += arguments 23 | 24 | env = _get_env(machine, user, application, **environment) 25 | 26 | process = subprocess.Popen(args, 27 | stderr=subprocess.STDOUT, stdout=subprocess.PIPE, 28 | universal_newlines=True, env=env) 29 | process.wait() 30 | out, err = process.communicate() 31 | out = out.strip() 32 | return out 33 | 34 | def _get_env(machine, user, application, **environment): 35 | domain = applications.get_application_domain(user, application) 36 | env = dict(PATH=os.environ['PATH'], VIRTUAL_HOST=domain) 37 | 38 | env.update({k: v for (k, v) in zip( 39 | map(lambda s: 'VIRTUAL_HOST_' + s.upper(), application.subdomains), 40 | map(lambda s: s + '.' + domain, application.subdomains))}) 41 | 42 | if app.config["LETSENCRYPT"] and applications.get_application_https(user, application): 43 | admin = security.get_admin() 44 | env.update(LETSENCRYPT_HOST=domain, LETSENCRYPT_EMAIL=admin.email, 45 | LETSENCRYPT_TEST="true" if app.config["LETSENCRYPT_TEST"] else "") 46 | 47 | env.update({k: v for (k, v) in zip( 48 | map(lambda s: 'LETSENCRYPT_HOST_' + s.upper(), application.subdomains), 49 | map(lambda s: s + '.' + domain, application.subdomains))}) 50 | 51 | 52 | env.update(machine_module.get_env_vars(machine)) 53 | 54 | env.update(**environment) 55 | 56 | return env 57 | -------------------------------------------------------------------------------- /puffin/core/config.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import re 3 | import os 4 | 5 | from puffin import app 6 | from .. import util 7 | 8 | 9 | class DefaultConfig: 10 | 11 | # Interface and port where to serve Puffin 12 | HOST = "0.0.0.0" 13 | PORT = 8080 14 | 15 | # Number of threads serving the requests (keep in mind in CPython there's [GIL]()). 16 | THREADS = 1 17 | 18 | # Debugging settings, they might be useful during development (see [Flask]()) 19 | DEBUG = False 20 | TESTING = False 21 | 22 | # External address of the server, used when generating installed application domains 23 | # For localhost use: 24 | SERVER_NAME = None 25 | # For remote host use: 26 | # SERVER_NAME = "example.com" 27 | 28 | # Secret key used to encrypt session cookies and passwords 29 | SECRET_KEY = b"puffin" 30 | 31 | # Postgres database connection settings 32 | DB_HOST = "puffindb" 33 | DB_PORT = "5432" 34 | DB_NAME = "puffin" 35 | DB_USER = "postgres" 36 | DB_PASSWORD = "" 37 | 38 | # SMTP connection settings used to send emails 39 | MAIL_SERVER = "mailhog" 40 | MAIL_PORT = 1025 41 | MAIL_USE_TLS = False 42 | MAIL_USE_SSL = False 43 | MAIL_USERNAME = None 44 | MAIL_PASSWORD = None 45 | MAIL_DEFAULT_SENDER = "puffin " 46 | MAIL_SUPPRESS_SEND = False 47 | 48 | # Account registration 49 | SECURITY_REGISTERABLE = True 50 | 51 | # Docker machine settings. 52 | # For local docker instance use the following settings: 53 | MACHINE_URL = "unix://var/run/docker.sock" 54 | MACHINE_PATH = None 55 | # For docker running on a remote machine use the following settings: 56 | # IP address and port number of the machine 57 | # MACHINE_URL=https://123.45.67.89:2376 58 | # Path to a directory containing cert.pem, key.pem and ca.pem files, 59 | # generated by docker-machine. 60 | # MACHINE_PATH=/etc/puffin/machine/ 61 | 62 | # HTTPS / Let's Encrypt 63 | LETSENCRYPT = False 64 | LETSENCRYPT_TEST = True 65 | 66 | # URL and site ID for [Piwik](https://piwik.org). 67 | ANALYTICS_PIWIK_BASE_URL = None 68 | ANALYTICS_PIWIK_SITE_ID = None 69 | 70 | # Extra notifications for administrator 71 | NEW_USER_NOTIFICATION = False 72 | 73 | # Extra links that will appear in the main menu 74 | LINK_1 = None 75 | LINK_2 = None 76 | LINK_3 = None 77 | 78 | def init(): 79 | app.config.from_object("puffin.core.config.DefaultConfig") 80 | 81 | app.config.update(get_env_vars()) 82 | 83 | app.config['VERSION'] = get_version() 84 | app.config['SERVER_NAME_FULL'] = get_server_name_full() 85 | app.config['LINKS'] = get_links() 86 | 87 | validate() 88 | 89 | 90 | def get_version(): 91 | version = (None, None) 92 | try: 93 | description = subprocess.check_output( 94 | ["git", "describe", "--long", "--match", "[0-9].*"], 95 | stderr=subprocess.STDOUT, cwd=util.HOME, universal_newlines=True) 96 | m = re.match(r"([\w\.]+)-\d+-g(\w+)", description) 97 | if m: 98 | version = (m.group(1), m.group(2)) 99 | except subprocess.CalledProcessError: 100 | pass 101 | return version 102 | 103 | def get_env_vars(): 104 | env_vars = {} 105 | for name in (n for n in dir(DefaultConfig) if not n.startswith("_")): 106 | value = os.environ.get(name) 107 | if value != None: 108 | default_value = getattr(DefaultConfig, name) 109 | env_vars[name] = cast_str(value, type(default_value)) 110 | return env_vars 111 | 112 | def cast_str(value, typ): 113 | if typ is bool: 114 | return value.lower() in ("true", "1", "yes") 115 | if typ is int: 116 | return int(value) 117 | else: 118 | return value 119 | 120 | def validate(): 121 | if app.config["SECRET_KEY"] == DefaultConfig.SECRET_KEY: 122 | app.logger.warning("No SECRET_KEY provided, using the default one.") 123 | 124 | def get_server_name_full(): 125 | return app.config["SERVER_NAME"] or "localhost" 126 | 127 | def get_links(): 128 | links = [] 129 | for key in ("LINK_1", "LINK_2", "LINK_3"): 130 | value = app.config.get(key) 131 | if value: 132 | links.append(value.split()) 133 | return links 134 | -------------------------------------------------------------------------------- /puffin/core/db.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | import sqlalchemy.orm 3 | import sqlalchemy.orm.attributes 4 | import sqlalchemy.dialects.postgresql 5 | import flask_sqlalchemy 6 | import flask_migrate 7 | 8 | from puffin import app 9 | 10 | 11 | db = flask_sqlalchemy.SQLAlchemy() 12 | 13 | 14 | def init(): 15 | url = get_url(app.config["DB_USER"], app.config["DB_PASSWORD"], 16 | app.config["DB_HOST"], app.config["DB_PORT"], app.config["DB_NAME"]) 17 | app.config['SQLALCHEMY_DATABASE_URI'] = url 18 | 19 | # Track modifications of objects and emit signals, expensive, perhaps disable 20 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True 21 | 22 | db.init_app(app) 23 | # See http://piotr.banaszkiewicz.org/blog/2012/06/29/flask-sqlalchemy-init_app/, option 2 24 | db.app = app 25 | 26 | migrate = flask_migrate.Migrate(app, db) 27 | 28 | def create(name): 29 | "Create database if it does not exist" 30 | url = get_url(app.config["DB_USER"], app.config["DB_PASSWORD"], 31 | app.config["DB_HOST"], app.config["DB_PORT"], "postgres") 32 | 33 | engine = sqlalchemy.create_engine(url) 34 | with engine.connect() as conn: 35 | 36 | result = conn.execute( 37 | "SELECT 1 FROM pg_catalog.pg_database WHERE datname='{}'" 38 | .format(name)) 39 | if result.first(): 40 | return False 41 | 42 | conn.execute("COMMIT") 43 | conn.execute("CREATE DATABASE {}".format(name)) 44 | 45 | return True 46 | 47 | def get_url(user, password, host, port, name): 48 | string = "postgresql://" 49 | 50 | if user: 51 | string += user 52 | if password: 53 | string += ":" + password 54 | string += "@" 55 | 56 | if host: 57 | string += host 58 | 59 | if port: 60 | string += ":" + port 61 | 62 | if name: 63 | string += "/" + name 64 | 65 | return string 66 | 67 | 68 | def update_model_with_json(model): 69 | # Needed for JSON fields, see https://bashelton.com/2014/03/updating-postgresql-json-fields-via-sqlalchemy/ 70 | mapper = sqlalchemy.orm.object_mapper(model) 71 | for column in mapper.columns.values(): 72 | if isinstance(column.type, sqlalchemy.dialects.postgresql.JSON): 73 | sqlalchemy.orm.attributes.flag_modified(model, column.name) 74 | 75 | db.session.add(model) 76 | 77 | -------------------------------------------------------------------------------- /puffin/core/db_tables.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import sqlalchemy.orm 4 | from sqlalchemy.dialects import postgresql 5 | 6 | from .db import db 7 | from . import applications 8 | from . import security 9 | 10 | 11 | user_table = db.Table('user', 12 | db.Column('user_id', postgresql.UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), 13 | db.Column('login', db.String(32), nullable=False), 14 | db.Column('name', db.String(128), nullable=False), 15 | db.Column('email', db.String(256), nullable=False), 16 | db.Column('password', db.String(256), nullable=True), 17 | db.Column('active', db.Boolean()), 18 | db.Column('confirmed_at', db.DateTime()), 19 | ) 20 | 21 | db.Index('idx_user_login', user_table.c.login, unique=True) 22 | db.Index('idx_user_email', user_table.c.email, unique=True) 23 | 24 | sqlalchemy.orm.mapper(security.User, user_table) 25 | 26 | 27 | application_settings_table = db.Table('application_settings', 28 | db.Column('application_settings_id', postgresql.UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), 29 | db.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), 30 | db.Column('application_id', db.String(64), nullable=False), 31 | db.Column('settings', postgresql.JSON, nullable=False), 32 | ) 33 | 34 | db.Index('idx_application_settings', application_settings_table.c.user_id, 35 | application_settings_table.c.application_id, unique=True) 36 | 37 | sqlalchemy.orm.mapper(applications.ApplicationSettings, application_settings_table) 38 | 39 | 40 | def init(): 41 | pass 42 | -------------------------------------------------------------------------------- /puffin/core/docker.py: -------------------------------------------------------------------------------- 1 | import time 2 | import inspect 3 | 4 | import requests 5 | import requests.exceptions 6 | import docker 7 | import docker.errors 8 | 9 | from puffin import app 10 | from .db import db 11 | from . import machine as machine_module 12 | from . import compose 13 | from . import network 14 | from . import applications 15 | from . import queue 16 | from . import security 17 | from . import backup 18 | from .. import util 19 | 20 | 21 | # How long to wait after creating an app, to allow dependencies startup 22 | APPLICATION_SLEEP_AFTER_CREATE = 10 23 | 24 | # How long wait for application startup 25 | APPLICATION_CREATE_TIMEOUT = 180 26 | 27 | 28 | def init(): 29 | pass 30 | 31 | def get_client(): 32 | machine = machine_module.get_machine() 33 | client = docker.DockerClient(base_url=machine.url, tls=machine_module.get_tls_config(machine), version="auto") 34 | client.ping() 35 | return client 36 | 37 | def create_application(client, user, application, async=True): 38 | if get_application_status(client, user, application) != applications.ApplicationStatus.DELETED: 39 | raise RuntimeError("Application already installed or updating, user: {}, application: {}".format(user.login, application.application_id)) 40 | name = applications.get_application_name(user, application) 41 | 42 | if async: 43 | queue.task(name, create_application_task, user.user_id, application) 44 | else: 45 | create_application_task(user.user_id, application, False) 46 | 47 | def create_application_task(user_id, application, wait=True): 48 | user = db.session.query(security.User).get(user_id) 49 | network.create_network(get_client(), applications.get_application_name(user, application) + "_default") 50 | output = compose.compose_start(machine_module.get_machine(), user, application) 51 | if output: 52 | print(output) 53 | application_url = "http://" + applications.get_application_domain(user, application) 54 | if wait: 55 | time.sleep(APPLICATION_SLEEP_AFTER_CREATE) 56 | wait_until_up(application_url) 57 | applications.set_application_started(user, application, True) 58 | 59 | def delete_application(client, user, application, async=True): 60 | if get_application_status(client, user, application) != applications.ApplicationStatus.CREATED: 61 | raise RuntimeError("Application not installed or updating, user: {}, application: {}".format(user.login, application.application_id)) 62 | name = applications.get_application_name(user, application) 63 | 64 | if async: 65 | queue.task(name, delete_application_task, user.user_id, application) 66 | else: 67 | delete_application_task(user.user_id, application) 68 | 69 | def delete_application_task(user_id, application): 70 | user = db.session.query(security.User).get(user_id) 71 | output = compose.compose_stop(machine_module.get_machine(), user, application) 72 | if output: 73 | print(output) 74 | backup.backup(user, application) 75 | applications.set_application_started(user, application, False) 76 | 77 | def run_service(user, application, service, *arguments, **environment): 78 | return compose.compose_run(machine_module.get_machine(), user, application, "run", "--rm", service, *arguments, **environment) 79 | 80 | def get_application_status(client, user, application): 81 | container = get_main_container(client, user, application) 82 | return _get_application_status(user, application, container) 83 | 84 | def get_application_statuses(client, user): 85 | apps = applications.get_applications() 86 | application_statuses = [] 87 | container_name = applications.get_application_name(user, None) + ".*_main_1" 88 | containers = get_containers(client, container_name) 89 | for container in containers: 90 | user_application_id = _get_user_application_id(container) 91 | if not user_application_id: 92 | continue 93 | login, application_id = user_application_id 94 | 95 | application = apps.get(application_id) 96 | if not application: 97 | continue 98 | 99 | status = _get_application_status(user, application, container) 100 | application_statuses.append((application, status)) 101 | return application_statuses 102 | 103 | def _get_application_status(user, application, container): 104 | name = applications.get_application_name(user, application) 105 | if queue.task_exists(name): 106 | return applications.ApplicationStatus.UPDATING 107 | if container: 108 | return applications.ApplicationStatus.CREATED 109 | else: 110 | return applications.ApplicationStatus.DELETED 111 | 112 | def get_all_running_applications(): 113 | apps = applications.get_applications() 114 | users = {u.login : u for u in security.get_all_users()} 115 | 116 | client = get_client() 117 | containers = get_containers(client, "_main_1") 118 | 119 | running_applications = [] 120 | for container in containers: 121 | user_application_id = _get_user_application_id(container) 122 | if not user_application_id: 123 | continue 124 | login, application_id = user_application_id 125 | 126 | application = apps.get(application_id) 127 | user = users.get(login) 128 | if not application or not user: 129 | continue 130 | 131 | running_applications.append((user, application)) 132 | 133 | return set(running_applications) 134 | 135 | def _get_user_application_id(container): 136 | if container.name.endswith("_main_1"): 137 | return applications.get_user_application_id(container.name[:-7]) 138 | return None 139 | 140 | def get_application_image_version(client, application): 141 | image_name = application.main_image 142 | try: 143 | image = client.images.get(image_name) 144 | except docker.errors.ImageNotFound: 145 | return None 146 | env_list = util.safe_get(image.attrs, "Config", "Env") 147 | env = util.env_dict(env_list) 148 | return env.get("VERSION") 149 | 150 | def get_application_version(client, user, application): 151 | name = applications.get_application_name(user, application) 152 | container = get_main_container(client, user, application) 153 | if not container: 154 | return None 155 | env_list = util.safe_get(container.attrs, "Config", "Env") 156 | env = util.env_dict(env_list) 157 | return env.get("VERSION") 158 | 159 | def get_containers(client, name=""): 160 | return client.containers.list(filters=dict(name=name)) 161 | 162 | def get_main_container(client, user, application): 163 | name = applications.get_application_name(user, application) + "_main_1" 164 | containers = get_containers(client, name) 165 | if len(containers) == 1: 166 | return containers[0] 167 | else: 168 | return None 169 | 170 | def wait_until_up(url, timeout=APPLICATION_CREATE_TIMEOUT): 171 | start_time = time.time() 172 | while True: 173 | try: 174 | r = requests.get(url) 175 | if r.status_code == 200: 176 | break 177 | except requests.exceptions.RequestException: 178 | pass 179 | if start_time + timeout <= time.time(): 180 | break 181 | time.sleep(1) 182 | 183 | def install_proxy(): 184 | return _install("_proxy") 185 | 186 | def install_mail(): 187 | env = { 188 | 'MAIL_SERVER': app.config['MAIL_SERVER'], 189 | 'MAIL_PORT': str(app.config['MAIL_PORT']), 190 | 'MAIL_USERNAME': app.config['MAIL_USERNAME'] or 'username', 191 | 'MAIL_PASSWORD': app.config['MAIL_PASSWORD'] or 'password', 192 | } 193 | 194 | return _install("_mail", **env) 195 | 196 | def install_dns(): 197 | return _install("_dns") 198 | 199 | def _install(name, **environment): 200 | client = get_client() 201 | user = security.get_user("puffin") 202 | application = applications.get_application(name) 203 | if get_application_status(client, user, application) != applications.ApplicationStatus.DELETED: 204 | return False 205 | compose.compose_start(machine_module.get_machine(), user, application, **environment) 206 | return True 207 | 208 | def create_networks(): 209 | #TODO: replace with running _network app and remove prefix once custom network names supported 210 | client = get_client() 211 | networks = [network.name for network in client.networks.list(names=["puffin_front", "puffin_back"])] 212 | if len(networks) == 2: 213 | return False 214 | if not "puffin_front" in networks: 215 | client.networks.create("puffin_front") 216 | if not "puffin_back" in networks: 217 | client.networks.create("puffin_back") 218 | return True 219 | 220 | def create_volumes(): 221 | client = get_client() 222 | volumes = client.volumes.list(filters={"name" : "puffin_backup"}) 223 | if len(volumes) > 0: 224 | return False 225 | client.volumes.create("puffin_backup") 226 | return True 227 | -------------------------------------------------------------------------------- /puffin/core/machine.py: -------------------------------------------------------------------------------- 1 | import docker.tls 2 | 3 | from puffin import app 4 | 5 | 6 | class Machine: 7 | 8 | def __init__(self, url, path): 9 | self.url = url 10 | self.path = path 11 | 12 | @property 13 | def cert(self): 14 | return self.path + 'cert.pem' if self.path else None 15 | 16 | @property 17 | def key(self): 18 | return self.path + 'key.pem' if self.path else None 19 | 20 | @property 21 | def ca(self): 22 | return self.path + 'ca.pem' if self.path else None 23 | 24 | 25 | def init(): 26 | pass 27 | 28 | def get_machine(): 29 | url = app.config["MACHINE_URL"] 30 | path = app.config["MACHINE_PATH"] 31 | 32 | return Machine(url, path) 33 | 34 | 35 | def get_tls_config(machine): 36 | if not machine.path: 37 | return None 38 | 39 | return docker.tls.TLSConfig( 40 | client_cert=(machine.cert, machine.key), 41 | verify=machine.ca, 42 | assert_hostname = False 43 | ) 44 | 45 | def get_env_vars(machine): 46 | env = dict(DOCKER_HOST=machine.url) 47 | 48 | if machine.path: 49 | env.update(dict(DOCKER_TLS_VERIFY="1", DOCKER_CERT_PATH=machine.path)) 50 | 51 | return env 52 | -------------------------------------------------------------------------------- /puffin/core/mail.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import flask_mail 3 | 4 | from puffin import app 5 | from . import queue 6 | 7 | 8 | mail = flask_mail.Mail() 9 | 10 | def init(): 11 | mail.init_app(app) 12 | 13 | def send(recipient=None, subject=None, template=None, message=None, async=True, **kwargs): 14 | if not (message or (recipient and subject and template)): 15 | raise ValueError("Provide recipient, subject, template or message") 16 | 17 | if not message: 18 | message = create_message(recipient, subject, template, **kwargs) 19 | 20 | if async: 21 | queue.task(None, send_message, message) 22 | else: 23 | send_message(message) 24 | 25 | def send_message(message): 26 | mail.send(message) 27 | 28 | def create_message(recipient, subject, template, **kwargs): 29 | message = flask_mail.Message(subject, recipients=[recipient]) 30 | message.body = flask.render_template("mail/" + template + ".txt", **kwargs) 31 | return message 32 | -------------------------------------------------------------------------------- /puffin/core/network.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | 3 | import docker.types 4 | 5 | 6 | def init(): 7 | pass 8 | 9 | def get_next_cidr(client): 10 | networks = client.networks.list() 11 | last_cidr = ipaddress.ip_network("10.0.0.0/24") 12 | for network in networks: 13 | if (network.attrs["IPAM"] and network.attrs["IPAM"]["Config"] 14 | and len(network.attrs["IPAM"]["Config"]) > 0 15 | and network.attrs["IPAM"]["Config"][0]["Subnet"]): 16 | cidr = ipaddress.ip_network(network.attrs["IPAM"]["Config"][0]["Subnet"]) 17 | if cidr.network_address.packed[0] == 10: 18 | if cidr.prefixlen != 24: 19 | raise Exception( 20 | "Invalid network prefix length {0} for network {1}" 21 | .format(cidr.prefixlen, network.name)) 22 | if cidr > last_cidr: 23 | last_cidr = cidr 24 | 25 | next_cidr = ipaddress.ip_network((last_cidr.network_address + 256).exploded + "/24") 26 | if next_cidr.network_address.packed[0] > 10: 27 | raise Exception("No more networks available") 28 | last_cidr = next_cidr 29 | return next_cidr 30 | 31 | def create_network(client, name): 32 | cidr = get_next_cidr(client) 33 | print("Creating network {0} with subnet {1}".format(name, cidr.exploded)) 34 | 35 | networks = client.networks.list(names=[name]) 36 | if len(networks) > 0: 37 | for network in networks: 38 | network.remove() 39 | 40 | ipam_pool = docker.types.IPAMPool(subnet=cidr.exploded, 41 | gateway=(cidr.network_address + 1).exploded) 42 | ipam_config = docker.types.IPAMConfig(pool_configs=[ipam_pool]) 43 | client.networks.create(name, ipam=ipam_config) 44 | -------------------------------------------------------------------------------- /puffin/core/queue.py: -------------------------------------------------------------------------------- 1 | import queue as queue_module 2 | import threading 3 | 4 | from puffin import app 5 | from .. import util 6 | 7 | 8 | # Based on http://code.activestate.com/recipes/577187-python-thread-pool/ 9 | 10 | queue = None 11 | 12 | task_ids = util.SafeSet() 13 | 14 | 15 | def init(): 16 | global queue 17 | threads = 1 18 | queue = queue_module.Queue() 19 | for _ in range(threads): 20 | Worker(queue) 21 | 22 | def task(task_id, func, *args, **kwargs): 23 | if task_id: 24 | task_ids.add(task_id) 25 | queue.put((task_id, func, args, kwargs)) 26 | 27 | def task_exists(task_id): 28 | return task_ids.contains(task_id) 29 | 30 | def wait(): 31 | queue.join() 32 | 33 | class Worker(threading.Thread): 34 | """Thread executing tasks from a given tasks queue""" 35 | def __init__(self, queue): 36 | threading.Thread.__init__(self) 37 | self.queue = queue 38 | self.daemon = True 39 | self.start() 40 | 41 | def run(self): 42 | while True: 43 | task_id, func, args, kwargs = self.queue.get() 44 | try: 45 | with app.app_context(): 46 | func(*args, **kwargs) 47 | except Exception as e: 48 | app.logger.warn("Error processing task", exc_info=e) 49 | finally: 50 | self.queue.task_done() 51 | if task_id: 52 | task_ids.remove(task_id) 53 | 54 | -------------------------------------------------------------------------------- /puffin/core/security.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import datetime 3 | 4 | import flask 5 | import flask_security 6 | import flask_security.datastore 7 | import flask_security.forms 8 | import flask_security.utils 9 | import flask_security.signals 10 | import wtforms 11 | from wtforms import validators 12 | 13 | from puffin import app 14 | from .db import db 15 | from . import mail 16 | 17 | 18 | class User(flask_security.UserMixin): 19 | 20 | def __init__(self, login, name, email, password, active, roles, 21 | confirmed=False): 22 | self.login = login 23 | self.name = name 24 | self.email = email 25 | self.password = password 26 | self.active = active 27 | if confirmed: 28 | self.confirmed_at = datetime.datetime.now() 29 | 30 | @property 31 | def id(self): 32 | return self.user_id 33 | 34 | @property 35 | def roles(self): 36 | return [] 37 | 38 | @roles.setter 39 | def roles(self, role): 40 | pass 41 | 42 | @property 43 | def confirmed(self): 44 | return self.confirmed_at != None 45 | 46 | def __eq__(self, other): 47 | if other == None: 48 | return False 49 | # Need to use get_id method because AnonymousUser has it, and it's used 50 | # during registration email verification 51 | return self.get_id() == other.get_id() 52 | 53 | def __hash__(self): 54 | return hash(self.get_id()) 55 | 56 | 57 | security = None 58 | 59 | def init(): 60 | global security 61 | 62 | app.config['SECURITY_CONFIRMABLE'] = not app.config['MAIL_SUPPRESS_SEND'] 63 | app.config['SECURITY_CHANGEABLE'] = True 64 | app.config['SECURITY_SEND_PASSWORD_CHANGE_EMAIL'] = not app.config['MAIL_SUPPRESS_SEND'] 65 | app.config['SECURITY_POST_CHANGE_VIEW'] = "profile.html" 66 | app.config['SECURITY_PASSWORD_HASH'] = "bcrypt" 67 | app.config['SECURITY_MSG_CONFIRMATION_REQUIRED'] = ( 68 | flask.Markup('Email requires confirmation. Resend confirmation instructions.'), 69 | 'error') 70 | # This comes from config: app.config['SECURITY_REGISTERABLE'] 71 | 72 | # Update all salts with SECRET_KEY if they are not set 73 | secret_key = app.config['SECRET_KEY'] 74 | for salt in ('SECURITY_PASSWORD_SALT', 'SECURITY_CONFIRM_SALT', 75 | 'SECURITY_RESET_SALT', 'SECURITY_LOGIN_SALT', 76 | 'SECURITY_REMEMBER_SALT'): 77 | app.config[salt] = app.config.get(salt, secret_key) 78 | 79 | app.config['SECURITY_EMAIL_SENDER'] = app.config['MAIL_DEFAULT_SENDER'] 80 | 81 | app.config['SECURITY_POST_LOGIN_VIEW'] = "/" 82 | 83 | security = flask_security.Security(app, CustomUserDatastore(), 84 | login_form=CustomLoginForm, 85 | register_form=CustomRegisterForm, 86 | confirm_register_form=CustomRegisterForm) 87 | 88 | security.send_mail_task(send_security_mail) 89 | 90 | if app.config['SECURITY_CONFIRMABLE'] and app.config['NEW_USER_NOTIFICATION']: 91 | flask_security.signals.user_confirmed.connect(new_user_notification, app) 92 | 93 | 94 | class CustomUserDatastore(flask_security.datastore.SQLAlchemyDatastore, flask_security.datastore.UserDatastore): 95 | 96 | def __init__(self): 97 | flask_security.datastore.SQLAlchemyDatastore.__init__(self, db) 98 | flask_security.datastore.UserDatastore.__init__(self, User, None) 99 | 100 | def get_user(self, identifier): 101 | if isinstance(identifier, uuid.UUID): 102 | user = db.session.query(User).get(identifier) 103 | else: 104 | user = db.session.query(User).filter_by(email=identifier).first() 105 | if user == None: 106 | user = db.session.query(User).filter_by(login=identifier).first() 107 | return user 108 | 109 | def find_user(self, **kwargs): 110 | if "id" in kwargs: 111 | kwargs["user_id"] = kwargs["id"] 112 | del kwargs["id"] 113 | user = db.session.query(User).filter_by(**kwargs).first() 114 | return user 115 | 116 | def find_role(self, role): 117 | return None 118 | 119 | class CustomLoginForm(flask_security.forms.LoginForm): 120 | 121 | email = wtforms.StringField("Email or Login") 122 | 123 | class CustomRegisterForm(flask_security.forms.RegisterForm): 124 | 125 | login = wtforms.StringField('Login', validators=[validators.Required(), validators.Length(3, 32), 126 | validators.Regexp(r'^[a-z][a-z0-9_]*$', 0, 'Login must have only lowercase letters, numbers or underscores')]) 127 | 128 | name = wtforms.StringField('Name', validators=[validators.Required(), validators.Length(1, 64), 129 | validators.Regexp(r'^[A-Za-z0-9_\- ]+$', 0, 'Name must have only letters, numbers, spaces, dots, dashes or underscores')]) 130 | 131 | def send_security_mail(message): 132 | mail.send(message=message) 133 | 134 | def new_user_notification(sender, user): 135 | admin = get_admin() 136 | mail.send(recipient=admin.email, subject="New Puffin user: " + user.login, template="new_user", user=user) 137 | 138 | def get_user(login): 139 | return security.datastore.get_user(login) 140 | 141 | def create_user(login): 142 | user = security.datastore.create_user(login=login, name=login.capitalize(), 143 | email=login + "@" + app.config["SERVER_NAME_FULL"], 144 | password=flask_security.utils.encrypt_password(login), 145 | confirmed=True) 146 | update_user(user) 147 | 148 | def get_all_users(): 149 | return db.session.query(User).order_by(User.login).all() 150 | 151 | def activate_user(login): 152 | user = db.session.query(User).filter_by(login=login).first() 153 | result = security.datastore.activate_user(user) 154 | update_user(user) 155 | return result 156 | 157 | def deactivate_user(login): 158 | user = db.session.query(User).filter_by(login=login).first() 159 | result = security.datastore.deactivate_user(user) 160 | update_user(user) 161 | return result 162 | 163 | def update_user(user): 164 | db.session.add(user) 165 | db.session.commit() 166 | 167 | def get_admin(): 168 | return get_user("puffin") 169 | -------------------------------------------------------------------------------- /puffin/core/stats.py: -------------------------------------------------------------------------------- 1 | import cachetools 2 | 3 | from . import security 4 | from . import docker 5 | 6 | 7 | stats_cache = cachetools.TTLCache(maxsize=1, ttl=300) 8 | 9 | 10 | class Stats: 11 | pass 12 | 13 | 14 | def init(): 15 | pass 16 | 17 | @cachetools.cached(stats_cache) 18 | def get_stats(): 19 | stats = Stats() 20 | stats.users = get_users() 21 | stats.apps = get_apps() 22 | stats.containers = get_containers() 23 | return stats 24 | 25 | def get_users(): 26 | return len([u for u in security.get_all_users() if u.confirmed]) 27 | 28 | def get_apps(): 29 | return len(docker.get_all_running_applications()) 30 | 31 | def get_containers(): 32 | return len(docker.get_containers(docker.get_client())) 33 | 34 | -------------------------------------------------------------------------------- /puffin/gui/__init__.py: -------------------------------------------------------------------------------- 1 | from . import view 2 | 3 | -------------------------------------------------------------------------------- /puffin/gui/forms.py: -------------------------------------------------------------------------------- 1 | import flask_wtf 2 | from flask_security.core import current_user 3 | import wtforms 4 | from wtforms import validators 5 | 6 | from puffin import app 7 | 8 | 9 | class ApplicationForm(flask_wtf.Form): 10 | start = wtforms.SubmitField('Start') 11 | stop = wtforms.SubmitField('Stop') 12 | 13 | class ApplicationSettingsForm(flask_wtf.Form): 14 | domain = wtforms.StringField('Domain', description="If you change it then make sure you also configure it with your DNS provider") 15 | https = wtforms.BooleanField('HTTPS', description="Enable HTTPS via Let's Encrypt") 16 | update = wtforms.SubmitField('Update') 17 | 18 | def validate(self): 19 | rv = flask_wtf.Form.validate(self) 20 | if not rv: 21 | return False 22 | 23 | if self.domain.data: 24 | server_name = app.config["SERVER_NAME_FULL"] 25 | if (server_name != "localhost" 26 | and not self.domain.data.endswith(current_user.login + "." + server_name) 27 | and self.domain.data.endswith(server_name)): 28 | self.domain.errors.append('Invalid domain, cannot end with ' + server_name) 29 | return False 30 | 31 | return True 32 | 33 | class ApplicationBackupForm(flask_wtf.Form): 34 | name = wtforms.SelectField('Name') 35 | backup = wtforms.SubmitField('Backup') 36 | restore = wtforms.SubmitField('Restore') 37 | 38 | class ProfileForm(flask_wtf.Form): 39 | login = wtforms.StringField('Login') 40 | email = wtforms.StringField('Email') 41 | 42 | name = wtforms.StringField('Name', 43 | validators=[ 44 | validators.Required(), 45 | validators.Length(1, 64), 46 | validators.Regexp(r'^[A-Za-z0-9_\- ]+$', 0, 'Name must have only letters, numbers, spaces, dots, dashes or underscores')]) 47 | 48 | submit = wtforms.SubmitField('Update') 49 | 50 | -------------------------------------------------------------------------------- /puffin/gui/view.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import flask 4 | from flask_security.core import current_user 5 | from flask_security.decorators import login_required 6 | 7 | from puffin import app 8 | from puffin.core import security 9 | from puffin.core import applications 10 | from puffin.core import docker 11 | from puffin.core import backup 12 | from puffin.core import stats 13 | from . import forms 14 | 15 | 16 | @app.context_processor 17 | def utility_processor(): 18 | return dict(current_user=current_user, version=app.config.get("VERSION"), 19 | links=app.config.get("LINKS", []), 20 | ApplicationStatus=applications.ApplicationStatus, 21 | stats=stats.get_stats()) 22 | 23 | @app.route('/', methods=['GET']) 24 | def index(): 25 | return flask.render_template('index.html', applications=applications.get_application_list()) 26 | 27 | @app.route('/about.html', methods=['GET']) 28 | def about(): 29 | return flask.render_template('about.html') 30 | 31 | @app.route('/profile.html', methods=['GET']) 32 | @login_required 33 | def my_profile(): 34 | return flask.redirect(flask.url_for('profile', login=current_user.login)) 35 | 36 | @app.route('/profile/.html', methods=['GET', 'POST']) 37 | @login_required 38 | def profile(login): 39 | if current_user.login != login: 40 | flask.abort(403, "You are not allowed to view this profile") 41 | 42 | user = current_user 43 | 44 | form = forms.ProfileForm(obj=user) 45 | if form.validate_on_submit(): 46 | user.name = form.name.data 47 | security.update_user(user) 48 | return flask.redirect(flask.url_for('profile', login=login)) 49 | 50 | return flask.render_template('profile.html', user=current_user, form=form) 51 | 52 | @app.route('/application/.html', methods=['GET', 'POST']) 53 | def application(application_id): 54 | client = docker.get_client() 55 | application = applications.get_application(application_id) 56 | if not application: 57 | flask.abort(404) 58 | application_status = None 59 | application_domain = None 60 | application_version = None 61 | application_image_version = docker.get_application_image_version(client, application) 62 | form = None 63 | 64 | if current_user.is_authenticated(): 65 | 66 | form = forms.ApplicationForm() 67 | 68 | if form.validate_on_submit(): 69 | if form.start.data: 70 | docker.create_application(client, current_user, application) 71 | 72 | if form.stop.data: 73 | docker.delete_application(client, current_user, application) 74 | return flask.redirect(flask.url_for('application', application_id=application_id)) 75 | 76 | application_status = docker.get_application_status(client, current_user, application) 77 | application_domain = applications.get_application_domain(current_user, application) 78 | application_version = docker.get_application_version(client, current_user, application) 79 | 80 | return flask.render_template('application.html', application=application, 81 | application_status=application_status, application_domain=application_domain, 82 | application_version=application_version, application_image_version=application_image_version, 83 | form=form) 84 | 85 | @app.route('/application/.json', methods=['GET']) 86 | @login_required 87 | def application_status(application_id): 88 | client = docker.get_client() 89 | application = applications.get_application(application_id) 90 | status = docker.get_application_status(client, current_user, application).name 91 | return flask.jsonify(status=status) 92 | 93 | @app.route('/applications', methods=['GET'], endpoint='applications') 94 | @login_required 95 | def apps(): 96 | client = docker.get_client() 97 | app_statuses = docker.get_application_statuses(client, current_user) 98 | apps = [a[0] for a in app_statuses if a[1] == applications.ApplicationStatus.CREATED] 99 | return flask.render_template('applications.html', applications=apps) 100 | 101 | @app.route('/media/') 102 | def media(path): 103 | if not path[-3:] in ("png", "jpg"): 104 | raise Exception("Unsupported media file format") 105 | if path.startswith('image-'): 106 | path = path[6:] 107 | return flask.send_from_directory(applications.APPLICATION_HOME, path) 108 | 109 | @app.route('/application//settings.html', methods=['GET', 'POST']) 110 | @login_required 111 | def application_settings(application_id): 112 | user = current_user 113 | application = applications.get_application(application_id) 114 | 115 | application_settings = applications.get_application_settings(user.user_id, application_id) 116 | default_domain = applications.get_default_application_domain(user, application) 117 | 118 | form = forms.ApplicationSettingsForm() 119 | if form.validate_on_submit(): 120 | domain = form.domain.data.strip() 121 | if len(domain) != 0 and domain != default_domain: 122 | application_settings.settings["domain"] = domain 123 | else: 124 | application_settings.settings.pop("domain", None) 125 | https = form.https.data 126 | if https: 127 | application_settings.settings["https"] = True 128 | else: 129 | application_settings.settings.pop("https", None) 130 | applications.update_application_settings(application_settings) 131 | 132 | flask.flash("Settings updated successfully") 133 | return flask.redirect(flask.url_for('application', application_id=application_id)) 134 | 135 | form.domain.data = applications.get_application_domain(user, application) 136 | form.https.data = applications.get_application_https(user, application) 137 | 138 | return flask.render_template('application_settings.html', application=application, 139 | application_settings=application_settings, form=form) 140 | 141 | @app.route('/application//backup.html', methods=['GET', 'POST']) 142 | @login_required 143 | def application_backup(application_id): 144 | user = current_user 145 | application = applications.get_application(application_id) 146 | 147 | backups = backup.list(user, application) 148 | 149 | form = forms.ApplicationBackupForm() 150 | form.name.choices = [(b,b) for b in backups] 151 | 152 | if form.validate_on_submit(): 153 | if form.backup.data: 154 | backup.backup(user, application) 155 | flask.flash("Backup created successfully.") 156 | return flask.redirect(flask.url_for('application', application_id=application_id)) 157 | 158 | if form.restore.data: 159 | backup_name = form.name.data 160 | backup.restore(user, application, backup_name) 161 | flask.flash("Backup {} restored successfully.".format(backup_name)) 162 | return flask.redirect(flask.url_for('application', application_id=application_id)) 163 | 164 | return flask.render_template('application_backup.html', application=application, 165 | backups=backups, form=form) 166 | -------------------------------------------------------------------------------- /puffin/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/puffin/static/images/favicon.png -------------------------------------------------------------------------------- /puffin/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/puffin/static/images/logo.png -------------------------------------------------------------------------------- /puffin/static/images/new_application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/puffin/static/images/new_application.png -------------------------------------------------------------------------------- /puffin/static/images/puffin-scaled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/puffin/static/images/puffin-scaled.png -------------------------------------------------------------------------------- /puffin/static/images/puffin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/puffin/static/images/puffin.png -------------------------------------------------------------------------------- /puffin/static/scripts/puffin.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/puffin/static/scripts/puffin.js -------------------------------------------------------------------------------- /puffin/static/styles/puffin.css: -------------------------------------------------------------------------------- 1 | /* Move down content because we have a fixed navbar that is 50px tall */ 2 | html { 3 | position: relative; 4 | min-height: 100%; 5 | } 6 | 7 | body { 8 | padding-top: 50px; 9 | padding-bottom: 20px; 10 | 11 | /* Margin bottom by footer height */ 12 | margin-bottom: 60px; 13 | 14 | /* Always show scroll bar for consistent width */ 15 | overflow-y: scroll; 16 | } 17 | 18 | .navbar-brand a { 19 | text-decoration: none; 20 | } 21 | 22 | .footer { 23 | position: absolute; 24 | bottom: 0; 25 | width: 100%; 26 | 27 | /* Set the fixed height of the footer here */ 28 | height: 60px; 29 | } 30 | 31 | /* Application list */ 32 | .apps { 33 | margin-top: 30px; 34 | } 35 | 36 | .app.thumbnail a { 37 | color: black; 38 | } 39 | 40 | /* Application thumbnails */ 41 | .app.thumbnail.new { 42 | opacity: 0.3; 43 | border: 2px dashed #999; 44 | } 45 | 46 | .screenshots { 47 | margin-top: 20px; 48 | } 49 | 50 | .blinking { 51 | animation: blinker 1.5s cubic-bezier(.5, 0, 1, 1) infinite alternate; 52 | } 53 | @keyframes blinker { 54 | from { opacity: 1; } 55 | to { opacity: 0.5; } 56 | } 57 | 58 | .version { 59 | font-size: 16px; 60 | color: #9d9d9d; 61 | } 62 | 63 | .about div { 64 | padding-top: 20px; 65 | padding-bottom: 20px; 66 | } 67 | 68 | .about .intro { 69 | background-color: #ffffff; 70 | } 71 | 72 | .about .catalog { 73 | background-color: #dadbdb; 74 | } 75 | 76 | .about .hosting { 77 | background-color: #f5d4cf; 78 | } 79 | 80 | .about .start { 81 | background-color: #feedd5; 82 | } 83 | 84 | .about .status { 85 | background-color: #ffffff; 86 | } 87 | 88 | .application div.meta { 89 | margin-top: 20px; 90 | } 91 | 92 | .application div.actions { 93 | margin-top: 20px; 94 | } 95 | 96 | .application img { 97 | margin: 5px; 98 | } 99 | 100 | .btn { 101 | margin-right: 6px; 102 | } 103 | -------------------------------------------------------------------------------- /puffin/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block title %}Puffin | About{% endblock %} 5 | 6 | 7 | {% block content %} 8 | 9 |
10 | 11 |
12 |
13 |

Introduction

14 |

15 | Puffin lets you simply run web applications, such as blogs, forums, chat rooms, file sharing, 16 | news readers and many others, which will always be reachable from any location or device. 17 |

18 |
19 |
20 | 21 |
22 |
23 |

Getting Started

24 |

25 | First you need to login 26 | {%- if config.SECURITY_REGISTERABLE %} 27 | or register 28 | {%- endif %}. 29 | Once done, you can run any application from the catalog with a single click. 30 | After that, the application will be available under your subdomain. 31 | If you have any questions, feel free to create an issue on GitHub. 32 |

33 |
34 |
35 | 36 |
37 |
38 |

Application Catalog

39 |

40 | Puffin contains an ever growing list of web applications. 41 | Adding new ones is easy thanks to the popular 42 | Docker containerization technology - 43 | learn more about how to add your own application. 44 | Applications are regularly updated. 45 | To assure than new version doesn't corrupt the data, an automatic backup is performed. 46 |

47 |
48 |
49 | 50 |
51 |
52 |

Hosting

53 |

54 | You can run applications on a managed platform - the service is free and no setup is required. 55 | Alternatively, you can choose to launch Puffin on your own server 56 | and let your friends run their applications on it. 57 |

58 |
59 |
60 | 61 |
62 |
63 |

Status

64 |

65 | Keep in mind that this is a demo platform and all data will be regularly deleted. 66 | Puffin software is still in the alpha development stage, so it may be unstable 67 | - you can track the project's progress on the Blog 68 | and in the Code. 69 |

70 |
71 |
72 | 73 |
74 | 75 | {{ self.footer() }} 76 | 77 | {% endblock %} 78 | 79 | -------------------------------------------------------------------------------- /puffin/templates/application.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | 5 | {% block title %}Puffin | {{ application.name }}{% endblock %} 6 | 7 | 8 | {% block page %} 9 | 10 |
11 | 12 | 15 | 16 |
17 | 18 |
19 | {{ application.description | markdown | bclean | blinkify | safe }} 20 | 21 |
22 | 23 | Version: 24 | {% if application_image_version: %} 25 | {{ application_image_version }} 26 | {% else %} 27 | Unknown 28 | {% endif %} 29 | 30 |   31 | {% if application_status == ApplicationStatus.CREATED %} 32 | 33 | Running Version: 34 | {% if application_version: %} 35 | {{ application_version }} 36 | {% else %} 37 | Unknown 38 | {% endif %} 39 | 40 | {% endif %} 41 |
42 | 43 |
44 | {% if current_user.is_authenticated() %} 45 |
46 | {{ form.hidden_tag() }} 47 | {{ wtf.form_errors(form, hiddens="only") }} 48 | {% if application_status == ApplicationStatus.CREATED %} 49 | Open 50 | 51 | 52 | 53 | {% elif application_status == ApplicationStatus.DELETED %} 54 | 55 | Settings 56 | Backup 57 | {% elif application_status == ApplicationStatus.UPDATING %} 58 | 59 | 69 | {% else %} 70 | 71 | {% endif %} 72 |
73 | {% else %} 74 | Log In or 75 | {% if config.SECURITY_REGISTERABLE %} 76 | Register 77 | {% endif %} 78 | to run application. 79 | {% endif %} 80 |
81 | 82 |
83 | 84 |
85 | 86 | {% endblock %} 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /puffin/templates/application_backup.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | 5 | {% block title %}Puffin | {{ application.name }} backup{% endblock %} 6 | 7 | 8 | {% block page %} 9 | 10 |
11 | 12 | 15 |
16 | 17 |

« Back

18 | 19 |
20 | 21 | {{ form.hidden_tag() }} 22 | 23 |

24 | Create new backup   25 | 26 |

27 | 28 |

29 | Restore existing backup   30 | 35 | 36 |

37 | 38 |
39 | 40 |
41 | 42 |
43 | 44 | {% endblock %} 45 | 46 | 47 | -------------------------------------------------------------------------------- /puffin/templates/application_settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | 5 | {% block title %}Puffin | {{ application.name }} settings{% endblock %} 6 | 7 | 8 | {% block page %} 9 | 10 |
11 | 12 | 15 |
16 | 17 |

« Back

18 | 19 |
20 | {{ form.hidden_tag() }} 21 | {{ wtf.form_errors(form, hiddens="only") }} 22 | 23 | {{ wtf.form_field(form.domain) }} 24 | 25 | {{ wtf.form_field(form.https) }} 26 | 27 | {{ wtf.form_field(form.update, button_map={'update': 'success'}) }} 28 | 29 |
30 | 31 |
32 | 33 |
34 | 35 | {% endblock %} 36 | 37 | -------------------------------------------------------------------------------- /puffin/templates/applications.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'macros.html' import application_thumbnail %} 3 | 4 | 5 | {% block title %}Puffin | My Applications{% endblock %} 6 | 7 | 8 | {% block page %} 9 | 10 | 13 | 14 |
15 | {% for application in applications %} 16 | {{ application_thumbnail(application) }} 17 | {% else %} 18 | No running applications. Go to the catalog to run some. 19 | {% endfor %} 20 |
21 | 22 | {% endblock %} 23 | 24 | -------------------------------------------------------------------------------- /puffin/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "bootstrap/base.html" %} 2 | 3 | 4 | {% block title %}Puffin{% endblock %} 5 | 6 | 7 | {% block head %} 8 | {{ super() }} 9 | 10 | 11 | {% endblock %} 12 | 13 | 14 | {% block styles %} 15 | {{ super() }} 16 | 17 | {% endblock %} 18 | 19 | 20 | {% block scripts %} 21 | {{ super() }} 22 | 23 | {{ analytics }} 24 | {% endblock %} 25 | 26 | 27 | {% block navbar %} 28 | 29 | 66 | 67 | {% endblock %} 68 | 69 | 70 | {% block content %} 71 | 72 | {% if self.hero() %} 73 |
74 |
75 | {% block hero %}{% endblock %} 76 |
77 |
78 | {% endif %} 79 | 80 |
81 | {% for message in get_flashed_messages() %} 82 |
83 | 84 | {{ message }} 85 |
86 | {% endfor %} 87 | 88 | {% block page %}{% endblock %} 89 |
90 | 91 | {{ self.footer() }} 92 | 93 | {% endblock %} 94 | 95 | 96 | {% block footer %} 97 | 98 |
99 |
100 |

101 | Created by loomchild et al. 102 | Licensed under AGPL. 103 | Source code hosted on Github. 104 | Version {{ "-".join(version) }}. 105 | Stats: {{ stats.users }} users, {{ stats.apps }} apps, {{ stats.containers }} containers. 106 |

107 |
108 |
109 | 110 | {% endblock %} 111 | 112 | -------------------------------------------------------------------------------- /puffin/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'macros.html' import application_thumbnail %} 3 | 4 | 5 | {% block title %}Puffin{% endblock %} 6 | 7 | 8 | {% block head %} 9 | {{ super() }} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% endblock %} 20 | 21 | 22 | {% block hero %} 23 | 24 |
25 |

Welcome to Puffin!

26 |

27 | Puffin is a webapp store. It's the easiest way to run software on the net. 28 | Learn more » 29 |

30 | 31 | {% endblock %} 32 | 33 | 34 | {% block page %} 35 | 36 | {{ self.applications() }} 37 | 38 | {% endblock %} 39 | 40 | 41 | {% block applications %} 42 | 43 |
44 | {% for application in applications %} 45 | {{ application_thumbnail(application) }} 46 | {% endfor %} 47 | {{ application_thumbnail() }} 48 |
49 | 50 | {% endblock %} 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /puffin/templates/macros.html: -------------------------------------------------------------------------------- 1 | {% import "bootstrap/wtf.html" as wtf %} 2 | 3 | {% macro application_thumbnail(application) %} 4 |
5 |
6 |
7 |
8 | {% if application %} 9 | {{ application.name }} 10 | {% else %} 11 | new application 12 | {% endif %} 13 |
14 |
15 |
16 | {% if application %} 17 |

{{ application.name }}

18 |

{{ application.subtitle }}

19 | {% else %} 20 |

 

21 |

add new application to the catalog

22 | {% endif %} 23 |
24 |
25 |
26 |
27 |
28 | {% endmacro %} 29 | -------------------------------------------------------------------------------- /puffin/templates/mail/new_user.txt: -------------------------------------------------------------------------------- 1 | A new user has succesfully registered to Puffin: 2 | 3 | Name: {{ user.name }} 4 | Login: {{ user.login }} 5 | Email: {{ user.email }} 6 | 7 | -------------------------------------------------------------------------------- /puffin/templates/mail/test.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/puffin/templates/mail/test.txt -------------------------------------------------------------------------------- /puffin/templates/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | 5 | {% block title %}Puffin | {{ user.login }}{% endblock %} 6 | 7 | 8 | {% block page %} 9 | 10 |
11 | 12 | 15 |
16 | 17 |
18 | {{ form.hidden_tag() }} 19 | {{ wtf.form_errors(form, hiddens="only") }} 20 | 21 | {{ wtf.form_field(form.login, disabled=True) }} 22 | {{ wtf.form_field(form.email, disabled=True) }} 23 | {{ wtf.form_field(form.name) }} 24 | 25 |
26 | {{ wtf.form_field(form.submit) }} 27 |
28 |
29 | 30 |
31 | Change Password 32 |
33 | 34 |
35 | 36 |
37 | 38 | {% endblock %} 39 | 40 | -------------------------------------------------------------------------------- /puffin/templates/security/change_password.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}Puffin | Change Password{% endblock %} 5 | 6 | {% block page %} 7 |
8 | 11 |
12 | {% set form = change_password_form %} 13 |
14 | {{ form.hidden_tag() }} 15 | {{ wtf.form_errors(form, hiddens="only") }} 16 | 17 | {{ wtf.form_field(form.password) }} 18 | {{ wtf.form_field(form.new_password) }} 19 | {{ wtf.form_field(form.new_password_confirm) }} 20 | 21 | {{ wtf.form_field(form.submit) }} 22 |
23 |
24 |
25 | {% endblock %} 26 | 27 | -------------------------------------------------------------------------------- /puffin/templates/security/login_user.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}Puffin | Log In{% endblock %} 5 | 6 | {% block page %} 7 |
8 | 11 |
12 | {% set form = login_user_form %} 13 |
14 | {{ form.hidden_tag() }} 15 | {{ wtf.form_errors(form, hiddens="only") }} 16 | 17 | {{ wtf.form_field(form.email) }} 18 | {{ wtf.form_field(form.password) }} 19 | {{ wtf.form_field(form.remember) }} 20 | {{ wtf.form_field(form.submit) }} 21 |
22 |
23 |
24 | {% endblock %} 25 | 26 | -------------------------------------------------------------------------------- /puffin/templates/security/register_user.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}Puffin | Register{% endblock %} 5 | 6 | {% block page %} 7 |
8 | 11 |
12 | {% set form = register_user_form %} 13 |
14 | {{ form.hidden_tag() }} 15 | {{ wtf.form_errors(form, hiddens="only") }} 16 | 17 | {{ wtf.form_field(form.login) }} 18 | {{ wtf.form_field(form.email) }} 19 | {{ wtf.form_field(form.name) }} 20 | {{ wtf.form_field(form.password) }} 21 | {{ wtf.form_field(form.password_confirm) }} 22 | 23 | {{ wtf.form_field(form.submit) }} 24 |
25 |
26 |
27 | {% endblock %} 28 | 29 | -------------------------------------------------------------------------------- /puffin/templates/security/send_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}Puffin | Resend Confirmation Instructions{% endblock %} 5 | 6 | {% block page %} 7 |
8 | 11 |
12 | {% set form = send_confirmation_form %} 13 | {{ wtf.quick_form(form) }} 14 |
15 |
16 | {% endblock %} 17 | 18 | -------------------------------------------------------------------------------- /puffin/util/__init__.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import threading 3 | import os 4 | from os import path 5 | 6 | 7 | HOME=path.abspath(path.join(path.dirname(path.abspath(__file__)), '..', '..')) 8 | 9 | def to_uuid(string): 10 | "Convert given string to UUID, return None if not possible" 11 | try: 12 | return uuid.UUID(string) 13 | except ValueError: 14 | return None 15 | 16 | def truncate(string, length, suffix="..."): 17 | "Truncate string to a given length, preserving last word" 18 | if len(string) <= length: 19 | return string 20 | else: 21 | return string[:length].rsplit(' ', 1)[0] + suffix 22 | 23 | def deproxy(o): 24 | "Returns a real current object if an object is a Flask proxy" 25 | if getattr(o, "_get_current_object", None): 26 | o = o._get_current_object() 27 | return o 28 | 29 | class SafeSet(): 30 | 31 | def __init__(self): 32 | self.data = set() 33 | self.lock = threading.Lock() 34 | 35 | def add(self, element): 36 | with self.lock: 37 | if element in self.data: 38 | raise Exception("Element already exists") 39 | self.data.add(element) 40 | 41 | def remove(self, element): 42 | with self.lock: 43 | self.data.remove(element) 44 | 45 | def contains(self, element): 46 | with self.lock: 47 | return element in self.data 48 | 49 | def safe_get(dct, *keys): 50 | for key in keys: 51 | try: 52 | dct = dct[key] 53 | except KeyError: 54 | return None 55 | return dct 56 | 57 | def env_dict(env_list): 58 | return dict(filter(lambda e: len(e) == 2, map(lambda e: e.split("=", 1), env_list))) 59 | 60 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = *.egg .git env templates 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask >= 0.10, < 0.11 2 | Flask-SQLAlchemy >= 2.0, < 3.0 3 | Flask-Migrate >= 1.6, < 1.7 4 | Flask-Security == 1.7.4 5 | Flask-Login == 0.2.11 6 | Flask-WTF >= 0.12, < 0.13 7 | Flask-Bootstrap >= 3.3, < 3.4 8 | Flask-Script >= 2.0, < 2.1 9 | Flask-Mail >= 0.9, < 0.10 10 | Flask-Markdown >= 0.3, < 0.4 11 | Flask-Bleach >= 0.0.2, < 0.1 12 | Flask-Analytics >= 0.5, < 0.6 13 | waitress >= 0.8, < 0.9 14 | psycopg2 >= 2.6, < 2.7 15 | bcrypt >= 3.1, < 3.2 16 | cachetools >= 1.1, < 1.2 17 | PyYAML >= 3.11, < 3.12 18 | docker >= 2.2.1, < 2.3 19 | docker-compose >= 1.12, < 1.13 20 | requests >= 2.7, < 2.8 21 | dumb-init >= 1.0, < 1.1 22 | bleach >= 1.5, < 2.0 23 | 24 | pytest >= 2.8, < 3.0 25 | pytest-cov >= 2.2, < 3.0 26 | ipython >= 6.0, < 7.0 27 | reload >= 0.9, < 1.0 28 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/test/__init__.py -------------------------------------------------------------------------------- /test/unit_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puffinrocks/puffin/9be55d6d4d8b058f6e87895669f2c8c59d4db128/test/unit_test/__init__.py -------------------------------------------------------------------------------- /test/unit_test/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_dummy(): 2 | assert True 3 | --------------------------------------------------------------------------------