├── .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 | [](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 | [](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 | [](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 |
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 |