├── MANIFEST.in ├── requirements.txt ├── docs ├── .statigen.toml ├── config.md ├── index.md └── install.md ├── flux ├── static │ └── flux │ │ ├── img │ │ ├── bare.png │ │ ├── gogs.png │ │ ├── logo.png │ │ ├── gitea.png │ │ ├── github.png │ │ ├── gitlab.png │ │ ├── bitbucket.png │ │ ├── favicon.ico │ │ ├── gitbucket.png │ │ └── logo.svg │ │ ├── fonts │ │ ├── fontello.eot │ │ ├── fontello.ttf │ │ ├── fontello.woff │ │ ├── fontello.woff2 │ │ ├── open-sans-v15-latin-600.eot │ │ ├── open-sans-v15-latin-600.ttf │ │ ├── open-sans-v15-latin-600.woff │ │ ├── open-sans-v15-latin-600.woff2 │ │ ├── open-sans-v15-latin-regular.eot │ │ ├── open-sans-v15-latin-regular.ttf │ │ ├── open-sans-v15-latin-regular.woff │ │ ├── open-sans-v15-latin-regular.woff2 │ │ └── fontello.svg │ │ ├── css │ │ ├── open-sans.css │ │ └── style.css │ │ └── js │ │ ├── nunjs.min.js │ │ └── script.js ├── enums.py ├── templates │ ├── 403.html │ ├── 404.html │ ├── 500.html │ ├── login.html │ ├── overrides_edit.html │ ├── users.html │ ├── overrides_upload.html │ ├── macros.html │ ├── dashboard.html │ ├── repositories.html │ ├── edit_user.html │ ├── base.html │ ├── view_repo.html │ ├── integration.html │ ├── edit_repo.html │ ├── view_build.html │ └── overrides_list.html ├── __init__.py ├── config.py ├── main.py ├── flux-fontello.config.json ├── file_utils.py ├── models.py ├── build.py ├── utils.py └── views.py ├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE.txt ├── setup.py ├── contrib └── geany-project.geany ├── README.md └── flux_config.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | include requirements.txt 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=0.10.1 2 | pony>=0.7.3 3 | pyOpenSSL>=0.15.1 4 | cryptography>=2.0 5 | -------------------------------------------------------------------------------- /docs/.statigen.toml: -------------------------------------------------------------------------------- 1 | [statigen] 2 | contentDirectory = "." 3 | 4 | [site] 5 | title = "Flux CI" 6 | -------------------------------------------------------------------------------- /flux/static/flux/img/bare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/bare.png -------------------------------------------------------------------------------- /flux/static/flux/img/gogs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/gogs.png -------------------------------------------------------------------------------- /flux/static/flux/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/logo.png -------------------------------------------------------------------------------- /flux/static/flux/img/gitea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/gitea.png -------------------------------------------------------------------------------- /flux/static/flux/img/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/github.png -------------------------------------------------------------------------------- /flux/static/flux/img/gitlab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/gitlab.png -------------------------------------------------------------------------------- /flux/static/flux/img/bitbucket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/bitbucket.png -------------------------------------------------------------------------------- /flux/static/flux/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/favicon.ico -------------------------------------------------------------------------------- /flux/static/flux/img/gitbucket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/img/gitbucket.png -------------------------------------------------------------------------------- /flux/static/flux/fonts/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/fontello.eot -------------------------------------------------------------------------------- /flux/static/flux/fonts/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/fontello.ttf -------------------------------------------------------------------------------- /flux/static/flux/fonts/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/fontello.woff -------------------------------------------------------------------------------- /flux/static/flux/fonts/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/fontello.woff2 -------------------------------------------------------------------------------- /flux/static/flux/fonts/open-sans-v15-latin-600.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/open-sans-v15-latin-600.eot -------------------------------------------------------------------------------- /flux/static/flux/fonts/open-sans-v15-latin-600.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/open-sans-v15-latin-600.ttf -------------------------------------------------------------------------------- /flux/static/flux/fonts/open-sans-v15-latin-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/open-sans-v15-latin-600.woff -------------------------------------------------------------------------------- /flux/static/flux/fonts/open-sans-v15-latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/open-sans-v15-latin-600.woff2 -------------------------------------------------------------------------------- /flux/static/flux/fonts/open-sans-v15-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/open-sans-v15-latin-regular.eot -------------------------------------------------------------------------------- /flux/static/flux/fonts/open-sans-v15-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/open-sans-v15-latin-regular.ttf -------------------------------------------------------------------------------- /flux/static/flux/fonts/open-sans-v15-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/open-sans-v15-latin-regular.woff -------------------------------------------------------------------------------- /flux/static/flux/fonts/open-sans-v15-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NiklasRosenstein/flux-ci/HEAD/flux/static/flux/fonts/open-sans-v15-latin-regular.woff2 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *.sqlite 3 | *.egg-info 4 | __pycache__/ 5 | dist/ 6 | build/ 7 | builds/ 8 | overrides/ 9 | venv/ 10 | .venv/ 11 | .env/ 12 | Dockerfile 13 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Configuration" 3 | ordering = 2 4 | +++ 5 | 6 | Check out `flux_config.py` for the configuration template and the 7 | parameter documentation. 8 | 9 | *TODO ...* 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *.sqlite 3 | *.egg-info 4 | __pycache__/ 5 | dist/ 6 | build/ 7 | builds/ 8 | overrides/ 9 | customs/ 10 | venv/ 11 | .venv/ 12 | .env/ 13 | Pipfile 14 | Pipfile.lock 15 | *.geany 16 | -------------------------------------------------------------------------------- /flux/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class GitFolderHandling(Enum): 4 | """ 5 | This enum defines way, how is .git folder handled during project build. 6 | """ 7 | DELETE_BEFORE_BUILD = 1 8 | DELETE_AFTER_BUILD = 2 9 | DISABLE_DELETE = 3 -------------------------------------------------------------------------------- /flux/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import build_icon, build_ref, fmtdate %} 3 | {% set page_title = "403 - Forbidden" %} 4 | 5 | {% block body %} 6 |
7 | 8 | 9 | 10 |
You don't have permission to access this page.
11 |
12 | {% endblock body%} -------------------------------------------------------------------------------- /flux/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import build_icon, build_ref, fmtdate %} 3 | {% set page_title = "404 - Not Found" %} 4 | 5 | {% block body %} 6 |
7 | 8 | 9 | 10 |
The requested URL was not found on the server.
11 |
12 | {% endblock body%} -------------------------------------------------------------------------------- /flux/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import build_icon, build_ref, fmtdate %} 3 | {% set page_title = "500 - Internal Server Error" %} 4 | 5 | {% block body %} 6 |
7 | 8 | 9 | 10 |
The server encountered an internal error.
11 |
12 | {% endblock body%} -------------------------------------------------------------------------------- /flux/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import render_error_list %} 3 | {% set page_title = "Login" %} 4 | {% block body %} 5 |
6 | Please sign in to the application 7 | {{ render_error_list(errors) }} 8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 | 16 |
17 |
18 | {% endblock body %} 19 | -------------------------------------------------------------------------------- /flux/templates/overrides_edit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import render_error_list %} 3 | {% set page_title = "Overrides edit" %} 4 | {% block toolbar %} 5 |
  • 6 | 7 | Parent folder 8 | 9 |
  • 10 | {% endblock toolbar %} 11 | {% block body %} 12 | {{ render_error_list(errors) }} 13 |

    /{{ overrides_path }}

    14 |
    15 |
    16 | 17 |
    18 | 19 |
    20 | {% endblock body %} 21 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Home" 3 | ordering = 0 4 | renderTitle = false 5 | +++ 6 | 7 | # Welcome to the Flux documentation! 8 | 9 | Flux is a simple and lightweight continuous integration server that responds 10 | to Webhooks that can be triggered by various Git hosting services. 11 | 12 | Flux should be deployed over an SSL encrpyted proxy pass server and be used 13 | for internal purposes only since it does not provide mechanisms to prevent 14 | bad code execution. 15 | 16 | ## Features 17 | 18 | * Lightweight and easy to deploy 19 | * Operational on Windows, Linux and Mac OS 20 | * Supports BitBucket, GitHub, GitLab, Gogs, Gitea, GitBucket 21 | * Stop & Restart builds 22 | * View and download build logs and artifacts 23 | * User access control 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for Flux-CI. 2 | 3 | FROM library/alpine:3.4 4 | 5 | EXPOSE 4042 6 | 7 | RUN apk update && apk upgrade && \ 8 | apk add --no-cache 'git>2.6.6-r0' && \ 9 | apk add --no-cache bash gcc linux-headers musl-dev && \ 10 | apk add --no-cache openssl-dev libffi-dev python3-dev && \ 11 | apk add --no-cache python3 && \ 12 | apk add --no-cache sqlite 13 | 14 | # Install Python dependencies. 15 | COPY requirements.txt /opt/requirements.txt 16 | RUN pip3 install -r /opt/requirements.txt 17 | RUN rm /opt/requirements.txt 18 | 19 | # Install Flux-CI. 20 | COPY . /opt/flux 21 | RUN pip3 install /opt/flux 22 | RUN rm -r /opt/flux 23 | 24 | # Copy Flux-CI configuration. 25 | RUN mkdir -p /opt/flux 26 | COPY flux_config.py /opt/flux 27 | 28 | ENV PYTHONPATH=/opt/flux 29 | CMD flux-ci --web 30 | -------------------------------------------------------------------------------- /flux/templates/users.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% set page_title = "Users" %} 3 | {% block toolbar %} 4 | {% if user.can_manage %} 5 |
  • 6 | 7 | Add User 8 | 9 |
  • 10 | {% endif %} 11 | {% endblock toolbar %} 12 | 13 | {% block body %} 14 | 15 | {% if user.can_manage %} 16 | {% for auser in users %} 17 | 18 | 19 | 20 | 21 | 22 | 23 | {{ auser.name }} 24 | 25 | 26 | 27 | {% endfor %} 28 | {% endif %} 29 | 30 | {% endblock body %} -------------------------------------------------------------------------------- /flux/templates/overrides_upload.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import render_error_list %} 3 | {% set page_title = "Overrides upload" %} 4 | 5 | {% block toolbar %} 6 |
  • 7 | 8 | Back 9 | 10 |
  • 11 | {% endblock toolbar %} 12 | 13 | {% block body %} 14 | {{ render_error_list(errors) }} 15 |

    /{{ overrides_path }}

    16 |
    17 | 24 | 27 |
    28 | {% endblock body %} -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Niklas Rosenstein 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import setuptools 4 | import sys 5 | 6 | def package_files(directory): 7 | paths = [] 8 | for (path, directories, filenames) in os.walk(directory): 9 | for filename in filenames: 10 | paths.append(os.path.join('..', path, filename)) 11 | return paths 12 | 13 | if sys.version[:3] < '3.4': 14 | raise EnvironmentError('Flux CI is not compatible with Python {}' 15 | .format(sys.version[:3])) 16 | 17 | with open('README.md') as fp: 18 | readme = fp.read() 19 | 20 | with open('requirements.txt') as fp: 21 | requirements = fp.readlines() 22 | 23 | setuptools.setup( 24 | name = 'flux-ci', 25 | version = '1.1.0', 26 | author = 'Niklas Rosenstein', 27 | author_email = 'rosensteinniklas@gmail.com', 28 | description = 'Flux is your own private CI server.', 29 | long_description = readme, 30 | long_description_content_type = 'text/markdown', 31 | license = 'MIT', 32 | url = 'https://github.com/NiklasRosenstein/flux-ci', 33 | install_requires = requirements, 34 | packages = setuptools.find_packages(), 35 | package_data = { 36 | 'flux': package_files('flux/static') + package_files('flux/templates') 37 | }, 38 | entry_points = dict( 39 | console_scripts = ['flux-ci=flux.main:_entry_point'] 40 | ) 41 | ) 42 | -------------------------------------------------------------------------------- /flux/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Niklas Rosenstein 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | __author__ = 'Niklas Rosenstein ' 22 | __version__ = '1.1.0' 23 | 24 | import flask 25 | import os 26 | 27 | app = flask.Flask(__name__) 28 | app.template_folder = os.path.join(os.path.dirname(__file__), 'templates') 29 | app.static_folder = os.path.join(os.path.dirname(__file__), 'static') 30 | -------------------------------------------------------------------------------- /flux/templates/macros.html: -------------------------------------------------------------------------------- 1 | {% macro build_icon(build) %} 2 | {% if build.status == build.Status_Queued %} 3 | 4 | {% elif build.status == build.Status_Building %} 5 | 6 | {% elif build.status == build.Status_Error %} 7 | 8 | {% elif build.status == build.Status_Success %} 9 | 10 | {% elif build.status == build.Status_Stopped %} 11 | 12 | {% else %} 13 | 14 | {% endif %} 15 | {%- endmacro %} 16 | 17 | {% macro build_ref(build) %} 18 | {% if build and build.ref and build.ref.startswith('refs/heads/') %} 19 | {{ build.ref.replace('refs/heads/', '', 1) }} 20 | {% elif build and build.ref and build.ref.startswith('refs/tags/')%} 21 | {{ build.ref.replace('refs/tags/', '', 1) }} 22 | {% else %} 23 | {{ build.ref }} 24 | {% endif %} 25 | {% endmacro %} 26 | 27 | {% macro fmtdate(date) %} 28 | {% if date %} 29 | {{ date.strftime("%Y/%m/%d %H:%M:%S") }} 30 | {%- endif %} 31 | {%- endmacro %} 32 | 33 | {% macro render_error_list(errors) %} 34 | {% if errors %} 35 |
    36 | 37 | 38 | 39 | 40 | 41 | 42 | {% for msg in errors %} 43 |
    {{ msg }}
    44 | {% endfor %} 45 |
    46 | {% endif %} 47 | {% endmacro %} 48 | -------------------------------------------------------------------------------- /flux/static/flux/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 15 | 16 | -------------------------------------------------------------------------------- /flux/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import build_icon, build_ref, fmtdate %} 3 | {% set page_title = "Dashboard" %} 4 | {% block body %} 5 | {% if builds %} 6 | {% for build in builds %} 7 | 8 | 9 | 10 | 11 | {{ build_icon(build) }} 12 | 13 | 14 | #{{ build.num }} 15 | 16 | 17 | 18 | {{ build.repo.name }} 19 | 20 | 21 | {{ build_ref(build) }} 22 | 23 | 24 | 25 | 26 | 27 | 28 | {{ build.commit_sha[0:8]}} 29 | 30 | 31 | {{ fmtdate(build.date_queued) }} 32 | 33 | 34 | 35 | {{ flux.utils.get_date_diff(build.date_finished, build.date_started) }} 36 | 37 | 38 | 39 | 40 | {% endfor %} 41 | {% else %} 42 |
    43 | 44 | 45 | 46 |
    No builds
    47 |
    48 | {% endif %} 49 | {% endblock body %} 50 | -------------------------------------------------------------------------------- /contrib/geany-project.geany: -------------------------------------------------------------------------------- 1 | [editor] 2 | line_wrapping=false 3 | line_break_column=100 4 | auto_continue_multiline=true 5 | 6 | [file_prefs] 7 | final_new_line=true 8 | ensure_convert_new_lines=false 9 | strip_trailing_spaces=false 10 | replace_tabs=false 11 | 12 | [indentation] 13 | indent_width=2 14 | indent_type=0 15 | indent_hard_tab_width=8 16 | detect_indent=false 17 | detect_indent_width=false 18 | indent_mode=0 19 | 20 | [project] 21 | name=Flux CI 22 | base_path=.. 23 | description=\sFlux is your own private & lightweight CI server - https://github.com/NiklasRosenstein/flux-ci 24 | file_patterns= 25 | 26 | [long line marker] 27 | long_line_behaviour=2 28 | long_line_column=100 29 | 30 | [files] 31 | current_page=-1 32 | 33 | [VTE] 34 | last_dir=. 35 | 36 | [prjorg] 37 | source_patterns=*.py;*.html; 38 | header_patterns=*.json;*.in; 39 | ignored_dirs_patterns=.*;CVS;flux_ci.egg-info;dist;build;contrib;docs;builds; 40 | ignored_file_patterns=*.o;*.obj;*.a;*.lib;*.so;*.dll;*.lo;*.la;*.class;*.jar;*.pyc;*.mo;*.gmo;.gitignore;.dockerignore;LICENSE.txt;*.lock;contrib;Dockerfile;MANIFEST.in;Pipfile*;requirements.txt;setup.py;db.sqlite; 41 | generate_tag_prefs=1 42 | external_dirs= 43 | 44 | [build-menu] 45 | filetypes=Python;CSS;Javascript;HTML; 46 | EX_00_LB=_Execute 47 | EX_00_CM=pipenv run flux-ci --web 48 | EX_00_WD=%p 49 | EX_01_LB=Browser 50 | EX_01_CM=xdg-open "localhost:4040" 51 | EX_01_WD=%p 52 | PythonFT_00_LB=Setup env 53 | PythonFT_00_CM=PIPENV_VENV_IN_PROJECT=1 pipenv install -e . 54 | PythonFT_00_WD=%p 55 | CSSFT_00_LB=Setup env 56 | CSSFT_00_CM=PIPENV_VENV_IN_PROJECT=1 pipenv install -e . 57 | CSSFT_00_WD=%p 58 | JavascriptFT_00_LB=Setup env 59 | JavascriptFT_00_CM=PIPENV_VENV_IN_PROJECT=1 pipenv install -e . 60 | JavascriptFT_00_WD= 61 | HTMLFT_00_LB=Setup env 62 | HTMLFT_00_CM=PIPENV_VENV_IN_PROJECT=1 pipenv install -e . 63 | HTMLFT_00_WD=%p 64 | HTMLFT_01_LB=tidy 65 | HTMLFT_01_CM=tidy %f >/dev/null 66 | HTMLFT_01_WD= 67 | -------------------------------------------------------------------------------- /flux/templates/repositories.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import build_icon, build_ref, fmtdate %} 3 | {% set page_title = "Repositories" %} 4 | {% block toolbar %} 5 | {% if user.can_manage %} 6 |
  • 7 | 8 | Add Repository 9 | 10 |
  • 11 | {% endif %} 12 | {% endblock toolbar %} 13 | 14 | {% block body %} 15 | {% if repositories %} 16 | {% for repo in repositories %} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{ repo.name }} 26 | 27 | 28 | {% if user.can_manage %} 29 | {{ repo.clone_url }} 30 | {% else %} 31 |   32 | {% endif %} 33 | 34 | 35 | 36 | {% if repo.builds %} 37 | {% set build = repo.most_recent_build() %} 38 | 39 | 40 | 41 | #{{ build.num }} 42 | 43 | 44 | {{ build_ref(build) }} 45 | 46 | 47 | 48 | {{ build_icon(build) }} 49 | 50 | 51 | {% endif %} 52 | 53 | 54 | {% endfor %} 55 | {% else %} 56 |
    57 | 58 | 59 | 60 |
    No repositories
    61 |
    62 | {% endif %} 63 | {% endblock body %} -------------------------------------------------------------------------------- /flux/static/flux/css/open-sans.css: -------------------------------------------------------------------------------- 1 | /* open-sans-300 - latin */ 2 | @font-face { 3 | font-family: 'Open Sans'; 4 | font-style: normal; 5 | font-weight: 300; 6 | src: url('../fonts/open-sans-v15-latin-300.eot'); /* IE9 Compat Modes */ 7 | src: local('Open Sans Light'), local('OpenSans-Light'), 8 | url('../fonts/open-sans-v15-latin-300.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ 9 | url('../fonts/open-sans-v15-latin-300.woff2') format('woff2'), /* Super Modern Browsers */ 10 | url('../fonts/open-sans-v15-latin-300.woff') format('woff'), /* Modern Browsers */ 11 | url('../fonts/open-sans-v15-latin-300.ttf') format('truetype'), /* Safari, Android, iOS */ 12 | url('../fonts/open-sans-v15-latin-300.svg#OpenSans') format('svg'); /* Legacy iOS */ 13 | } 14 | /* open-sans-regular - latin */ 15 | @font-face { 16 | font-family: 'Open Sans'; 17 | font-style: normal; 18 | font-weight: 400; 19 | src: url('../fonts/open-sans-v15-latin-regular.eot'); /* IE9 Compat Modes */ 20 | src: local('Open Sans Regular'), local('OpenSans-Regular'), 21 | url('../fonts/open-sans-v15-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ 22 | url('../fonts/open-sans-v15-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ 23 | url('../fonts/open-sans-v15-latin-regular.woff') format('woff'), /* Modern Browsers */ 24 | url('../fonts/open-sans-v15-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ 25 | url('../fonts/open-sans-v15-latin-regular.svg#OpenSans') format('svg'); /* Legacy iOS */ 26 | } 27 | /* open-sans-600 - latin */ 28 | @font-face { 29 | font-family: 'Open Sans'; 30 | font-style: normal; 31 | font-weight: 600; 32 | src: url('../fonts/open-sans-v15-latin-600.eot'); /* IE9 Compat Modes */ 33 | src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'), 34 | url('../fonts/open-sans-v15-latin-600.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ 35 | url('../fonts/open-sans-v15-latin-600.woff2') format('woff2'), /* Super Modern Browsers */ 36 | url('../fonts/open-sans-v15-latin-600.woff') format('woff'), /* Modern Browsers */ 37 | url('../fonts/open-sans-v15-latin-600.ttf') format('truetype'), /* Safari, Android, iOS */ 38 | url('../fonts/open-sans-v15-latin-600.svg#OpenSans') format('svg'); /* Legacy iOS */ 39 | } -------------------------------------------------------------------------------- /flux/config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Niklas Rosenstein 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | """ 22 | Loads the Python configuration file for Flux CI. 23 | """ 24 | 25 | import os 26 | import sys 27 | 28 | loaded = False 29 | 30 | 31 | def load(filename=None): 32 | global loaded 33 | if filename is None: 34 | filename = os.getenv('FLUX_CONFIG', 'flux_config.py') 35 | filename = os.path.expanduser(filename) 36 | if not os.path.isabs(filename): 37 | for path in [os.getenv('FLUX_ROOT'), '.', 'data']: 38 | if not path: continue 39 | if os.path.isfile(os.path.join(path, filename)): 40 | filename = os.path.join(path, filename) 41 | break 42 | filename = os.path.normpath(os.path.abspath(filename)) 43 | with open(filename) as fp: 44 | scope = {'__file__': filename} 45 | exec(compile(fp.read(), filename, 'exec'), scope) 46 | del scope['__file__'] 47 | globals().update(scope) 48 | loaded = True 49 | 50 | 51 | def prepend_path(path, envvar='PATH'): 52 | ''' Prepend *path* to the ``PATH`` environment variable. ''' 53 | 54 | path = os.path.normpath(os.path.abspath(os.path.expanduser(path))) 55 | os.environ[envvar] = path + os.pathsep + os.environ[envvar] 56 | return os.environ[envvar] 57 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Installation" 3 | ordering = 1 4 | +++ 5 | 6 | ## Requirements 7 | 8 | * Python 3 9 | * Git 2.3 (for `GIT_SSH_COMMAND`) 10 | * [Flask](http://flask.pocoo.org/) 11 | * [PonyORM](https://ponyorm.com/) 12 | 13 | ## Manual Installation 14 | 15 | $ git clone https://github.com/NiklasRosenstein/flux.git -b stable && cd stable 16 | $ virtualenv .venv 17 | $ .venv/bin/pip install -r requirements.txt 18 | # Update the secret key and root account credentials in flux_config.py 19 | $ $(EDITOR) flux_config.py 20 | $ .venv/bin/python flux_run.py 21 | 22 | Visit http://localhost:4042. Also check out the [Configuration](../config) page. 23 | 24 | Depending on the database you want to use, you may need to install additional 25 | modules into the virtual environment, like `psycopg2` for PostgreSQL. The 26 | default database uses an SQLite database file in the current working directory. 27 | 28 | For security reasons, you should place the Flux CI server behind an SSL 29 | encrypted proxy pass server. This is an example configuration for nginx: 30 | 31 | ```nginx 32 | server { 33 | listen 80; 34 | listen 443; 35 | ssl on; 36 | server_name flux.example.com; 37 | 38 | if ($scheme = http) { 39 | return 301 https://$host$request_uri; 40 | } 41 | 42 | location / { 43 | proxy_pass http://0.0.0.0:4042$request_uri; 44 | proxy_set_header Host $host; 45 | } 46 | } 47 | ``` 48 | 49 | ## Docker Setup 50 | 51 | ### Building the Docker Image 52 | 53 | $ docker build -t flux . 54 | 55 | ### Running the container 56 | 57 | Make sure that the `flux_config.py` exists in the `data/` directory. 58 | 59 | > *Important note for Windows*: Mounting volumes in Docker on Windows is 60 | > a bit different and using a local path like `./data` creates a new volume 61 | > instead. 62 | 63 | $ docker run --rm -it \ 64 | -e FLUX_HOST=0.0.0.0 \ 65 | -e FLUX_SERVER_NAME=localhost:4042 \ 66 | -e FLUX_ROOT=/opt/flux \ 67 | -p 4042:4042 \ 68 | -v ./data:/opt/flux \ 69 | flux 70 | 71 | ## Manage the server 72 | 73 | To run Flux on a specific user or simply for using daemon manager, I recommend 74 | using the [nocrux][] daemon manager. 75 | 76 | ``` 77 | $ pip install --user nocrux 78 | $ cat nocrux_config.py 79 | register_daemon( 80 | name = 'flux', 81 | prog = '/home/flux/flux/.env/bin/python', 82 | args = ['flux_run.py'], 83 | cwd = '/home/flux/flux', 84 | user = 'flux', 85 | group = 'flux' 86 | ) 87 | $ nocrux flux start 88 | [nocrux]: (flux) starting "/home/flux/flux/.env/bin/python flux_run.py" 89 | [nocrux]: (flux) started. (pid: 30747) 90 | ``` 91 | 92 | [nocrux]: https://github.com/NiklasRosenstein/nocrux 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flux-CI 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | [![Join the chat at https://gitter.im/flux-ci/Lobby](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/flux-ci/Lobby) 5 | 6 | Flux is a simple and lightweight continuous integration server that responds 7 | to Webhooks that can be triggered by various Git hosting services. 8 | 9 | Flux should be deployed over an SSL encrypted proxy pass server and be used 10 | for internal purposes only since it does not provide mechanisms to prevent 11 | bad code execution. 12 | 13 | [View the full documentation ▸](https://niklasrosenstein.github.io/flux-ci) 14 | 15 | ## Development 16 | We recommend using a Python virtualenv to install Flux CI and its dependencies into. 17 | Rerun the last command to restart Flux CI with latest local changes. 18 | 19 | 20 | 21 | 44 |
    virtualenvPipenvExecute main directly
    22 | 23 | ``` 24 | virtualenv .venv 25 | source .venv/bin/activate 26 | pip install -e . 27 | flux-ci --web 28 | ``` 29 | 30 | 31 | 32 | ``` 33 | PIPENV_VENV_IN_PROJECT=1 pipenv install -e . 34 | pipenv run flux-ci --web 35 | ``` 36 | 37 | 38 | 39 | ``` 40 | python -m flux.main 41 | ``` 42 | 43 |
    45 | 46 | ## Screenshots 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |

    Login

    Dashboard

    Repositories

    Repository Overview

    Build overview

    Users

    User Settings

    Integration

    Confirmation

    Logo

    64 | 65 | --- 66 | 67 |

    Copyright © 2018 Niklas Rosenstein

    68 | -------------------------------------------------------------------------------- /flux/templates/edit_user.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import render_error_list %} 3 | {% set is_new = (cuser == None) %} 4 | {% set is_myself = not is_new and user and cuser.id == user.id %} 5 | {% set page_title = ("User Settings" if not is_new else "Add User")|safe %} 6 | 7 | {% block toolbar %} 8 | {% if user.can_manage %} 9 |
  • 10 | 11 | Users 12 | 13 |
  • 14 | {% endif %} 15 | {% endblock toolbar %} 16 | 17 | {% block body %} 18 | {{ render_error_list(errors) }} 19 |
    20 |
    21 | 22 | 27 |
    28 |
    29 | 30 | 35 |
    36 |
    37 | {% macro checked_with(cond) %}{{ "checked"|safe if cond else "" }}{% endmacro %} 38 | {% set checkbox_flags = 'disabled="disabled"'|safe if not user.can_manage else '' %} 39 | 40 | 44 | 48 | 52 |
    53 | 54 | 57 | {% if not is_new and not is_myself %} 58 | 61 | Delete User 62 | 63 | {% endif %} 64 |
    65 | 66 | {% if not is_new and (cuser.id == request.user.id or request.user.can_manage) %} 67 |

    Login Sessions

    68 | {% for token in cuser.login_tokens.order_by('lambda x: desc(x.created)') %} 69 |
    70 | 71 | 72 |
    73 | {% else %} 74 |

    No login session.

    75 | {% endfor %} 76 | {% endif %} 77 | 78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /flux/static/flux/js/nunjs.min.js: -------------------------------------------------------------------------------- 1 | /* nunjs 0.0.2 */ 2 | Nunjs={each:function(t,e){var n,s=0;if(Array.isArray(t)||t instanceof NodeList)for(n=t.length;s=0},matches:function(t,e){if(!e||!t||1!==t.nodeType)return!1;var n=t.matches||t.webkitMatchesSelector||t.mozMatchesSelector||t.oMatchesSelector||t.matchesSelector||t.msMatchesSelector;return!!n&&n.call(t,e)}},_events={},window.$=function(t){var e="string"==typeof t?document.querySelectorAll(t):t,n={addClass:function(t){return this.each(function(){this.classList.contains(t)||this.classList.add(t)}),this},attr:function(t,n){return null===n||void 0===n?this[0].getAttribute(t):(this.each(e,function(){this.setAttribute(t,n)}),this)},click:function(t){return null===t||"function"!=typeof t?this.each(function(){this.click()}):this.each(function(e,n){$(n).on("click",t)}),this},each:function(t){return Nunjs.each(e,t),this},find:function(t){var e=[];return this.each(function(){for(var n=this.querySelectorAll(t),s=0;s 0) { 126 | $(this).parent('.upload-form').submit(); 127 | } 128 | } 129 | }); 130 | 131 | $('[data-toggle]').click(function(event) { 132 | event.preventDefault(); 133 | event.stopPropagation(); 134 | var id = $(this).attr('data-toggle'); 135 | $(id).toggle(); 136 | }); 137 | }); -------------------------------------------------------------------------------- /flux/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ config.app_title|safe + ((" | " + page_title) if page_title else "") }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 19 | {% block head %} 20 | {% endblock head %} 21 | 22 | 23 |
    24 | 25 | 26 | 27 |
    confirm message
    28 |
    29 | 30 | 31 |
    32 |
    33 |
    34 | 35 | 36 | 37 |
    input message
    38 | 39 |
    40 | 41 | 42 |
    43 |
    44 |
    45 |
    46 |
    47 | 82 |
    83 |
    84 |
    85 |
      86 | {% block toolbar %} 87 | {% endblock toolbar %} 88 |
    89 |

    {{ page_title if page_title and page_title != "Login" else "" }}

    90 | {% set flash = flux.utils.flash() %} 91 | {% if flash %} 92 |
    93 | 94 | 95 | 96 | 97 | 98 | 99 |
    {{ flash }}
    100 |
    101 | {% endif %} 102 | {% block body %} 103 | {% endblock body %} 104 |
    105 |
    106 |
    107 |
    108 | Powered by Flux CI v{{ flux.__version__ }} 109 |
    110 |
    111 | 112 | 113 | -------------------------------------------------------------------------------- /flux/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2018 Niklas Rosenstein 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | 24 | import argparse 25 | import re 26 | import subprocess 27 | import sys 28 | import os 29 | 30 | 31 | def get_argument_parser(prog=None): 32 | parser = argparse.ArgumentParser(prog=prog) 33 | parser.add_argument('--web', action='store_true', help='launch builtin webserver') 34 | parser.add_argument('-c','--config-file', help='Flux CI config file to load') 35 | return parser 36 | 37 | 38 | def main(argv=None, prog=None): 39 | parser = get_argument_parser(prog) 40 | args = parser.parse_args(argv) 41 | 42 | if not args.web: 43 | parser.print_usage() 44 | return 0 45 | 46 | # Add possible locations of flux config 47 | sys.path.insert(0, '.') 48 | if args.config_file and os.path.isfile(args.config_file): 49 | sys.path.insert(0, os.path.dirname(args.config_file)) 50 | 51 | # Load config as global 52 | from flux import config 53 | config.load(args.config_file) 54 | 55 | start_web() 56 | 57 | 58 | def check_requirements(): 59 | """ 60 | Checks some system requirements. If they are not met, prints an error and 61 | exits the process. Currently, this only checks if the required Git version 62 | is met (>= 2.3). 63 | """ 64 | 65 | # Test if Git version is at least 2.3 (for GIT_SSH_COMMAND) 66 | git_version = subprocess.check_output(['git', '--version']).decode().strip() 67 | git_version = re.search('^git version (\d\.\d+)', git_version) 68 | if git_version: 69 | git_version = git_version.group(1) 70 | if not git_version or int(git_version.split('.')[1]) < 3: 71 | print('Error: {!r} installed but need at least 2.3'.format(git_version)) 72 | sys.exit(1) 73 | 74 | 75 | def start_web(): 76 | check_requirements() 77 | 78 | import flux 79 | from flux import app, config, utils 80 | 81 | app.jinja_env.globals['config'] = config 82 | app.jinja_env.globals['flux'] = flux 83 | app.secret_key = config.secret_key 84 | app.config['DEBUG'] = config.debug 85 | app.config['SERVER_NAME'] = config.server_name 86 | print('DEBUG = {}'.format(config.debug)) 87 | print('SERVER_NAME = {}'.format(config.server_name)) 88 | 89 | from flux import views, build, models 90 | from urllib.parse import urlparse 91 | 92 | # Ensure that some of the required directories exist. 93 | for dirname in [config.root_dir, config.build_dir, config.override_dir, config.customs_dir]: 94 | if not os.path.exists(dirname): 95 | os.makedirs(dirname) 96 | 97 | # Make sure the root user exists and has all privileges, and that 98 | # the password is up to date. 99 | with models.session(): 100 | models.User.create_or_update_root() 101 | 102 | # Create a dispatcher for the sub-url under which the app is run. 103 | url_prefix = urlparse(config.app_url).path 104 | if url_prefix and url_prefix != '/': 105 | import flask 106 | from werkzeug.wsgi import DispatcherMiddleware 107 | target_app = DispatcherMiddleware(flask.Flask('_dummy_app'), { 108 | url_prefix: app, 109 | }) 110 | else: 111 | target_app = app 112 | 113 | app.logger.info('Starting builder threads...') 114 | build.run_consumers(num_threads=config.parallel_builds) 115 | build.update_queue() 116 | try: 117 | from werkzeug.serving import run_simple 118 | run_simple(config.host, config.port, target_app, 119 | use_debugger=config.debug, use_reloader=False) 120 | finally: 121 | app.logger.info('Stopping builder threads...') 122 | build.stop_consumers() 123 | 124 | 125 | _entry_point = lambda: sys.exit(main()) 126 | 127 | 128 | if __name__ == '__main__': 129 | _entry_point() 130 | -------------------------------------------------------------------------------- /flux/templates/view_repo.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import build_icon, build_ref, fmtdate %} 3 | {% set page_title = repo.name %} 4 | {% block head %} 5 | 15 | {% endblock head %} 16 | 17 | {% block toolbar %} 18 |
  • 19 | 20 | Repositories 21 | 22 |
  • 23 | {% if user.can_manage %} 24 | 40 | 51 | {% endif %} 52 | {% endblock toolbar %} 53 | 54 | {% block body %} 55 | {% if user.can_manage %} 56 |
    57 |
    Clone URL
    58 |
    {{ repo.clone_url }}
    59 |
    60 | {% endif %} 61 | {% if builds %} 62 | {% for build in builds %} 63 | 64 | 65 | 66 | 67 | {{ build_icon(build) }} 68 | 69 | 70 | #{{ build.num }} 71 | 72 | 73 | 74 | {{ build_ref(build) }} 75 | 76 | 77 | {{ build.commit_sha[0:8]}} 78 | 79 | 80 | 81 | 82 |   83 | 84 | 85 | {{ fmtdate(build.date_queued) }} 86 | 87 | 88 | 89 | 90 | 91 | 92 | {{ fmtdate(build.date_started) }} 93 | 94 | 95 | {{ fmtdate(build.date_finished) }} 96 | 97 | 98 | 99 | {{ flux.utils.get_date_diff(build.date_finished, build.date_started) }} 100 | 101 | 102 | 103 | 104 | {% endfor %} 105 | 106 |
    107 | {% if next_page %} 108 | 109 | Newer 110 | 111 | {% endif %} 112 | {% if previous_page %} 113 | 114 | Older 115 | 116 | {% endif %} 117 |
    118 | {% else %} 119 |
    120 | 121 | 122 | 123 |
    No builds for this repository.
    124 |
    125 | {% endif %} 126 | {% endblock %} 127 | -------------------------------------------------------------------------------- /flux_config.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This is the Flux configuration file. 3 | ''' 4 | 5 | import os 6 | from datetime import timedelta 7 | from flux.config import prepend_path 8 | from flux.enums import GitFolderHandling 9 | 10 | ## If your system does not provide the required Git version (>= 2.3), 11 | ## you can compile it by yourself and install it locally (or not install 12 | ## it at all and keep all build products in the source tree). Uncomment 13 | ## the next line and adjust path to update the PATH environment variable 14 | ## so flux can find the newer Git version. 15 | # prepend_path('~/git') 16 | 17 | ## Root directory where the flux data is stored, repositories are checked 18 | ## out and built. Defaults to the flux application directory. 19 | root_dir = os.path.abspath(os.environ.get('FLUX_ROOT', os.path.dirname(__file__))) 20 | 21 | ## The host address to which the Flux web server should bind to. For 22 | ## real world deployment, you should hide Flux behind a NGinx/Apache/etc. 23 | ## proxy server with SSL support. 24 | host = os.environ.get('FLUX_HOST', 'localhost') 25 | 26 | ## Port of the Flux web server. 27 | port = int(os.environ.get('FLUX_PORT', 4042)) 28 | 29 | ## Enable this option to increase the logging output, wich makes it 30 | ## easier to find and debug problems with Flux. 31 | debug = True 32 | 33 | ## The application title displayed in the header of the website. 34 | app_title = 'Flux CI' 35 | 36 | ## The application URL that is required in various places. Adjust 37 | ## if it differs from the HOST:PORT combination. 38 | app_url = os.environ.get('FLUX_APP_URL', 'http://{}:{}'.format(host, port)) 39 | 40 | ## The server name. This is important for redirects, especially when 41 | ## behind a proxy eg. via NGinx. Make sure to set the 'Host' header 42 | ## to this server name when passing the request to the Flux app. 43 | ## 44 | ## When deploying flux in a Docker container. you must set this to the 45 | ## name that is addressable from the outside of the container, eg. 46 | ## "localhost:4042". 47 | server_name = os.environ.get('FLUX_SERVER_NAME', app_url.replace('http://', '').replace('https://', '')) 48 | 49 | ## Secret key required for HTTP session. Use your own random key 50 | ## for deployment! Here's a useful link to quickly get a bunch of 51 | ## such random secret strings: 52 | ## 53 | ## https://api.wordpress.org/secret-key/1.1/salt/ 54 | secret_key = 'ThAHy8oxRiNIQDBnVlNjEVY78fXdWHdi' 55 | 56 | ## The PonyORM database configuration. 57 | ## https://ponyorm.com/ 58 | database = { 59 | 'provider': 'sqlite', 60 | 'filename': '{}/db.sqlite'.format(root_dir), 61 | 'create_db': True 62 | } 63 | 64 | ## Username and password of the root user with full access. 65 | root_user = 'root' 66 | root_password = 'alpine' 67 | 68 | ## The number of builds that may be executed in parallel. One is 69 | ## usually a good value since today's builds (depending on the used 70 | ## build system) are usually multiprocessed already. 71 | parallel_builds = 1 72 | 73 | ## Filenames of build scripts in a repository. The first matching 74 | ## filename will be used. 75 | if os.name == 'nt': 76 | build_scripts = ['.flux-build.cmd'] 77 | else: 78 | build_scripts = ['.flux-build.sh'] 79 | 80 | ## The directory in which all repositories are cloned to 81 | ## and the builds are executed in. The directory structure that 82 | ## is created by flux is // . 83 | build_dir = os.path.join(root_dir, 'builds') 84 | 85 | ## The directory which contain file overrides for repositories. 86 | ## Anything in the corresponding repository folder 87 | ## will overwrite repository contents after clone. 88 | ## This is especially useful, if you want to make sure your 89 | ## desired build script and nothing else gets executed 90 | ## or to override e.g. an icon. 91 | ## -> Example: 92 | ## override_dir/// containing icon.png 93 | ## -> gets copied to (after clone) 94 | ## build_dir////icon.png 95 | override_dir = os.path.join(root_dir, 'overrides') 96 | 97 | ## The directory which contains custom files for each repository. 98 | ## Usage of files could be variable. 99 | customs_dir = os.path.join(root_dir, 'customs') 100 | 101 | ## Full path to the SSH identity file, or None to let SSH decide. 102 | ssh_identity_file = None 103 | 104 | ## True if SSH verbose mode should be used. 105 | ssh_verbose = False 106 | 107 | ## The time that a login token should be valid for. Specify "None" to 108 | ## prevent login tokens from expiring. 109 | login_token_duration = timedelta(hours=6) 110 | 111 | ## Defines, how .git folder should be handled during build process, it uses values from GitFolderHandling enum: 112 | ## * DELETE_BEFORE_BUILD - Native behaviour, that deletes .git folder before .flux-build runs. 113 | ## * DELETE_AFTER_BUILD - Deletes .git folder after .flux-build successfully runs, before artifact is zipped. 114 | ## * DISABLE_DELETE - .git folder is never deleted, it will be part of artifact ZIP. 115 | git_folder_handling = GitFolderHandling.DELETE_BEFORE_BUILD 116 | -------------------------------------------------------------------------------- /flux/templates/integration.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% set page_title = "Integration" %} 3 | {% block body %} 4 | {% if user.can_manage %} 5 |

    Public Key

    6 | {% if public_key %} 7 |

    8 | This is the public key of the Flux CI server that needs to be added 9 | to the Git server from which repositories are cloned. 10 |

    11 |
    {{ public_key }}
    12 | {% else %} 13 |
    14 | 15 | 16 | 17 |
    The server has no SSH public key!
    18 |
    19 | {% endif %} 20 | 21 |

    Webhook

    22 |

    23 | This is the Webhook URL. You should use the appropriate name of 24 | the Git server for the ?api= url parameter. A list of 25 | Git servers for which webhooks are supported can be found below. 26 |

    27 |
    {{ flux.utils.strip_url_path(config.app_url) }}{{ url_for('hook_push') }}
    28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
    ServiceWebhook URL Parameter
    BitBucket (Self-hosted) ?api=bitbucket
    BitBucket (Cloud) ?api=bitbucket-cloud
    GitBucket ?api=gitbucket
    Gitea ?api=gitea
    GitHub ?api=github
    GitLab ?api=gitlab
    Gogs ?api=gogs
    Bare repository?api=bare
    79 |
    80 | 81 | 82 | 83 |
    84 | The bare API is a simplified JSON payload for the purpose of using Flux CI 85 | with bare repositories. 86 |

    87 | Example of custom webhook request: 88 |

    89 | 90 |
    {
     91 |   "owner": "owner",
     92 |   "name": "name",
     93 |   "ref": "refs/tags/1.0",
     94 |   "commit": "0000000000000000000000000000000000000000",
     95 |   "secret": "custom-project-secret-in-plain-text"
     96 | }
    97 |
    98 |

    99 | Example of custom update hook: 100 |

    101 | 102 |
    #!/bin/sh
    103 | refname="$1"
    104 | newrev="$3"
    105 | 
    106 | if [ "$newrev" =  "0000000000000000000000000000000000000000" ]; then
    107 |         exit 0
    108 | fi
    109 | 
    110 | curl --header "Content-Type: application/json" --request POST --data "{\"owner\": \"owner\", \"name\": \"name\", \"ref\": \"$refname\", \"commit\": \"$newrev\", \"secret\": \"custom-project-secret-in-plain-text\"}" http://localhost/flux/hook/push?api=bare > /dev/null
    111 | 
    112 | exit 0
    113 |
    114 |
    115 |
    116 | {% endif %} 117 | 118 | {% endblock body %} 119 | -------------------------------------------------------------------------------- /flux/templates/edit_repo.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import render_error_list %} 3 | {% set page_title = "Edit Repository" if repo else "Add Repository" %} 4 | {% block toolbar %} 5 |
  • 6 | 7 | {{ repo.name if repo else "Repositories" }} 8 | 9 |
  • 10 | {% endblock toolbar %} 11 | 12 | {% block body %} 13 | {{ render_error_list(errors) }} 14 |
    15 | You must ensure that the Flux CI server has read permission for 16 | the clone URL that you specify below. The URL is stored unencrypted 17 | in the database, thus you should avoid using the 18 | https://username:password@host/name format. 19 |
    20 |
    21 |
    22 | 23 | 24 |
    25 |
    26 | 27 | 28 | 29 |
    30 |
    31 | 32 |
    33 | The secret key that is sent by the Git server for 34 | authentication purpose. The default value is a randomly 35 | generated UUID that serves the purpose. You can also leave 36 | the field blank if the secret sent by the server is an empty string. 37 |
    38 | 39 |
    40 |
    41 | 42 |
    43 | A list of Git refs on which builds are triggered. If no refs are 44 | listed, a build is triggered for any ref. One Git ref per line. 45 |
    46 | 47 |
    48 |
    49 | 50 |
    51 | Build script, that overrides default build script attached in project repository. 52 | This also allows to not require build script to be placed in project repository. 53 |
    54 | 55 |
    56 | {% if repo %} 57 |
    58 | 59 | {% if public_key %} 60 |
    {{ public_key }}
    61 | {% else %} 62 |

    63 | You can generate a unique SSH keypair that will be used to clone this repository 64 | when it is built. In that case, the public key is displayed here so you can add it 65 | to your hosted Git repository. 66 |

    67 | {% endif %} 68 |
    69 | {% if public_key %} 70 | 73 | Remove SSH Keypair 74 | 75 | {% else %} 76 | 79 | Generate SSH Keypair 80 | 81 | {% endif %} 82 | {% endif %} 83 | 84 |
    85 | 110 | {% endblock body %} 111 | -------------------------------------------------------------------------------- /flux/templates/view_build.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import build_icon, build_ref, fmtdate %} 3 | {% set page_title = build.repo.name + " #" + build.num|string %} 4 | {% block head %} 5 | {% if build.status == build.Status_Building %} 6 | 7 | {% endif %} 8 | {% endblock head %} 9 | 10 | {% block toolbar %} 11 |
  • 12 | 13 | {{ build.repo.name }} 14 | 15 |
  • 16 | {% if user.can_manage or build.check_download_permission(build.Data_Log, user) or build.check_download_permission(build.Data_Artifact, user) %} 17 | 49 | 77 | {% endif %} 78 | {% endblock toolbar %} 79 | 80 | {% block body %} 81 | 82 | 83 | 84 | {{ build_icon(build) }} 85 | 86 | 87 | #{{ build.num }} 88 | 89 | 90 | 91 | {{ build_ref(build) }} 92 | 93 | 94 | {{ build.commit_sha[0:8]}} 95 | 96 | 97 | 98 | 99 |   100 | 101 | 102 | {{ fmtdate(build.date_queued) }} 103 | 104 | 105 | 106 | 107 | 108 | 109 | {{ fmtdate(build.date_started) }} 110 | 111 | 112 | {{ fmtdate(build.date_finished) }} 113 | 114 | 115 | 116 | {{ flux.utils.get_date_diff(build.date_finished, build.date_started) }} 117 | 118 | 119 | 120 | 121 | {% if build.status != build.Status_Queued and build.check_download_permission(build.Data_Log, user) %} 122 |

    Build Log

    123 | {% if not build.exists(build.Data_Log) %} 124 |
    125 | 126 | 127 | 128 |
    Build log missing.
    129 |
    130 | {% else %} 131 |
    {{ build.log_contents() }}
    132 | {% endif %} 133 | {% endif %} 134 | {% endblock %} 135 | -------------------------------------------------------------------------------- /flux/flux-fontello.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "css_prefix_text": "fa-", 4 | "css_use_suffix": false, 5 | "hinting": true, 6 | "units_per_em": 1000, 7 | "ascent": 850, 8 | "glyphs": [ 9 | { 10 | "uid": "e99461abfef3923546da8d745372c995", 11 | "css": "cog", 12 | "code": 59393, 13 | "src": "fontawesome" 14 | }, 15 | { 16 | "uid": "43ab845088317bd348dee1d975700c48", 17 | "css": "check-circle", 18 | "code": 59392, 19 | "src": "fontawesome" 20 | }, 21 | { 22 | "uid": "598a5f2bcf3521d1615de8e1881ccd17", 23 | "css": "clock-o", 24 | "code": 59395, 25 | "src": "fontawesome" 26 | }, 27 | { 28 | "uid": "ead4c82d04d7758db0f076584893a8c1", 29 | "css": "calendar-o", 30 | "code": 61747, 31 | "src": "fontawesome" 32 | }, 33 | { 34 | "uid": "9de4ac1aec6b1cca1929e1407eecf3db", 35 | "css": "calendar-check-o", 36 | "code": 62068, 37 | "src": "fontawesome" 38 | }, 39 | { 40 | "uid": "3363990fa5a224d75ed1740e1ec50bb6", 41 | "css": "stop-circle", 42 | "code": 62093, 43 | "src": "fontawesome" 44 | }, 45 | { 46 | "uid": "a73c5deb486c8d66249811642e5d719a", 47 | "css": "refresh", 48 | "code": 59396, 49 | "src": "fontawesome" 50 | }, 51 | { 52 | "uid": "0d20938846444af8deb1920dc85a29fb", 53 | "css": "sign-out", 54 | "code": 59398, 55 | "src": "fontawesome" 56 | }, 57 | { 58 | "uid": "d870630ff8f81e6de3958ecaeac532f2", 59 | "css": "chevron-left", 60 | "code": 59399, 61 | "src": "fontawesome" 62 | }, 63 | { 64 | "uid": "9a76bc135eac17d2c8b8ad4a5774fc87", 65 | "css": "download", 66 | "code": 59400, 67 | "src": "fontawesome" 68 | }, 69 | { 70 | "uid": "44e04715aecbca7f266a17d5a7863c68", 71 | "css": "plus", 72 | "code": 59401, 73 | "src": "fontawesome" 74 | }, 75 | { 76 | "uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6", 77 | "css": "pencil", 78 | "code": 59402, 79 | "src": "fontawesome" 80 | }, 81 | { 82 | "uid": "bbfb51903f40597f0b70fd75bc7b5cac", 83 | "css": "trash", 84 | "code": 61944, 85 | "src": "fontawesome" 86 | }, 87 | { 88 | "uid": "3db5347bd219f3bce6025780f5d9ef45", 89 | "css": "tag", 90 | "code": 59403, 91 | "src": "fontawesome" 92 | }, 93 | { 94 | "uid": "bc4b94dd7a9a1dd2e02f9e4648062596", 95 | "css": "code-fork", 96 | "code": 61734, 97 | "src": "fontawesome" 98 | }, 99 | { 100 | "uid": "0f4cae16f34ae243a6144c18a003f2d8", 101 | "css": "times-circle", 102 | "code": 59404, 103 | "src": "fontawesome" 104 | }, 105 | { 106 | "uid": "e82cedfa1d5f15b00c5a81c9bd731ea2", 107 | "css": "info-circle", 108 | "code": 59397, 109 | "src": "fontawesome" 110 | }, 111 | { 112 | "uid": "c76b7947c957c9b78b11741173c8349b", 113 | "css": "exclamation-triangle", 114 | "code": 59394, 115 | "src": "fontawesome" 116 | }, 117 | { 118 | "uid": "559647a6f430b3aeadbecd67194451dd", 119 | "css": "bars", 120 | "code": 61641, 121 | "src": "fontawesome" 122 | }, 123 | { 124 | "uid": "12f4ece88e46abd864e40b35e05b11cd", 125 | "css": "check", 126 | "code": 59405, 127 | "src": "fontawesome" 128 | }, 129 | { 130 | "uid": "5211af474d3a9848f67f945e2ccaf143", 131 | "css": "times", 132 | "code": 59406, 133 | "src": "fontawesome" 134 | }, 135 | { 136 | "uid": "17ebadd1e3f274ff0205601eef7b9cc4", 137 | "css": "question-circle", 138 | "code": 59407, 139 | "src": "fontawesome" 140 | }, 141 | { 142 | "uid": "57a0ac800df728aad61a7cf9e12f5fef", 143 | "css": "flag", 144 | "code": 59408, 145 | "src": "fontawesome" 146 | }, 147 | { 148 | "uid": "4aad6bb50b02c18508aae9cbe14e784e", 149 | "css": "share-alt", 150 | "code": 61920, 151 | "src": "fontawesome" 152 | }, 153 | { 154 | "uid": "8b80d36d4ef43889db10bc1f0dc9a862", 155 | "css": "user", 156 | "code": 59409, 157 | "src": "fontawesome" 158 | }, 159 | { 160 | "uid": "5434b2bf3a3a6c4c260a11a386e3f5d1", 161 | "css": "stop-circle-o", 162 | "code": 62094, 163 | "src": "fontawesome" 164 | }, 165 | { 166 | "uid": "399ef63b1e23ab1b761dfbb5591fa4da", 167 | "css": "chevron-right", 168 | "code": 59410, 169 | "src": "fontawesome" 170 | }, 171 | { 172 | "uid": "9bd60140934a1eb9236fd7a8ab1ff6ba", 173 | "css": "wait-spin", 174 | "code": 59444, 175 | "src": "fontelico" 176 | }, 177 | { 178 | "uid": "b091a8bd0fdade174951f17d936f51e4", 179 | "css": "folder-o", 180 | "code": 61716, 181 | "src": "fontawesome" 182 | }, 183 | { 184 | "uid": "1b5a5d7b7e3c71437f5a26befdd045ed", 185 | "css": "file-o", 186 | "code": 59411, 187 | "src": "fontawesome" 188 | }, 189 | { 190 | "uid": "eeec3208c90b7b48e804919d0d2d4a41", 191 | "css": "upload", 192 | "code": 59412, 193 | "src": "fontawesome" 194 | }, 195 | { 196 | "uid": "6846d155ad5bda456569df81f3057faa", 197 | "css": "clone", 198 | "code": 62029, 199 | "src": "fontawesome" 200 | }, 201 | { 202 | "uid": "8beac4a5fd5bed9f82ca7a96cc8ba218", 203 | "css": "key", 204 | "code": 59413, 205 | "src": "entypo" 206 | } 207 | ] 208 | } -------------------------------------------------------------------------------- /flux/file_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | 3 | import io 4 | import os 5 | import shutil 6 | 7 | 8 | def split_url_path(path): 9 | """ 10 | Separates URL path to repository name and path. 11 | 12 | # Parameters 13 | path (str): The path from URL. 14 | 15 | # Return 16 | tuple (str, str): The repository name and the path to be listed. 17 | """ 18 | 19 | separator = '/' 20 | parts = path.split(separator) 21 | return separator.join(parts[0:2]), separator.join(parts[2:]) 22 | 23 | 24 | def list_folder(cwd): 25 | """ 26 | List folder on *cwd* path as list of *File*. 27 | 28 | # Parameters 29 | cwd (str): The absolute path to be listed. 30 | 31 | # Return 32 | list (File): The list of files and folders listed in path. 33 | """ 34 | 35 | data = sorted(os.listdir(cwd)) 36 | dirs = [] 37 | files = [] 38 | for filename in data: 39 | file = File(filename, cwd) 40 | if file.type == File.TYPE_FOLDER: 41 | dirs.append(file) 42 | else: 43 | files.append(file) 44 | result = dirs + files 45 | return result 46 | 47 | 48 | def create_folder(cwd, folder_name): 49 | """ 50 | Creates folder named *folder_name* on defined *cwd* path. 51 | If does not exist, it creates it and return new path of folder. 52 | If already exists, it returns empty str. 53 | 54 | # Parameters 55 | cwd (str): The absolute path, where folder should be created. 56 | folder_name (str): The name of folder to be created. 57 | 58 | # Return 59 | str: Path of newly created folder, if it does not already exist. 60 | """ 61 | 62 | path = os.path.join(cwd, folder_name) 63 | if not os.path.exists(path): 64 | os.makedirs(path) 65 | return path 66 | return '' 67 | 68 | 69 | def create_file(cwd, file_name): 70 | """ 71 | Creates file named *file_name* on defined *cwd* path. 72 | If does not exist, it creates it and return new path of file. 73 | If already exists, it returns empty str. 74 | 75 | # Parameters 76 | cwd (str): The absolute path, where file should be created. 77 | file_name (str): The name of file to be created. 78 | 79 | # Return 80 | str: Path of newly created file, if it does not already exist. 81 | """ 82 | 83 | path = os.path.join(cwd, file_name) 84 | if not os.path.exists(path): 85 | open(path, 'w').close() 86 | return path 87 | return '' 88 | 89 | 90 | def create_file_path(file_path): 91 | """ 92 | Creates file defined by *file_path*. 93 | If does not exist, it creates it and return new path of file. 94 | If already exists, it returns empty str. 95 | 96 | # Parameters 97 | cwd (str): The absolute path, where file should be created. 98 | file_name (str): The name of file to be created. 99 | 100 | # Return 101 | str: Path of newly created file, if it does not already exist. 102 | """ 103 | 104 | if not os.path.exists(file_path): 105 | open(file_path, 'w').close() 106 | return file_path 107 | return '' 108 | 109 | 110 | def read_file(path): 111 | """ 112 | Reads file located in defined *path*. 113 | If does not exist, it returns empty str. 114 | 115 | # Parameters 116 | path (str): The absolute path of file to be read. 117 | 118 | # Return 119 | str: Text content of file. 120 | """ 121 | 122 | if os.path.isfile(path): 123 | file = open(path, mode='r') 124 | content = file.read() 125 | file.close() 126 | return content 127 | return '' 128 | 129 | 130 | def write_file(path, data, encoding='utf8'): 131 | """ 132 | Writes *data* into file located in *path*, only if it already exists. 133 | As workaround, it replaces \r symbol. 134 | 135 | # Parameters 136 | path (str): The absolute path of file to be written. 137 | data (str): Text content to be written. 138 | """ 139 | 140 | if os.path.isfile(path): 141 | file = io.open(path, mode='w', encoding=encoding) 142 | file.write(data.replace('\r', '')) 143 | file.close() 144 | 145 | 146 | def rename(path, new_path): 147 | """ 148 | Performs rename operation from *path* to *new_path*. This operation 149 | performs only in case, that there is not file/folder with same name. 150 | 151 | # Parameters 152 | path (str): Old path to be renamed. 153 | new_path (str): New path to be renamed to. 154 | """ 155 | 156 | if (os.path.isfile(path) and not os.path.isfile(new_path)) or (os.path.isdir(path) and not os.path.isdir(new_path)): 157 | os.rename(path, new_path) 158 | 159 | 160 | def delete(path): 161 | """ 162 | Performs delete operation on file or folder stored on *path*. 163 | If on *path* is file, it performs os.remove(). 164 | If on *path* is folder, it performs shutil.rmtree(). 165 | 166 | # Parameters 167 | path (str): The absolute path of file or folder to be deleted. 168 | """ 169 | 170 | if os.path.isfile(path): 171 | os.remove(path) 172 | elif os.path.isdir(path): 173 | shutil.rmtree(path) 174 | 175 | 176 | def human_readable_size(filesize = 0): 177 | """ 178 | Converts number of bytes from *filesize* to human-readable format. 179 | e.g. 2048 is converted to "2 kB". 180 | 181 | # Parameters: 182 | filesize (int): The size of file in bytes. 183 | 184 | # Return: 185 | str: Human-readable size. 186 | """ 187 | 188 | for unit in ['', 'k', 'M', 'G', 'T', 'P']: 189 | if filesize < 1024: 190 | return "{} {}B".format("{:.2f}".format(filesize).rstrip('0').rstrip('.'), unit) 191 | filesize = filesize / 1024 192 | return '0 B' 193 | 194 | 195 | class File: 196 | TYPE_FOLDER = 'folder' 197 | TYPE_FILE = 'file' 198 | 199 | def __init__(self, filename, path): 200 | full_path = os.path.join(path, filename) 201 | 202 | self.type = File.TYPE_FOLDER if os.path.isdir(full_path) else File.TYPE_FILE 203 | self.filename = filename 204 | self.path = path 205 | self.filesize = os.path.getsize(full_path) if self.type == File.TYPE_FILE else 0 206 | self.filesize_readable = human_readable_size(self.filesize) 207 | -------------------------------------------------------------------------------- /flux/templates/overrides_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import render_error_list %} 3 | {% set page_title = "File Overrides" %} 4 | {% block head %} 5 | 33 | {% endblock head%} 34 | 35 | {% block toolbar %} 36 | {% if overrides_path != None and overrides_path != '' %} 37 |
  • 38 | 39 | /{{ parent_name }} 40 | 41 |
  • 42 | {% else %} 43 |
  • 44 | 45 | {{ repo.name }} 46 | 47 |
  • 48 | {% endif %} 49 | 63 | 68 | {% endblock toolbar %} 69 | 70 | {% block body %} 71 | {{ render_error_list(errors) }} 72 |

    /{{ overrides_path }}

    73 | {% if overrides_path != None and overrides_path != '' %} 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | ../ 83 | 84 | 85 | 86 | 87 | 88 | {% endif %} 89 | {% if files %} 90 | {% for file in files %} 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | {% set url_path = list_path if file.type == 'folder' else edit_path %} 101 | 102 | {{ file.filename }} 103 | 104 | 105 | 106 | {% if file.type == 'file' %} 107 | {{ file.filesize_readable }} 108 | {% else %} 109 |   110 | {% endif %} 111 | 112 | 113 | 114 | 115 | 116 |   117 |   118 | 119 | 120 | {% if file.type == 'file' %} 121 | 124 | 125 | 126 | {% endif %} 127 | 133 | 134 | 135 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | {% endfor %} 146 | {% elif not files and (overrides_path == None or overrides_path == '') %} 147 |
    148 | 149 | 150 | 151 |
    No files in this directory.
    152 |
    153 | {% endif %} 154 | {% endblock body %} 155 | -------------------------------------------------------------------------------- /flux/models.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | This package provides the database models using PonyORM. 4 | 5 | Note that we do not rely on the auto increment feature as the previous 6 | SQLAlchemy implementation did not set AUTO INCREMENT on the ID fields, 7 | as PonyORM would. 8 | """ 9 | 10 | from flask import url_for 11 | from flux import app, config, utils 12 | 13 | import datetime 14 | import hashlib 15 | import os 16 | import pony.orm as orm 17 | import shutil 18 | import uuid 19 | 20 | db = orm.Database(**config.database) 21 | session = orm.db_session 22 | commit = orm.commit 23 | rollback = orm.rollback 24 | select = orm.select 25 | desc = orm.desc 26 | 27 | 28 | class User(db.Entity): 29 | _table_ = 'users' 30 | 31 | id = orm.PrimaryKey(int) 32 | name = orm.Required(str, unique=True) 33 | passhash = orm.Required(str) 34 | can_manage = orm.Required(bool) 35 | can_download_artifacts = orm.Required(bool) 36 | can_view_buildlogs = orm.Required(bool) 37 | login_tokens = orm.Set('LoginToken') 38 | 39 | def __init__(self, **kwargs): 40 | if 'id' not in kwargs: 41 | kwargs['id'] = (orm.max(x.id for x in User) or 0) + 1 42 | super().__init__(**kwargs) 43 | 44 | def set_password(self, password): 45 | self.passhash = utils.hash_pw(password) 46 | 47 | @classmethod 48 | def get_by_login_details(cls, user_name, password): 49 | passhash = utils.hash_pw(password) 50 | return orm.select(x for x in cls if x.name == user_name and 51 | x.passhash == passhash).first() 52 | 53 | @classmethod 54 | def get_root_user(cls): 55 | return orm.select(x for x in cls if x.name == config.root_user).first() 56 | 57 | @classmethod 58 | def create_or_update_root(cls): 59 | root = cls.get_root_user() 60 | if root: 61 | # Make sure the root has all privileges. 62 | root.can_manage = True 63 | root.can_download_artifacts = True 64 | root.can_view_buildlogs = True 65 | root.set_password(config.root_password) 66 | root.name = config.root_user 67 | else: 68 | # Create a new root user. 69 | app.logger.info('Creating new root user: {!r}'.format(config.root_user)) 70 | root = cls( 71 | name=config.root_user, 72 | passhash=utils.hash_pw(config.root_password), 73 | can_manage=True, 74 | can_download_artifacts=True, 75 | can_view_buildlogs=True) 76 | return root 77 | 78 | def url(self): 79 | return url_for('edit_user', user_id=self.id) 80 | 81 | 82 | class LoginToken(db.Entity): 83 | """ 84 | A login token represents the credentials that we can savely store in the 85 | browser's session and it will not reveal any information about the users 86 | password. For additional security, a login token is bound to the IP that 87 | the user logged in from an has an expiration date. 88 | 89 | The expiration duration can be set with the `login_token_duration` 90 | configuration value. Setting this option to #None will prevent tokens 91 | from expiring. 92 | """ 93 | 94 | _table_ = 'logintokens' 95 | 96 | id = orm.PrimaryKey(int) 97 | ip = orm.Required(str) 98 | user = orm.Required(User) 99 | token = orm.Required(str, unique=True) 100 | created = orm.Required(datetime.datetime) 101 | 102 | @classmethod 103 | def create(cls, ip, user): 104 | " Create a new login token assigned to the specified IP and user. " 105 | 106 | id = (orm.max(x.id for x in cls) or 0) + 1 107 | created = datetime.datetime.now() 108 | token = str(uuid.uuid4()).replace('-', '') 109 | token += hashlib.md5((token + str(created)).encode()).hexdigest() 110 | return cls(id=id, ip=ip, user=user, token=token, created=created) 111 | 112 | def expired(self): 113 | " Returns #True if the token is expired, #False otherwise. " 114 | 115 | if config.login_token_duration is None: 116 | return False 117 | now = datetime.datetime.now() 118 | return (self.created + config.login_token_duration) < now 119 | 120 | 121 | class Repository(db.Entity): 122 | """ 123 | Represents a repository for which push events are being accepted. The Git 124 | server specified at the `clone_url` must accept the Flux server's public 125 | key. 126 | """ 127 | 128 | _table_ = 'repos' 129 | 130 | id = orm.PrimaryKey(int) 131 | name = orm.Required(str) 132 | secret = orm.Optional(str) 133 | clone_url = orm.Required(str) 134 | build_count = orm.Required(int, default=0) 135 | builds = orm.Set('Build') 136 | ref_whitelist = orm.Optional(str) # newline separated list of accepted Git refs 137 | 138 | def __init__(self, **kwargs): 139 | if 'id' not in kwargs: 140 | kwargs['id'] = (orm.max(x.id for x in Repository) or 0) + 1 141 | super().__init__(**kwargs) 142 | 143 | def url(self, **kwargs): 144 | return url_for('view_repo', path=self.name, **kwargs) 145 | 146 | def check_accept_ref(self, ref): 147 | whitelist = list(filter(bool, self.ref_whitelist.split('\n'))) 148 | if not whitelist or ref in whitelist: 149 | return True 150 | return False 151 | 152 | def validate_ref_whitelist(self, value, oldvalue, initiator): 153 | return '\n'.join(filter(bool, (x.strip() for x in value.split('\n')))) 154 | 155 | def most_recent_build(self): 156 | return self.builds.select().order_by(desc(Build.date_started)).first() 157 | 158 | 159 | class Build(db.Entity): 160 | """ 161 | Represents a build that is generated on a push to a repository. The build is 162 | initially queued and then processed when a slot is available. The build 163 | directory is generated from the configured root directory and the build 164 | #uuid. The log file has the exact same path with the `.log` suffix appended. 165 | 166 | After the build is complete (whether successful or errornous), the build 167 | directory is zipped and the original directory is removed. 168 | """ 169 | 170 | _table_ = 'builds' 171 | 172 | Status_Queued = 'queued' 173 | Status_Building = 'building' 174 | Status_Error = 'error' 175 | Status_Success = 'success' 176 | Status_Stopped = 'stopped' 177 | Status = [Status_Queued, Status_Building, Status_Error, Status_Success, Status_Stopped] 178 | 179 | Data_BuildDir = 'build_dir' 180 | Data_OverrideDir = 'override_dir' 181 | Data_Artifact = 'artifact' 182 | Data_Log = 'log' 183 | 184 | class CanNotDelete(Exception): 185 | pass 186 | 187 | id = orm.PrimaryKey(int) 188 | repo = orm.Required(Repository, column='repo_id') 189 | ref = orm.Required(str) 190 | commit_sha = orm.Required(str) 191 | num = orm.Required(int) 192 | status = orm.Required(str) # One of the Status strings 193 | date_queued = orm.Required(datetime.datetime, default=datetime.datetime.now) 194 | date_started = orm.Optional(datetime.datetime) 195 | date_finished = orm.Optional(datetime.datetime) 196 | 197 | def __init__(self, **kwargs): 198 | # Backwards compatibility for when SQLAlchemy was used, Auto Increment 199 | # was not enabled there. 200 | if 'id' not in kwargs: 201 | kwargs['id'] = (orm.max(x.id for x in Build) or 0) + 1 202 | super(Build, self).__init__(**kwargs) 203 | 204 | def url(self, data=None, **kwargs): 205 | path = self.repo.name + '/' + str(self.num) 206 | if not data: 207 | return url_for('view_build', path=path, **kwargs) 208 | elif data in (self.Data_Artifact, self.Data_Log): 209 | return url_for('download', build_id=self.id, data=data, **kwargs) 210 | else: 211 | raise ValueError('invalid mode: {!r}'.format(mode)) 212 | 213 | def path(self, data=Data_BuildDir): 214 | base = os.path.join(config.build_dir, self.repo.name.replace('/', os.sep), str(self.num)) 215 | if data == self.Data_BuildDir: 216 | return base 217 | elif data == self.Data_Artifact: 218 | return base + '.zip' 219 | elif data == self.Data_Log: 220 | return base + '.log' 221 | elif data == self.Data_OverrideDir: 222 | return os.path.join(config.override_dir, self.repo.name.replace('/', os.sep)) 223 | else: 224 | raise ValueError('invalid value for "data": {!r}'.format(data)) 225 | 226 | def exists(self, data): 227 | return os.path.exists(self.path(data)) 228 | 229 | def log_contents(self): 230 | path = self.path(self.Data_Log) 231 | if os.path.isfile(path): 232 | with open(path, 'r') as fp: 233 | return fp.read() 234 | return None 235 | 236 | def check_download_permission(self, data, user): 237 | if data == self.Data_Artifact: 238 | return user.can_download_artifacts and ( 239 | self.status == self.Status_Success or user.can_view_buildlogs) 240 | elif data == self.Data_Log: 241 | return user.can_view_buildlogs 242 | else: 243 | raise ValueError('invalid value for data: {!r}'.format(data)) 244 | 245 | def delete_build(self): 246 | if self.status == self.Status_Building: 247 | raise self.CanNotDelete('can not delete build in progress') 248 | try: 249 | os.remove(self.path(self.Data_Artifact)) 250 | except OSError as exc: 251 | app.logger.exception(exc) 252 | try: 253 | os.remove(self.path(self.Data_Log)) 254 | except OSError as exc: 255 | app.logger.exception(exc) 256 | 257 | # db.Entity Overrides 258 | 259 | def before_delete(self): 260 | self.delete_build() 261 | 262 | 263 | def get_target_for(path): 264 | """ 265 | Given an URL path, returns either a #Repository or #Build that the path 266 | identifies. #None will be retunred if the path points to an unknown 267 | repository or build. 268 | 269 | Examples: 270 | 271 | /User/repo => Repository(User/repo) 272 | /User/repo/1 => Build(1, Repository(User/repo)) 273 | """ 274 | 275 | parts = path.split('/') 276 | if len(parts) not in (2, 3): 277 | return None 278 | repo_name = parts[0] + '/' + parts[1] 279 | repo = Repository.get(name=repo_name) 280 | if not repo: 281 | return None 282 | if len(parts) == 3: 283 | try: num = int(parts[2]) 284 | except ValueError: return None 285 | return Build.get(repo=repo, num=num) 286 | return repo 287 | 288 | 289 | db.generate_mapping(create_tables=True) 290 | -------------------------------------------------------------------------------- /flux/build.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Niklas Rosenstein 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | ''' 21 | This module implements the Flux worker queue. Flux will start one or 22 | more threads (based on the ``parallel_builds`` configuration value) 23 | that will process the queue. 24 | ''' 25 | 26 | from flux import app, config, utils, models 27 | from flux.enums import GitFolderHandling 28 | from flux.models import select, Build 29 | from collections import deque 30 | from threading import Event, Condition, Thread 31 | from datetime import datetime 32 | from distutils import dir_util 33 | 34 | import contextlib 35 | import os 36 | import shlex 37 | import shutil 38 | import stat 39 | import subprocess 40 | import time 41 | import traceback 42 | 43 | 44 | class BuildConsumer(object): 45 | ''' This class can start a number of threads that consume 46 | :class:`Build` objects and execute them. ''' 47 | 48 | def __init__(self): 49 | self._cond = Condition() 50 | self._running = False 51 | self._queue = deque() 52 | self._terminate_events = {} 53 | self._threads = [] 54 | 55 | def put(self, build): 56 | if not isinstance(build, Build): 57 | raise TypeError('expected Build instance') 58 | assert build.id is not None 59 | # TODO: Check if the build is commited to the database, we should'nt 60 | # enqueue it before it is. 61 | if build.status != Build.Status_Queued: 62 | raise TypeError('build status must be {!r}'.format(Build.Status_Queued)) 63 | with self._cond: 64 | if build.id not in self._queue: 65 | self._queue.append(build.id) 66 | self._cond.notify() 67 | 68 | def terminate(self, build): 69 | ''' Given a :class:`Build` object, terminates the ongoing build 70 | process or removes the build from the queue and sets its status 71 | to "stopped". ''' 72 | 73 | if not isinstance(build, Build): 74 | raise TypeError('expected Build instance') 75 | with self._cond: 76 | if build.id in self._terminate_events: 77 | self._terminate_events[build.id].set() 78 | elif build.id in self._queue: 79 | self._queue.remove(build.id) 80 | build.status = build.Status_Stopped 81 | 82 | def stop(self, join=True): 83 | with self._cond: 84 | for event in self._terminate_events.values(): 85 | event.set() 86 | self._running = False 87 | self._cond.notify() 88 | if join: 89 | [t.join() for t in self._threads] 90 | 91 | def start(self, num_threads=1): 92 | def worker(): 93 | while True: 94 | with self._cond: 95 | while not self._queue and self._running: 96 | self._cond.wait() 97 | if not self._running: 98 | break 99 | build_id = self._queue.popleft() 100 | with models.session(): 101 | build = Build.get(id=build_id) 102 | if not build or build.status != Build.Status_Queued: 103 | continue 104 | with self._cond: 105 | do_terminate = self._terminate_events[build_id] = Event() 106 | try: 107 | do_build(build_id, do_terminate) 108 | except BaseException as exc: 109 | traceback.print_exc() 110 | finally: 111 | with self._cond: 112 | self._terminate_events.pop(build_id) 113 | 114 | if num_threads < 1: 115 | raise ValueError('num_threads must be >= 1') 116 | with self._cond: 117 | if self._running: 118 | raise RuntimeError('already running') 119 | self._running = True 120 | self._threads = [Thread(target=worker) for i in range(num_threads)] 121 | [t.start() for t in self._threads] 122 | 123 | def is_running(self, build): 124 | with self._cond: 125 | return build.id in self._queue 126 | 127 | 128 | _consumer = BuildConsumer() 129 | enqueue = _consumer.put 130 | terminate_build = _consumer.terminate 131 | run_consumers = _consumer.start 132 | stop_consumers = _consumer.stop 133 | 134 | 135 | def update_queue(consumer=None): 136 | ''' Make sure all builds in the database that are still queued 137 | are actually queued in the BuildConsumer. ''' 138 | 139 | if consumer is None: 140 | consumer = _consumer 141 | with models.session(): 142 | for build in select(x for x in Build if x.status == Build.Status_Queued): 143 | enqueue(build) 144 | for build in select(x for x in Build if x.status == Build.Status_Building): 145 | if not consumer.is_running(build): 146 | build.status = Build.Status_Stopped 147 | 148 | def deleteGitFolder(build_path): 149 | shutil.rmtree(os.path.join(build_path, '.git')) 150 | 151 | def do_build(build_id, terminate_event): 152 | """ 153 | Performs the build step for the build in the database with the specified 154 | *build_id*. 155 | """ 156 | 157 | logfile = None 158 | logger = None 159 | status = None 160 | 161 | with contextlib.ExitStack() as stack: 162 | try: 163 | try: 164 | # Retrieve the current build information. 165 | with models.session(): 166 | build = Build.get(id=build_id) 167 | app.logger.info('Build {}#{} started.'.format(build.repo.name, build.num)) 168 | 169 | build.status = Build.Status_Building 170 | build.date_started = datetime.now() 171 | 172 | build_path = build.path() 173 | override_path = build.path(Build.Data_OverrideDir) 174 | utils.makedirs(os.path.dirname(build_path)) 175 | logfile = stack.enter_context(open(build.path(build.Data_Log), 'w')) 176 | logger = utils.create_logger(logfile) 177 | 178 | # Prefetch the repository member as it is required in do_build_(). 179 | build.repo 180 | 181 | # Execute the actual build process (must not perform writes to the 182 | # 'build' object as the DB session is over). 183 | if do_build_(build, build_path, override_path, logger, logfile, terminate_event): 184 | status = Build.Status_Success 185 | else: 186 | if terminate_event.is_set(): 187 | status = Build.Status_Stopped 188 | else: 189 | status = Build.Status_Error 190 | 191 | finally: 192 | # Create a ZIP from the build directory. 193 | if os.path.isdir(build_path): 194 | logger.info('[Flux]: Zipping build directory...') 195 | utils.zipdir(build_path, build_path + '.zip') 196 | utils.rmtree(build_path, remove_write_protection=True) 197 | logger.info('[Flux]: Done') 198 | 199 | except BaseException as exc: 200 | with models.session(): 201 | build = Build.get(id=build_id) 202 | build.status = Build.Status_Error 203 | if logger: 204 | logger.exception(exc) 205 | else: 206 | app.logger.exception(exc) 207 | 208 | finally: 209 | with models.session(): 210 | build = Build.get(id=build_id) 211 | if status is not None: 212 | build.status = status 213 | build.date_finished = datetime.now() 214 | 215 | return status == Build.Status_Success 216 | 217 | 218 | def do_build_(build, build_path, override_path, logger, logfile, terminate_event): 219 | logger.info('[Flux]: build {}#{} started'.format(build.repo.name, build.num)) 220 | 221 | # Clone the repository. 222 | if build.repo and os.path.isfile(utils.get_repo_private_key_path(build.repo)): 223 | identity_file = utils.get_repo_private_key_path(build.repo) 224 | else: 225 | identity_file = config.ssh_identity_file 226 | 227 | ssh_command = utils.ssh_command(None, identity_file=identity_file) # Enables batch mode 228 | env = {'GIT_SSH_COMMAND': ' '.join(map(shlex.quote, ssh_command))} 229 | logger.info('[Flux]: GIT_SSH_COMMAND={!r}'.format(env['GIT_SSH_COMMAND'])) 230 | clone_cmd = ['git', 'clone', build.repo.clone_url, build_path, '--recursive'] 231 | res = utils.run(clone_cmd, logger, env=env) 232 | if res != 0: 233 | logger.error('[Flux]: unable to clone repository') 234 | return False 235 | 236 | if terminate_event.is_set(): 237 | logger.info('[Flux]: build stopped') 238 | return False 239 | 240 | if build.ref and build.commit_sha == ("0" * 32): 241 | build_start_point = build.ref 242 | is_ref_build = True 243 | else: 244 | build_start_point = build.commit_sha 245 | is_ref_build = False 246 | 247 | # Checkout the correct build_start_point. 248 | checkout_cmd = ['git', 'checkout', '-q', build_start_point] 249 | res = utils.run(checkout_cmd, logger, cwd=build_path) 250 | if res != 0: 251 | logger.error('[Flux]: failed to checkout {!r}'.format(build_start_point)) 252 | return False 253 | 254 | # If checkout was initiated by Start build, update commit_sha and ref of build 255 | if is_ref_build: 256 | # update commit sha 257 | get_ref_sha_cmd = ['git', 'rev-parse', 'HEAD'] 258 | res_ref_sha, res_ref_sha_stdout = utils.run(get_ref_sha_cmd, logger, cwd=build_path, return_stdout=True) 259 | if res_ref_sha == 0 and res_ref_sha_stdout != None: 260 | with models.session(): 261 | Build.get(id=build.id).commit_sha = res_ref_sha_stdout.strip() 262 | else: 263 | logger.error('[Flux]: failed to read current sha') 264 | return False 265 | # update ref; user could enter just branch name, e.g 'master' 266 | get_ref_cmd = ['git', 'rev-parse', '--symbolic-full-name', build_start_point] 267 | res_ref, res_ref_stdout = utils.run(get_ref_cmd, logger, cwd=build_path, return_stdout=True) 268 | if res_ref == 0 and res_ref_stdout != None and res_ref_stdout.strip() != 'HEAD' and res_ref_stdout.strip() != '': 269 | with models.session(): 270 | Build.get(id=build.id).ref = res_ref_stdout.strip() 271 | elif res_ref_stdout.strip() == '': 272 | # keep going, used ref was probably commit sha 273 | pass 274 | else: 275 | logger.error('[Flux]: failed to read current ref') 276 | return False 277 | 278 | if terminate_event.is_set(): 279 | logger.info('[Flux]: build stopped') 280 | return False 281 | 282 | # Deletes .git folder before build, if is configured so. 283 | if config.git_folder_handling == GitFolderHandling.DELETE_BEFORE_BUILD or config.git_folder_handling == None: 284 | logger.info('[Flux]: removing .git folder before build') 285 | deleteGitFolder(build_path) 286 | 287 | # Copy over overridden files if any 288 | if os.path.exists(override_path): 289 | dir_util.copy_tree(override_path, build_path) 290 | 291 | # Find the build script that we need to execute. 292 | script_fn = None 293 | for fname in config.build_scripts: 294 | script_fn = os.path.join(build_path, fname) 295 | if os.path.isfile(script_fn): 296 | break 297 | script_fn = None 298 | 299 | if not script_fn: 300 | choices = '{' + ','.join(map(str, config.build_scripts)) + '}' 301 | logger.error('[Flux]: no build script found, choices are ' + choices) 302 | return False 303 | 304 | # Make sure the build script is executable. 305 | st = os.stat(script_fn) 306 | os.chmod(script_fn, st.st_mode | stat.S_IEXEC) 307 | 308 | # Execute the script. 309 | logger.info('[Flux]: executing {}'.format(os.path.basename(script_fn))) 310 | logger.info('$ ' + shlex.quote(script_fn)) 311 | popen = subprocess.Popen([script_fn], cwd=build_path, 312 | stdout=logfile, stderr=subprocess.STDOUT, stdin=None) 313 | 314 | # Wait until the process finished or the terminate event is set. 315 | while popen.poll() is None and not terminate_event.is_set(): 316 | time.sleep(0.5) 317 | if terminate_event.is_set(): 318 | try: 319 | popen.terminate() 320 | except OSError as exc: 321 | logger.exception(exc) 322 | logger.error('[Flux]: build stopped. build script terminated') 323 | return False 324 | 325 | # Deletes .git folder after build, if is configured so. 326 | if config.git_folder_handling == GitFolderHandling.DELETE_AFTER_BUILD: 327 | logger.info('[Flux]: removing .git folder after build') 328 | deleteGitFolder(build_path) 329 | 330 | logger.info('[Flux]: exit-code {}'.format(popen.returncode)) 331 | return popen.returncode == 0 332 | -------------------------------------------------------------------------------- /flux/static/flux/fonts/fontello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright (C) 2018 by original authors @ fontello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /flux/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Niklas Rosenstein 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import io 22 | import functools 23 | import hashlib 24 | import hmac 25 | import logging 26 | import os 27 | import re 28 | import shlex 29 | import shutil 30 | import stat 31 | import subprocess 32 | import urllib.parse 33 | import uuid 34 | import werkzeug 35 | import zipfile 36 | 37 | from . import app, config, models 38 | from urllib.parse import urlparse 39 | from flask import request, session, redirect, url_for, Response 40 | from datetime import datetime 41 | from cryptography.hazmat.primitives import serialization 42 | from cryptography.hazmat.primitives.asymmetric import rsa 43 | from cryptography.hazmat.backends import default_backend 44 | 45 | 46 | def get_raise(data, key, expect_type=None): 47 | ''' Helper function to retrieve an element from a JSON data structure. 48 | The *key* must be a string and may contain periods to indicate nesting. 49 | Parts of the key may be a string or integer used for indexing on lists. 50 | If *expect_type* is not None and the retrieved value is not of the 51 | specified type, TypeError is raised. If the key can not be found, 52 | KeyError is raised. ''' 53 | 54 | parts = key.split('.') 55 | resolved = '' 56 | for part in parts: 57 | resolved += part 58 | try: 59 | part = int(part) 60 | except ValueError: 61 | pass 62 | 63 | if isinstance(part, str): 64 | if not isinstance(data, dict): 65 | raise TypeError('expected dictionary to access {!r}'.format(resolved)) 66 | try: 67 | data = data[part] 68 | except KeyError: 69 | raise KeyError(resolved) 70 | elif isinstance(part, int): 71 | if not isinstance(data, list): 72 | raise TypeError('expected list to access {!r}'.format(resolved)) 73 | try: 74 | data = data[part] 75 | except IndexError: 76 | raise KeyError(resolved) 77 | else: 78 | assert False, "unreachable" 79 | 80 | resolved += '.' 81 | 82 | if expect_type is not None and not isinstance(data, expect_type): 83 | raise TypeError('expected {!r} but got {!r} instead for {!r}'.format( 84 | expect_type.__name__, type(data).__name__, key)) 85 | return data 86 | 87 | 88 | def get(data, key, expect_type=None, default=None): 89 | ''' Same as :func:`get_raise`, but returns *default* if the key could 90 | not be found or the datatype doesn't match. ''' 91 | 92 | try: 93 | return get_raise(data, key, expect_type) 94 | except (TypeError, ValueError): 95 | return default 96 | 97 | 98 | def basic_auth(message='Login required'): 99 | ''' Sends a 401 response that enables basic auth. ''' 100 | 101 | headers = {'WWW-Authenticate': 'Basic realm="{}"'.format(message)} 102 | return Response('Please log in.', 401, headers, mimetype='text/plain') 103 | 104 | 105 | def requires_auth(func): 106 | ''' Decorator for view functions that require basic authentication. ''' 107 | 108 | @functools.wraps(func) 109 | def wrapper(*args, **kwargs): 110 | ip = request.remote_addr 111 | token_string = session.get('flux_login_token') 112 | token = models.LoginToken.select(lambda t: t.token == token_string).first() 113 | if not token or token.ip != ip or token.expired(): 114 | if token and token.expired(): 115 | flash("Your login session has expired.") 116 | token.delete() 117 | return redirect(url_for('login')) 118 | 119 | request.login_token = token 120 | request.user = token.user 121 | return func(*args, **kwargs) 122 | 123 | return wrapper 124 | 125 | 126 | def with_io_response(kwarg='stream', stream_type='text', **response_kwargs): 127 | ''' Decorator for View functions that create a :class:`io.StringIO` or 128 | :class:`io.BytesIO` (based on the *stream_type* parameter) and pass it 129 | as *kwarg* to the wrapped function. The contents of the buffer are 130 | sent back to the client. ''' 131 | 132 | if stream_type == 'text': 133 | factory = io.StringIO 134 | elif stream_type == 'bytes': 135 | factory = io.BytesIO 136 | else: 137 | raise ValueError('invalid value for stream_type: {!r}'.format(stream_type)) 138 | 139 | def decorator(func): 140 | @functools.wraps(func) 141 | def wrapper(*args, **kwargs): 142 | if kwarg in kwargs: 143 | raise RuntimeError('keyword argument {!r} already occupied'.format(kwarg)) 144 | kwargs[kwarg] = stream = factory() 145 | status = func(*args, **kwargs) 146 | return Response(stream.getvalue(), status=status, **response_kwargs) 147 | return wrapper 148 | 149 | return decorator 150 | 151 | 152 | def with_logger(kwarg='logger', stream_dest_kwarg='stream', replace=True): 153 | ''' Decorator that creates a new :class:`logging.Logger` object 154 | additionally to or in-place for the *stream* parameter passed to 155 | the wrapped function. This is usually used in combination with 156 | the :func:`with_io_response` decorator. 157 | 158 | Note that exceptions with this decorator will be logged and the 159 | returned status code will be 500 Internal Server Error. ''' 160 | 161 | def decorator(func): 162 | @functools.wraps(func) 163 | def wrapper(*args, **kwargs): 164 | if replace: 165 | stream = kwargs.pop(stream_dest_kwarg) 166 | else: 167 | stream = kwargs[stream_dest_kwarg] 168 | kwargs[kwarg] = logger = create_logger(stream) 169 | try: 170 | return func(*args, **kwargs) 171 | except BaseException as exc: 172 | logger.exception(exc) 173 | return 500 174 | return wrapper 175 | 176 | return decorator 177 | 178 | 179 | def create_logger(stream, name=__name__, fmt=None): 180 | ''' Creates a new :class:`logging.Logger` object with the 181 | specified *name* and *fmt* (defaults to a standard logging 182 | formating including the current time, levelname and message). 183 | 184 | The logger will also output to stderr. ''' 185 | 186 | fmt = fmt or '[%(asctime)-15s - %(levelname)s]: %(message)s' 187 | formatter = logging.Formatter(fmt) 188 | 189 | logger = logging.Logger(name) 190 | handler = logging.StreamHandler(stream) 191 | handler.setFormatter(formatter) 192 | logger.addHandler(handler) 193 | 194 | return logger 195 | 196 | 197 | def stream_file(filename, name=None, mime=None): 198 | def generate(): 199 | with open(filename, 'rb') as fp: 200 | yield from fp 201 | if name is None: 202 | name = os.path.basename(filename) 203 | headers = {} 204 | headers['Content-Type'] = mime or 'application/x-octet-stream' 205 | headers['Content-Length'] = os.stat(filename).st_size 206 | headers['Content-Disposition'] = 'attachment; filename="' + name + '"' 207 | return Response(generate(), 200, headers) 208 | 209 | 210 | def flash(message=None): 211 | if message is None: 212 | return session.pop('flux_flash', None) 213 | else: 214 | session['flux_flash'] = message 215 | 216 | 217 | def make_secret(): 218 | return str(uuid.uuid4()) 219 | 220 | 221 | def hash_pw(pw): 222 | return hashlib.md5(pw.encode('utf8')).hexdigest() 223 | 224 | 225 | def makedirs(path): 226 | ''' Shorthand that creates a directory and stays silent when it 227 | already exists. ''' 228 | 229 | if not os.path.exists(path): 230 | os.makedirs(path) 231 | 232 | 233 | def rmtree(path, remove_write_protection=False): 234 | """ 235 | A wrapper for #shutil.rmtree() that can try to remove write protection 236 | if removing fails, if enabled. 237 | """ 238 | 239 | if remove_write_protection: 240 | def on_rm_error(func, path, exc_info): 241 | os.chmod(path, stat.S_IWRITE) 242 | os.unlink(path) 243 | else: 244 | on_rm_error = None 245 | 246 | shutil.rmtree(path, onerror=on_rm_error) 247 | 248 | 249 | def zipdir(dirname, filename): 250 | dirname = os.path.abspath(dirname) 251 | zipf = zipfile.ZipFile(filename, 'w') 252 | for root, dirs, files in os.walk(dirname): 253 | for fname in files: 254 | arcname = os.path.join(os.path.relpath(root, dirname), fname) 255 | zipf.write(os.path.join(root, fname), arcname) 256 | zipf.close() 257 | 258 | 259 | def secure_filename(filename): 260 | """ 261 | Similar to #werkzeug.secure_filename(), but preserves leading dots in 262 | the filename. 263 | """ 264 | 265 | while True: 266 | filename = filename.lstrip('/').lstrip('\\') 267 | if filename.startswith('..') and filename[2:3] in '/\\': 268 | filename = filename[3:] 269 | elif filename.startswith('.') and filename[1:2] in '/\\': 270 | filename = filename[2:] 271 | else: 272 | break 273 | 274 | has_dot = filename.startswith('.') 275 | filename = werkzeug.secure_filename(filename) 276 | if has_dot: 277 | filename = '.' + filename 278 | return filename 279 | 280 | 281 | def quote(s, for_ninja=False): 282 | """ 283 | Enhanced implementation of #shlex.quote(). 284 | Does not generate single-quotes on Windows. 285 | """ 286 | 287 | if os.name == 'nt' and os.sep == '\\': 288 | s = s.replace('"', '\\"') 289 | if re.search('\s', s) or any(c in s for c in '<>'): 290 | s = '"' + s + '"' 291 | else: 292 | s = shlex.quote(s) 293 | return s 294 | 295 | 296 | def run(command, logger, cwd=None, env=None, shell=False, return_stdout=False, 297 | inherit_env=True): 298 | """ 299 | Run a subprocess with the specified command. The command and output of is 300 | logged to logger. The command will automatically be converted to a string 301 | or list of command arguments based on the *shell* parameter. 302 | 303 | # Parameters 304 | command (str, list): A command-string or list of command arguments. 305 | logger (logging.Logger): A logger that will receive the command output. 306 | cwd (str, None): The current working directory. 307 | env (dict, None): The environment for the subprocess. 308 | shell (bool): If set to #True, execute the command via the system shell. 309 | return_stdout (bool): Return the output of the command (including stderr) 310 | to the caller. The result will be a tuple of (returncode, output). 311 | inherit_env (bool): Inherit the current process' environment. 312 | 313 | # Return 314 | int, tuple of (int, str): The return code, or the returncode and the 315 | output of the command. 316 | """ 317 | 318 | if shell: 319 | if not isinstance(command, str): 320 | command = ' '.join(quote(x) for x in command) 321 | if logger: 322 | logger.info('$ ' + command) 323 | else: 324 | if isinstance(command, str): 325 | command = shlex.split(command) 326 | if logger: 327 | logger.info('$ ' + ' '.join(map(quote, command))) 328 | 329 | if env is None: 330 | env = {} 331 | if inherit_env: 332 | env = {**os.environ, **env} 333 | 334 | popen = subprocess.Popen( 335 | command, cwd=cwd, env=env, shell=shell, stdout=subprocess.PIPE, 336 | stderr=subprocess.STDOUT, stdin=None) 337 | stdout = popen.communicate()[0].decode() 338 | if stdout: 339 | if popen.returncode != 0 and logger: 340 | logger.error('\n' + stdout) 341 | else: 342 | if logger: 343 | logger.info('\n' + stdout) 344 | if return_stdout: 345 | return popen.returncode, stdout 346 | return popen.returncode 347 | 348 | 349 | def ssh_command(url, *args, no_ptty=False, identity_file=None, 350 | verbose=None, options=None): 351 | ''' Helper function to generate an SSH command. If not options are 352 | specified, the default option ``BatchMode=yes`` will be set. ''' 353 | 354 | if options is None: 355 | options = {'BatchMode': 'yes'} 356 | if verbose is None: 357 | verbose = config.ssh_verbose 358 | 359 | command = ['ssh'] 360 | if url is not None: 361 | command.append(url) 362 | command += ['-o{}={}'.format(k, v) for (k, v) in options.items()] 363 | if no_ptty: 364 | command.append('-T') 365 | if identity_file: 366 | command += ['-o', 'IdentitiesOnly=yes'] 367 | # NOTE: Workaround for windows, as the backslashes are gone at the time 368 | # Git tries to use the GIT_SSH_COMMAND. 369 | command += ['-i', identity_file.replace('\\', '/')] 370 | if verbose: 371 | command.append('-v') 372 | if args: 373 | command.append('--') 374 | command += args 375 | return command 376 | 377 | 378 | def strip_url_path(url): 379 | ''' Strips that path part of the specified *url*. ''' 380 | 381 | result = list(urllib.parse.urlparse(url)) 382 | result[2] = '' 383 | return urllib.parse.urlunparse(result) 384 | 385 | 386 | def get_github_signature(secret, payload_data): 387 | ''' Generates the Github HMAC signature from the repository 388 | *secret* and the *payload_data*. The GitHub signature is sent 389 | with the ``X-Hub-Signature`` header. ''' 390 | 391 | return hmac.new(secret.encode('utf8'), payload_data, hashlib.sha1).hexdigest() 392 | 393 | 394 | def get_bitbucket_signature(secret, payload_data): 395 | ''' Generates the Bitbucket HMAC signature from the repository 396 | *secret* and the *payload_data*. The Bitbucket signature is sent 397 | with the ``X-Hub-Signature`` header. ''' 398 | 399 | return hmac.new(secret.encode('utf8'), payload_data, hashlib.sha256).hexdigest() 400 | 401 | 402 | def get_date_diff(date1, date2): 403 | if (not date1) or (not date2): 404 | if (not date1) and date2: 405 | date1 = datetime.now() 406 | else: 407 | return '00:00:00' 408 | diff = (date1 - date2) if date1 > date2 else (date2 - date1) 409 | seconds = int(diff.seconds % 60) 410 | minutes = int(((diff.seconds - seconds) / 60) % 60) 411 | hours = int((diff.seconds - seconds - minutes * 60) / 3600) 412 | return '{:02d}:{:02d}:{:02d}'.format(hours, minutes, seconds) 413 | 414 | 415 | def is_page_active(page, user): 416 | path = request.path 417 | 418 | if page == 'dashboard' and (not path or path == '/'): 419 | return True 420 | elif page == 'repositories' and (path.startswith('/repositories') or path.startswith('/repo') or path.startswith('/edit/repo') or path.startswith('/build') or path.startswith('/overrides')): 421 | return True 422 | elif page == 'users' and (path.startswith('/users') or (path.startswith('/user') and path != ('/user/' + str(user.id)))): 423 | return True 424 | elif page == 'profile' and path == ('/user/' + str(user.id)): 425 | return True 426 | elif page == 'integration' and path == '/integration': 427 | return True 428 | return False 429 | 430 | 431 | def ping_repo(repo_url, repo = None): 432 | if not repo_url or repo_url == '': 433 | return 1 434 | 435 | if repo and os.path.isfile(get_repo_private_key_path(repo)): 436 | identity_file = get_repo_private_key_path(repo) 437 | else: 438 | identity_file = config.ssh_identity_file 439 | 440 | ssh_cmd = ssh_command(None, identity_file=identity_file) 441 | env = {'GIT_SSH_COMMAND': ' '.join(map(quote, ssh_cmd))} 442 | ls_remote = ['git', 'ls-remote', '--exit-code', repo_url] 443 | res = run(ls_remote, app.logger, env=env) 444 | return res 445 | 446 | 447 | def get_customs_path(repo): 448 | return os.path.join(config.customs_dir, repo.name.replace('/', os.sep)) 449 | 450 | 451 | def get_override_path(repo): 452 | return os.path.join(config.override_dir, repo.name.replace('/', os.sep)) 453 | 454 | 455 | def get_override_build_script_path(repo): 456 | return os.path.join(get_override_path(repo), config.build_scripts[0]) 457 | 458 | 459 | def read_override_build_script(repo): 460 | build_script_path = get_override_build_script_path(repo) 461 | if os.path.isfile(build_script_path): 462 | build_script_file = open(build_script_path, mode='r') 463 | build_script = build_script_file.read() 464 | build_script_file.close() 465 | return build_script 466 | return '' 467 | 468 | 469 | def write_override_build_script(repo, build_script): 470 | build_script_path = get_override_build_script_path(repo) 471 | if build_script.strip() == '': 472 | if os.path.isfile(build_script_path): 473 | os.remove(build_script_path) 474 | else: 475 | makedirs(os.path.dirname(build_script_path)) 476 | build_script_file = open(build_script_path, mode='w') 477 | build_script_file.write(build_script.replace('\r', '')) 478 | build_script_file.close() 479 | 480 | 481 | def get_public_key(): 482 | """ 483 | Returns the servers SSH public key. 484 | """ 485 | 486 | # XXX Support all valid options and eventually parse the config file? 487 | filename = config.ssh_identity_file or os.path.expanduser('~/.ssh/id_rsa') 488 | if not filename.endswith('.pub'): 489 | filename += '.pub' 490 | if os.path.isfile(filename): 491 | with open(filename) as fp: 492 | return fp.read() 493 | return None 494 | 495 | 496 | def generate_ssh_keypair(public_key_comment): 497 | """ 498 | Generates new RSA ssh keypair. 499 | 500 | Return: 501 | tuple(str, str): generated private and public keys 502 | """ 503 | 504 | key = rsa.generate_private_key(backend=default_backend(), public_exponent=65537, key_size=4096) 505 | private_key = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode('ascii') 506 | public_key = key.public_key().public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH).decode('ascii') 507 | 508 | if public_key_comment: 509 | public_key += ' ' + public_key_comment 510 | 511 | return private_key, public_key 512 | 513 | 514 | def get_repo_private_key_path(repo): 515 | """ 516 | Returns path of private key for repository from Customs folder. 517 | 518 | Return: 519 | str: path to custom private SSH key 520 | """ 521 | 522 | return os.path.join(get_customs_path(repo), 'id_rsa') 523 | 524 | 525 | def get_repo_public_key_path(repo): 526 | """ 527 | Returns path of public key for repository from Customs folder. 528 | 529 | Return: 530 | str: path to custom public SSH key 531 | """ 532 | 533 | return os.path.join(get_customs_path(repo), 'id_rsa.pub') 534 | -------------------------------------------------------------------------------- /flux/static/flux/css/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: #FFFFFF; 3 | color: #263238; 4 | font-size: 100%; 5 | font-family: 'Open Sans', sans-serif; 6 | letter-spacing: -0.03125rem; 7 | margin: 0; 8 | min-height: 100%; 9 | position: relative; 10 | } 11 | 12 | body { 13 | margin: 0 0 2.75rem 0; 14 | overflow-x: hidden; 15 | } 16 | 17 | a { 18 | color: #0D47A1; 19 | text-decoration: none; 20 | outline: 0; 21 | } 22 | 23 | a:hover { 24 | color: #1565C0; 25 | text-decoration: underline; 26 | } 27 | 28 | button, .btn { 29 | background-color: #FFFFFF; 30 | border: 0.0625rem solid #CFD8DC; 31 | border-radius: 0.25rem; 32 | color: #263238; 33 | cursor: pointer; 34 | display: inline-block; 35 | font-size: .875rem; 36 | line-height: 1rem; 37 | outline: 0; 38 | padding: .5rem .75rem; 39 | text-decoration: none; 40 | } 41 | 42 | button::-moz-focus-inner { 43 | border: 0; 44 | } 45 | 46 | button:hover, .btn:hover { 47 | background-color: #ECEFF1; 48 | border-color: #B0BEC5; 49 | text-decoration: none; 50 | } 51 | 52 | button:active, button:focus, .btn:active, .btn:focus { 53 | background-color: #CFD8DC; 54 | border-color: #90A4AE; 55 | outline: 0; 56 | } 57 | 58 | button .fa, .btn .fa { 59 | margin-right: .25rem; 60 | } 61 | 62 | button.btn-primary, .btn.btn-primary { 63 | background-color: #455A64; 64 | border-color: #37474F; 65 | color: #ECEFF1; 66 | } 67 | 68 | button.btn-primary:hover, .btn.btn-primary:hover { 69 | background-color: #37474F; 70 | border-color: #263238; 71 | } 72 | 73 | button.btn-primary:active, button.btn-primary:focus, .btn.btn-primary:active, .btn.btn-primary:focus { 74 | background-color: #263238; 75 | border-color: #242E33; 76 | } 77 | 78 | button.btn-danger, .btn.btn-danger { 79 | background-color: #D32F2F; 80 | border-color: #C62828; 81 | color: #FFEBEE; 82 | } 83 | 84 | button.btn-danger:hover, .btn.btn-danger:hover { 85 | background-color: #C62828; 86 | border-color: #B71C1C; 87 | } 88 | 89 | button.btn-danger:active, button.btn-danger:focus, .btn.btn-danger:active, .btn.btn-danger:focus { 90 | background-color: #B71C1C; 91 | border-color: #A61919; 92 | } 93 | 94 | h3 { 95 | margin-bottom: .5rem; 96 | } 97 | 98 | input[type="text"], input[type="password"], textarea { 99 | background-color: #FFFFFF; 100 | border: 0.0625rem solid #CFD8DC; 101 | box-sizing: border-box; 102 | display: block; 103 | color: #263238; 104 | font-size: 1rem; 105 | padding: .375rem .5rem; 106 | width: 100%; 107 | } 108 | 109 | input[type="text"]:focus, input[type="password"]:focus, textarea:focus { 110 | border-color: #90A4AE; 111 | outline: 0; 112 | } 113 | 114 | input[type="text"]:disabled, input[type="password"]:disabled, textarea:disabled { 115 | background-color: #ECEFF1; 116 | } 117 | 118 | input[type="checkbox"] { 119 | outline: 0; 120 | } 121 | 122 | .upload-form .block { 123 | position: relative; 124 | z-index: 0; 125 | } 126 | 127 | .upload-form .fa { 128 | color: #CFD8DC; 129 | font-size: 8rem; 130 | left: calc(50% - 4rem); 131 | position: absolute; 132 | top: 1rem; 133 | z-index: -1; 134 | } 135 | 136 | .upload-form input[type=file] { 137 | cursor: pointer; 138 | height: 100%; 139 | left: 0; 140 | opacity: 0; 141 | position: absolute; 142 | top: 0; 143 | width: 100%; 144 | } 145 | 146 | .upload-form span { 147 | display: block; 148 | font-weight: bold; 149 | padding: 5rem 0; 150 | text-align: center; 151 | } 152 | 153 | textarea { 154 | resize: vertical; 155 | } 156 | 157 | ::placeholder { 158 | color: #90A4AE; 159 | } 160 | 161 | pre { 162 | background-color: #ECEFF1; 163 | border: 0.0625rem solid #CFD8DC; 164 | margin: .5rem 0; 165 | overflow: hidden; 166 | padding: .75rem; 167 | white-space: pre-wrap; 168 | word-wrap: break-word; 169 | } 170 | 171 | .form-field { 172 | margin: 0 0 .5rem 0; 173 | } 174 | 175 | dl, .infobox { 176 | color: #455A64; 177 | font-size: .875rem; 178 | } 179 | 180 | dl dt { 181 | float: left; 182 | font-weight: bold; 183 | width: 5rem; 184 | } 185 | 186 | dl dt::after { 187 | content: ':'; 188 | display: inline-block; 189 | } 190 | 191 | dl dd { 192 | margin: 0; 193 | } 194 | 195 | @media (max-width: 991.8px) { 196 | body { 197 | overflow-x: hidden; 198 | } 199 | 200 | header { 201 | position: fixed; 202 | left: 0; 203 | top: 0; 204 | width: 100%; 205 | } 206 | 207 | header nav, .container { 208 | display: block; 209 | width: 100%; 210 | } 211 | 212 | header nav .brand { 213 | display: block; 214 | float: left; 215 | margin-left: 1rem; 216 | } 217 | 218 | header nav ul { 219 | display: none; 220 | clear: both; 221 | margin: 0 0 0.9375rem 0; 222 | padding: 0; 223 | } 224 | 225 | header nav ul li { 226 | display: block; 227 | border-bottom: 0.0625rem solid #37474F; 228 | } 229 | 230 | header nav ul li:last-child { 231 | border-bottom: 0; 232 | } 233 | 234 | header nav ul li a { 235 | font-size: 1rem; 236 | line-height: 1rem; 237 | padding: 0.5rem 1rem; 238 | } 239 | 240 | header nav .collapse-button { 241 | background: none; 242 | border: 0; 243 | display: block; 244 | float: right; 245 | margin-right: 1rem; 246 | } 247 | 248 | #confirm-dialog, #input-dialog { 249 | left: 1rem; 250 | right: 1rem; 251 | } 252 | 253 | main { 254 | margin-top: 3.4375rem; 255 | padding: 0 1.5rem 1.5rem 1.5rem; 256 | overflow: auto; 257 | } 258 | 259 | .block .left-side { 260 | display: block; 261 | margin-bottom: 1rem; 262 | } 263 | 264 | .block .right-side { 265 | display: block; 266 | } 267 | 268 | .block .right-side::after { 269 | clear: both; 270 | content: ''; 271 | display: block; 272 | } 273 | 274 | .block-item { 275 | display: table-cell; 276 | vertical-align: top; 277 | } 278 | 279 | .block-icon { 280 | width: 1.5rem; 281 | font-size: 1.5rem; 282 | padding-right: .25rem; 283 | } 284 | 285 | .block .left-side .block-top-item { 286 | max-width: 21.875rem; 287 | } 288 | 289 | .block .right-side .block-item:first-child { 290 | float: left; 291 | } 292 | 293 | .block .right-side .block-item:last-child { 294 | float: right; 295 | } 296 | 297 | footer .container { 298 | padding: 0 1.5rem; 299 | } 300 | } 301 | 302 | @media (min-width: 992px) { 303 | nav, .container { 304 | width: 900px; 305 | margin: 0 auto; 306 | } 307 | 308 | header nav .brand { 309 | display: inline-block; 310 | float: left; 311 | } 312 | 313 | header nav ul { 314 | display: inline-block; 315 | float: right; 316 | margin-right: -.5rem; 317 | margin: 0; 318 | padding: 0; 319 | } 320 | 321 | header nav ul li { 322 | display: inline-block; 323 | } 324 | 325 | header nav ul li a { 326 | font-size: 1rem; 327 | line-height: 1rem; 328 | padding: 1.1875rem .5rem; 329 | } 330 | 331 | header nav .collapse-button { 332 | display: none; 333 | } 334 | 335 | #confirm-dialog, #input-dialog { 336 | left: calc(50% - 12.5rem); 337 | width: 25rem; 338 | } 339 | 340 | .block::after { 341 | content: ''; 342 | display: block; 343 | clear: both; 344 | } 345 | 346 | .block .left-side { 347 | display: table; 348 | float: left; 349 | } 350 | 351 | .block .right-side { 352 | display: table; 353 | float: right; 354 | } 355 | 356 | .block .block-item { 357 | display: table-cell; 358 | vertical-align: middle; 359 | } 360 | 361 | .block .left-side .block-top-item { 362 | max-width: 21.875rem; 363 | } 364 | 365 | .block-icon { 366 | width: 2rem; 367 | font-size: 2rem; 368 | padding-right: .5rem; 369 | } 370 | 371 | .login-form { 372 | border: 0.0625rem solid #CFD8DC; 373 | margin: 0 auto; 374 | padding: .5rem; 375 | width: 20rem; 376 | } 377 | } 378 | 379 | @media (max-width: 575.8px) { 380 | .block .left-side .block-top-item { 381 | max-width: 10.625rem; 382 | } 383 | } 384 | 385 | /* header */ 386 | header { 387 | background-color: #263238; 388 | color: #ECEFF1; 389 | z-index: 1000; 390 | } 391 | 392 | header nav::after { 393 | content: ''; 394 | display: block; 395 | clear: both; 396 | } 397 | 398 | header nav .brand a { 399 | color: #ECEFF1; 400 | display: inline-block; 401 | font-size: 1.5rem; 402 | font-weight: 700; 403 | line-height: 1.5rem; 404 | padding: 0.9375rem 0; 405 | text-decoration: none; 406 | } 407 | 408 | header nav .brand img { 409 | height: 2.25rem; 410 | width: auto; 411 | margin-bottom: -.5625rem; 412 | margin-right: .125rem; 413 | margin-top: -.375rem; 414 | } 415 | 416 | header nav ul li { 417 | list-style: none; 418 | } 419 | 420 | header nav ul li.active a, header nav ul li.active a:hover { 421 | color: #FF9800; 422 | } 423 | 424 | header nav ul li a { 425 | color: #ECEFF1; 426 | display: block; 427 | text-decoration: none; 428 | } 429 | 430 | header nav ul li a:hover { 431 | background-color: #37474F; 432 | color: #B0BEC5; 433 | text-decoration: none; 434 | } 435 | 436 | header nav .collapse-button { 437 | color: #ECEFF1; 438 | font-size: 1.5rem; 439 | line-height: 1.5rem; 440 | outline: 0; 441 | padding: 0.9375rem 0 0.9375rem 0; 442 | } 443 | 444 | header nav ul li .fa { 445 | margin-right: .25rem; 446 | } 447 | 448 | /* main */ 449 | .blur { 450 | filter: blur(2px); 451 | } 452 | 453 | main { 454 | background-color: #FFFFFF; 455 | margin-bottom: 3.75rem; 456 | } 457 | 458 | #confirm-dialog, #input-dialog { 459 | background-color: #FFFFFF; 460 | border: 0.0625rem solid #CFD8DC; 461 | box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); 462 | display: none; 463 | padding: .5rem; 464 | position: fixed; 465 | top: 4rem; 466 | z-index: 9999; 467 | } 468 | 469 | #confirm-dialog .confirm-icon, #input-dialog .input-icon { 470 | color: #FF9800; 471 | float: left; 472 | font-size: 2rem; 473 | margin-right: .3125rem; 474 | } 475 | 476 | #input-dialog .input-icon { 477 | color: #33AAFF; 478 | } 479 | 480 | #confirm-dialog .confirm-message, #input-dialog .input-message { 481 | font-weight: 700; 482 | padding: .5rem 0 1rem 0; 483 | } 484 | 485 | #confirm-dialog .confirm-buttons, #input-dialog .input-buttons { 486 | border-top: 0.0625rem solid #CFD8DC; 487 | padding-top: .5rem; 488 | text-align: right; 489 | } 490 | 491 | #input-dialog .input-text { 492 | margin: 1rem 0; 493 | } 494 | 495 | #confirm-overlay, #input-overlay { 496 | background-color: rgba(38, 50, 56, 0.3); 497 | bottom: 0; 498 | display: none; 499 | left: 0; 500 | position: fixed; 501 | right: 0; 502 | top: 0; 503 | z-index: 9000; 504 | } 505 | 506 | #toolbar { 507 | margin: 0; 508 | padding: 0; 509 | float: right; 510 | } 511 | 512 | #toolbar li { 513 | display: inline-block; 514 | list-style: none; 515 | margin: 0; 516 | position: relative; 517 | } 518 | 519 | #toolbar li a { 520 | display: block; 521 | padding: .25rem .5rem; 522 | } 523 | 524 | #toolbar li a:hover { 525 | background-color: #ECEFF1; 526 | display: block; 527 | text-decoration: none; 528 | } 529 | 530 | #toolbar li:last-child { 531 | margin-right: 0; 532 | } 533 | 534 | #toolbar li .fa { 535 | color: #455A64; 536 | margin-right: .25rem; 537 | min-width: 0.6875rem; 538 | } 539 | 540 | #toolbar li .fa.fa-clone { 541 | font-size: .8125rem; 542 | } 543 | 544 | #toolbar li .dropdown-menu { 545 | background: #FFFFFF; 546 | border: 0.0625rem solid #CFD8DC; 547 | box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); 548 | display: none; 549 | margin-top: .5rem; 550 | min-width: 12rem; 551 | padding: .25rem 0; 552 | position: absolute; 553 | right: 0; 554 | z-index: 10; 555 | } 556 | 557 | #toolbar li .dropdown-menu a { 558 | display: block; 559 | font-size: .9375rem; 560 | padding: .25rem .5rem; 561 | } 562 | 563 | #toolbar li .dropdown-menu a:hover { 564 | background-color: #ECEFF1; 565 | display: block; 566 | text-decoration: none; 567 | } 568 | 569 | #toolbar li .dropdown-menu::before, #toolbar li .dropdown-menu::after { 570 | border: .5rem solid transparent; 571 | bottom: 100%; 572 | content: ""; 573 | display: block; 574 | height: 0; 575 | position: absolute; 576 | right: .5rem; 577 | width: 0; 578 | } 579 | 580 | #toolbar li .dropdown-menu::before { 581 | border-bottom-color: #CFD8DC; 582 | } 583 | 584 | #toolbar li .dropdown-menu::after { 585 | border-bottom-color: #FFFFFF; 586 | margin-bottom: -1px; 587 | } 588 | 589 | .block-link, .block-link:hover, .block a, .block a:hover { 590 | color: #263238; 591 | text-decoration: none; 592 | } 593 | 594 | .block { 595 | border: 0.0625rem solid #CFD8DC; 596 | display: block; 597 | margin-bottom: .5rem; 598 | padding: .5rem; 599 | font-size: 1rem; 600 | line-height: 1rem; 601 | } 602 | 603 | a .block:hover, .block-link .block:hover { 604 | background-color: #ECEFF1; 605 | } 606 | 607 | .block-icon .fa.fa-check-circle { 608 | color: #4CAF50; 609 | } 610 | 611 | .block-icon .fa.fa-times-circle { 612 | color: #F44336; 613 | } 614 | 615 | .block-icon .fa.fa-stop-circle { 616 | color: #FF9800; 617 | } 618 | 619 | .block-icon .fa.fa-refresh { 620 | color: #2196F3; 621 | -webkit-animation: spin 2s linear infinite; 622 | -moz-animation: spin 2s linear infinite; 623 | animation: spin 2s linear infinite; 624 | } 625 | 626 | .block-icon .fa.fa-clock-o { 627 | color: #263238; 628 | } 629 | 630 | .block-icon .fa.fa-share-alt, .block-icon .fa.fa-user, .block-icon .fa.fa-file-o, .block-icon .fa.fa-folder-o { 631 | color: #B0BEC5; 632 | } 633 | 634 | .block .block-build-number { 635 | width: 4rem; 636 | } 637 | 638 | .block .left-side .block-top-item { 639 | display: block; 640 | font-weight: 700; 641 | margin-bottom: .5rem; 642 | text-overflow: ellipsis; 643 | white-space: nowrap; 644 | } 645 | 646 | .block .left-side .block-bottom-item.additional { 647 | padding-left: 1rem; 648 | } 649 | 650 | .block .right-side .block-top-item { 651 | display: block; 652 | font-size: 0.875rem; 653 | margin-bottom: .5rem; 654 | } 655 | 656 | .block-bottom-item { 657 | display: block; 658 | font-size: 0.875rem; 659 | } 660 | 661 | .block .right-side .block-item:first-child { 662 | width: 12rem; 663 | } 664 | 665 | .block .block-fa .fa { 666 | margin-right: .25rem; 667 | } 668 | 669 | .btn-newer { 670 | float: left; 671 | } 672 | 673 | .btn-newer .fa { 674 | margin: 0 .25rem 0 0; 675 | } 676 | 677 | .btn-older { 678 | float: right; 679 | } 680 | 681 | .btn-older .fa { 682 | margin: 0 0 0 .25rem; 683 | } 684 | 685 | .block-buttons { 686 | text-align: right; 687 | font-size: 0; 688 | } 689 | 690 | .float-right { 691 | float: right; 692 | } 693 | 694 | .float-left { 695 | float: left; 696 | } 697 | 698 | .block-buttons .btn:first-child { 699 | margin-left: 0; 700 | } 701 | 702 | .block-buttons .btn { 703 | margin-left: 0.25rem; 704 | } 705 | 706 | .block-buttons .btn .fa { 707 | margin-right: 0; 708 | } 709 | 710 | .paging::after { 711 | content: ""; 712 | display: block; 713 | clear: both; 714 | } 715 | 716 | .messages { 717 | background-color: #ECEFF1; 718 | border: 0.0625rem solid #B0BEC5; 719 | clear: both; 720 | display: block; 721 | margin-bottom: 1rem; 722 | padding: .5rem; 723 | } 724 | 725 | .messages::after { 726 | content: ''; 727 | display: block; 728 | clear: both; 729 | } 730 | 731 | .messages .icon { 732 | float: left; 733 | } 734 | 735 | .messages .close { 736 | float: right; 737 | font-size: .8725rem; 738 | opacity: 0.3; 739 | } 740 | 741 | .messages .close:hover { 742 | opacity: 0.6; 743 | } 744 | 745 | .messages div { 746 | display: block; 747 | margin: 0 1.5rem; 748 | } 749 | 750 | .messages.error { 751 | background-color: #FFEBEE; 752 | border-color: #EF9A9A; 753 | color: #C62828; 754 | } 755 | 756 | .messages.error a { 757 | color: #C62828; 758 | } 759 | 760 | .messages.info { 761 | background-color: #E3F2FD; 762 | border-color: #90CAF9; 763 | color: #1565C0; 764 | } 765 | 766 | .messages.info a { 767 | color: #1565C0; 768 | } 769 | 770 | .messages.success { 771 | background-color: #E8F5E9; 772 | border-color: #A5D6A7; 773 | color: #2E7D32; 774 | } 775 | 776 | .messages.success a { 777 | color: #2E7D32; 778 | } 779 | 780 | .login-form .login-header { 781 | border-bottom: 0.0625rem solid #CFD8DC; 782 | display: block; 783 | font-weight: 700; 784 | margin-bottom: .75rem; 785 | padding-bottom: .75rem; 786 | } 787 | 788 | .login-session { 789 | padding: 0.5em; 790 | border: 1px solid grey; 791 | border-bottom: none; 792 | background-color: #e4f4ff; 793 | } 794 | .login-session:last-child { 795 | border-bottom: 1px solid grey; 796 | } 797 | .login-session.expired { 798 | background-color: #ddd; 799 | color: gray; 800 | } 801 | .login-session-ip { 802 | font-weight: bold; 803 | } 804 | .login-session-date { 805 | font-size: 80%; 806 | } 807 | 808 | .field { 809 | margin: .5rem 0; 810 | } 811 | 812 | .field.required label::after { 813 | color: #C62828; 814 | content: '*'; 815 | display: inline-block; 816 | font-size: .875rem; 817 | margin-left: .25rem; 818 | } 819 | 820 | .field label { 821 | display: block; 822 | font-weight: 700; 823 | margin-bottom: .25rem; 824 | } 825 | 826 | .field label.checkbox { 827 | font-weight: normal; 828 | } 829 | 830 | .field .infobox { 831 | margin-bottom: .5rem; 832 | } 833 | 834 | .fa.fa-wait-spin { 835 | -webkit-animation: spin 1s linear infinite; 836 | -moz-animation: spin 1s linear infinite; 837 | animation: spin 1s linear infinite; 838 | } 839 | 840 | #repo_check_result { 841 | display: block; 842 | margin-left: .25rem; 843 | } 844 | 845 | #repo_check_result .fa { 846 | margin-right: .25rem; 847 | } 848 | 849 | #repo_check_result .fa.fa-check { 850 | color: #4CAF50; 851 | } 852 | 853 | #repo_check_result .fa.fa-times { 854 | color: #F44336; 855 | } 856 | 857 | #repo_check_result .fa.fa-wait-spin { 858 | color: #2196F3; 859 | } 860 | 861 | #repo_build_script { 862 | min-height: 10rem; 863 | } 864 | 865 | #override_content { 866 | min-height: 20rem; 867 | } 868 | 869 | .toggable { 870 | display: none; 871 | } 872 | 873 | /* footer */ 874 | footer { 875 | background-color: #ECEFF1; 876 | bottom: 0; 877 | color: #455A64; 878 | font-size: 0.75rem; 879 | left: 0; 880 | line-height: 0.75rem; 881 | overflow: hidden; 882 | padding: 1rem 0; 883 | position: absolute; 884 | width: 100%; 885 | z-index: 1000; 886 | } 887 | 888 | @-moz-keyframes spin { 889 | 100% { 890 | -moz-transform: rotate(360deg); 891 | } 892 | } 893 | 894 | @-webkit-keyframes spin { 895 | 100% { 896 | -webkit-transform: rotate(360deg); 897 | } 898 | } 899 | 900 | @keyframes spin { 901 | 100% { 902 | -webkit-transform: rotate(360deg); 903 | transform:rotate(360deg); 904 | } 905 | } 906 | 907 | @font-face { 908 | font-family: 'fontello'; 909 | src: url('../fonts/fontello.eot?5736516'); 910 | src: url('../fonts/fontello.eot?5736516#iefix') format('embedded-opentype'), 911 | url('../fonts/fontello.woff2?5736516') format('woff2'), 912 | url('../fonts/fontello.woff?5736516') format('woff'), 913 | url('../fonts/fontello.ttf?5736516') format('truetype'), 914 | url('../fonts/fontello.svg?5736516#fontello') format('svg'); 915 | font-weight: normal; 916 | font-style: normal; 917 | } 918 | .fa { 919 | display: inline-block; 920 | font: normal normal normal .875rem/1 fontello; 921 | font-size: inherit; 922 | text-rendering: auto; 923 | -webkit-font-smoothing: antialiased; 924 | -moz-osx-font-smoothing: grayscale; 925 | } 926 | 927 | .fa-check-circle:before { content: '\e800'; } 928 | .fa-cog:before { content: '\e801'; } 929 | .fa-exclamation-triangle:before { content: '\e802'; } 930 | .fa-clock-o:before { content: '\e803'; } 931 | .fa-refresh:before { content: '\e804'; } 932 | .fa-info-circle:before { content: '\e805'; } 933 | .fa-sign-out:before { content: '\e806'; } 934 | .fa-chevron-left:before { content: '\e807'; } 935 | .fa-download:before { content: '\e808'; } 936 | .fa-plus:before { content: '\e809'; } 937 | .fa-pencil:before { content: '\e80a'; } 938 | .fa-tag:before { content: '\e80b'; } 939 | .fa-times-circle:before { content: '\e80c'; } 940 | .fa-check:before { content: '\e80d'; } 941 | .fa-times:before { content: '\e80e'; } 942 | .fa-question-circle:before { content: '\e80f'; } 943 | .fa-flag:before { content: '\e810'; } 944 | .fa-user:before { content: '\e811'; } 945 | .fa-chevron-right:before { content: '\e812'; } 946 | .fa-bars:before { content: '\f0c9'; } 947 | .fa-code-fork:before { content: '\f126'; } 948 | .fa-calendar-o:before { content: '\f133'; } 949 | .fa-share-alt:before { content: '\f1e0'; } 950 | .fa-trash:before { content: '\f1f8'; } 951 | .fa-calendar-check-o:before { content: '\f274'; } 952 | .fa-stop-circle:before { content: '\f28d'; } 953 | .fa-stop-circle-o:before { content: '\f28e'; } 954 | .fa-wait-spin:before { content: '\e834'; } 955 | .fa-folder-o:before { content: '\f114'; } 956 | .fa-file-o:before { content: '\e813'; } 957 | .fa-upload:before { content: '\e814'; } 958 | .fa-clone:before { content: '\f24d'; } 959 | .fa-key:before { content: '\e815'; } -------------------------------------------------------------------------------- /flux/views.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Niklas Rosenstein 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from flux import app, config, file_utils, models, utils 22 | from flux.build import enqueue, terminate_build 23 | from flux.models import User, LoginToken, Repository, Build, get_target_for, select, desc 24 | from flux.utils import secure_filename 25 | from flask import request, session, redirect, url_for, render_template, abort 26 | from datetime import datetime 27 | 28 | import json 29 | import os 30 | import uuid 31 | 32 | API_GOGS = 'gogs' 33 | API_GITHUB = 'github' 34 | API_GITEA = 'gitea' 35 | API_GITBUCKET = 'gitbucket' 36 | API_BITBUCKET = 'bitbucket' 37 | API_BITBUCKET_CLOUD = 'bitbucket-cloud' 38 | API_GITLAB = 'gitlab' 39 | API_BARE = 'bare' 40 | 41 | @app.route('/hook/push', methods=['POST']) 42 | @utils.with_io_response(mimetype='text/plain') 43 | @utils.with_logger() 44 | @models.session 45 | def hook_push(logger): 46 | ''' PUSH event webhook. The URL parameter ``api`` must be specified 47 | for Flux to expect the correct JSON payload format. Supported values 48 | for ``api`` are 49 | 50 | * ``gogs`` 51 | * ``github`` 52 | * ``gitea`` 53 | * ``gitbucket`` 54 | * ``bitbucket`` 55 | * ``bitbucket-cloud`` 56 | * ``gitlab`` 57 | * ``bare`` 58 | 59 | If no or an invalid value is specified for this parameter, a 400 60 | Invalid Request response is generator. ''' 61 | 62 | api = request.args.get('api') 63 | if api not in (API_GOGS, API_GITHUB, API_GITEA, API_GITBUCKET, API_BITBUCKET, API_BITBUCKET_CLOUD, API_GITLAB, API_BARE): 64 | logger.error('invalid `api` URL parameter: {!r}'.format(api)) 65 | return 400 66 | 67 | logger.info('PUSH event received. Processing JSON payload.') 68 | try: 69 | # XXX Determine encoding from Request Headers, if possible. 70 | data = json.loads(request.data.decode('utf8')) 71 | except (UnicodeDecodeError, ValueError) as exc: 72 | logger.error('Invalid JSON data received: {}'.format(exc)) 73 | return 400 74 | 75 | if api == API_GOGS: 76 | owner = utils.get(data, 'repository.owner.username', str) 77 | name = utils.get(data, 'repository.name', str) 78 | ref = utils.get(data, 'ref', str) 79 | commit = utils.get(data, 'after', str) 80 | secret = utils.get(data, 'secret', str) 81 | get_repo_secret = lambda r: r.secret 82 | elif api == API_GITHUB: 83 | event = request.headers.get('X-Github-Event') 84 | if event != 'push': 85 | logger.error("Payload rejected (expected 'push' event, got {!r})".format(event)) 86 | return 400 87 | owner = utils.get(data, 'repository.owner.name', str) 88 | name = utils.get(data, 'repository.name', str) 89 | ref = utils.get(data, 'ref', str) 90 | commit = utils.get(data, 'after', str) 91 | secret = request.headers.get('X-Hub-Signature', '').replace('sha1=', '') 92 | get_repo_secret = lambda r: utils.get_github_signature(r.secret, request.data) 93 | elif api == API_GITEA: 94 | event = request.headers.get('X-Gitea-Event') 95 | if event != 'push': 96 | logger.error("Payload rejected (expected 'push' event, got {!r})".format(event)) 97 | return 400 98 | owner = utils.get(data, 'repository.owner.username', str) 99 | name = utils.get(data, 'repository.name', str) 100 | ref = utils.get(data, 'ref', str) 101 | commit = utils.get(data, 'after', str) 102 | secret = utils.get(data, 'secret', str) 103 | get_repo_secret = lambda r: r.secret 104 | elif api == API_GITBUCKET: 105 | event = request.headers.get('X-Github-Event') 106 | if event != 'push': 107 | logger.error("Payload rejected (expected 'push' event, got {!r})".format(event)) 108 | return 400 109 | owner = utils.get(data, 'repository.owner.login', str) 110 | name = utils.get(data, 'repository.name', str) 111 | ref = utils.get(data, 'ref', str) 112 | commit = utils.get(data, 'after', str) 113 | secret = request.headers.get('X-Hub-Signature', '').replace('sha1=', '') 114 | if secret: 115 | get_repo_secret = lambda r: utils.get_github_signature(r.secret, request.data) 116 | else: 117 | get_repo_secret = lambda r: r.secret 118 | elif api == API_BITBUCKET: 119 | event = request.headers.get('X-Event-Key') 120 | if event != 'repo:refs_changed': 121 | logger.error("Payload rejected (expected 'repo:refs_changed' event, got {!r})".format(event)) 122 | return 400 123 | owner = utils.get(data, 'repository.project.name', str) 124 | name = utils.get(data, 'repository.name', str) 125 | ref = utils.get(data, 'changes.0.refId', str) 126 | commit = utils.get(data, 'changes.0.toHash', str) 127 | secret = request.headers.get('X-Hub-Signature', '').replace('sha256=', '') 128 | if secret: 129 | get_repo_secret = lambda r: utils.get_bitbucket_signature(r.secret, request.data) 130 | else: 131 | get_repo_secret = lambda r: r.secret 132 | elif api == API_BITBUCKET_CLOUD: 133 | event = request.headers.get('X-Event-Key') 134 | if event != 'repo:push': 135 | logger.error("Payload rejected (expected 'repo:push' event, got {!r})".format(event)) 136 | return 400 137 | owner = utils.get(data, 'repository.project.project', str) 138 | name = utils.get(data, 'repository.name', str) 139 | 140 | ref_type = utils.get(data, 'push.changes.0.new.type', str) 141 | ref_name = utils.get(data, 'push.changes.0.new.name', str) 142 | ref = "refs/" + ("heads/" if ref_type == "branch" else "tags/") + ref_name 143 | 144 | commit = utils.get(data, 'push.changes.0.new.target.hash', str) 145 | secret = None 146 | get_repo_secret = lambda r: r.secret 147 | elif api == API_GITLAB: 148 | event = utils.get(data, 'object_kind', str) 149 | if event != 'push' and event != 'tag_push': 150 | logger.error("Payload rejected (expected 'push' or 'tag_push' event, got {!r})".format(event)) 151 | return 400 152 | owner = utils.get(data, 'project.namespace', str) 153 | name = utils.get(data, 'project.name', str) 154 | ref = utils.get(data, 'ref', str) 155 | commit = utils.get(data, 'checkout_sha', str) 156 | secret = request.headers.get('X-Gitlab-Token') 157 | get_repo_secret = lambda r: r.secret 158 | elif api == API_BARE: 159 | owner = utils.get(data, 'owner', str) 160 | name = utils.get(data, 'name', str) 161 | ref = utils.get(data, 'ref', str) 162 | commit = utils.get(data, 'commit', str) 163 | secret = utils.get(data, 'secret', str) 164 | get_repo_secret = lambda r: r.secret 165 | else: 166 | assert False, "unreachable" 167 | 168 | if not name: 169 | logger.error('invalid JSON: no repository name received') 170 | return 400 171 | if not owner: 172 | logger.error('invalid JSON: no repository owner received') 173 | return 400 174 | if not ref: 175 | logger.error('invalid JSON: no Git ref received') 176 | return 400 177 | if not commit: 178 | logger.error('invalid JSON: no commit SHA received') 179 | return 400 180 | if len(commit) != 40: 181 | logger.error('invalid JSON: commit SHA has invalid length') 182 | return 400 183 | if secret == None: 184 | secret = '' 185 | 186 | name = owner + '/' + name 187 | 188 | repo = Repository.get(name=name) 189 | if not repo: 190 | logger.error('PUSH event rejected (unknown repository)') 191 | return 400 192 | if get_repo_secret(repo) != secret: 193 | logger.error('PUSH event rejected (invalid secret)') 194 | return 400 195 | if not repo.check_accept_ref(ref): 196 | logger.info('Git ref {!r} not whitelisted. No build dispatched'.format(ref)) 197 | return 200 198 | 199 | build = Build( 200 | repo=repo, 201 | commit_sha=commit, 202 | num=repo.build_count, 203 | ref=ref, 204 | status=Build.Status_Queued, 205 | date_queued=datetime.now(), 206 | date_started=None, 207 | date_finished=None) 208 | repo.build_count += 1 209 | 210 | models.commit() 211 | enqueue(build) 212 | logger.info('Build #{} for repository {} queued'.format(build.num, repo.name)) 213 | logger.info(utils.strip_url_path(config.app_url) + build.url()) 214 | return 200 215 | 216 | 217 | @app.route('/') 218 | @models.session 219 | @utils.requires_auth 220 | def dashboard(): 221 | context = {} 222 | context['builds'] = select(x for x in Build).order_by(desc(Build.date_queued)).limit(10) 223 | context['user'] = request.user 224 | return render_template('dashboard.html', **context) 225 | 226 | 227 | @app.route('/repositories') 228 | @models.session 229 | @utils.requires_auth 230 | def repositories(): 231 | repositories = select(x for x in Repository).order_by(Repository.name) 232 | return render_template('repositories.html', user=request.user, repositories=repositories) 233 | 234 | 235 | @app.route('/users') 236 | @models.session 237 | @utils.requires_auth 238 | def users(): 239 | if not request.user.can_manage: 240 | return abort(403) 241 | users = select(x for x in User) 242 | return render_template('users.html', user=request.user, users=users) 243 | 244 | 245 | @app.route('/integration') 246 | @models.session 247 | @utils.requires_auth 248 | def integration(): 249 | if not request.user.can_manage: 250 | return abort(403) 251 | return render_template('integration.html', user=request.user, public_key=utils.get_public_key()) 252 | 253 | 254 | @app.route('/login', methods=['GET', 'POST']) 255 | @models.session 256 | def login(): 257 | errors = [] 258 | if request.method == 'POST': 259 | user_name = request.form['user_name'] 260 | user_password = request.form['user_password'] 261 | if user_name and user_password: 262 | user = User.get(name=user_name, passhash=utils.hash_pw(user_password)) 263 | if user: 264 | token = LoginToken.create(request.remote_addr, user) 265 | session['flux_login_token'] = token.token 266 | return redirect(url_for('dashboard')) 267 | errors.append('Username or password invalid.') 268 | return render_template('login.html', errors=errors) 269 | 270 | 271 | @app.route('/logout') 272 | @models.session 273 | @utils.requires_auth 274 | def logout(): 275 | if request.login_token: 276 | request.login_token.delete() 277 | session.pop('flux_login_token') 278 | return redirect(url_for('dashboard')) 279 | 280 | 281 | @app.route('/repo/') 282 | @models.session 283 | @utils.requires_auth 284 | def view_repo(path): 285 | repo = get_target_for(path) 286 | if not isinstance(repo, Repository): 287 | return abort(404) 288 | 289 | context = {} 290 | page_size = 10 291 | 292 | try: 293 | context['page_number'] = int(request.args.get('page', 1)) 294 | except ValueError: 295 | context['page_number'] = 1 296 | 297 | page_from = (context['page_number'] - 1) * page_size 298 | page_to = page_from + page_size 299 | 300 | context['next_page'] = None if context['page_number'] <= 1 else context['page_number'] - 1 301 | context['previous_page'] = None if len(repo.builds) <= page_to else context['page_number'] + 1 302 | context['builds'] = repo.builds.select().order_by(desc(Build.date_queued))[page_from:page_to] 303 | return render_template('view_repo.html', user=request.user, repo=repo, **context) 304 | 305 | 306 | @app.route('/repo/generate-keypair/') 307 | @models.session 308 | @utils.requires_auth 309 | def generate_keypair(path): 310 | if not request.user.can_manage: 311 | return abort(403) 312 | 313 | repo = get_target_for(path) 314 | if not isinstance(repo, Repository): 315 | return abort(404) 316 | 317 | session['errors'] = [] 318 | 319 | utils.makedirs(utils.get_customs_path(repo)) 320 | 321 | try: 322 | private_key, public_key = utils.generate_ssh_keypair(repo.name + '@FluxCI') 323 | private_key_path = utils.get_repo_private_key_path(repo) 324 | public_key_path = utils.get_repo_public_key_path(repo) 325 | 326 | try: 327 | file_utils.create_file_path(private_key_path) 328 | file_utils.create_file_path(public_key_path) 329 | 330 | file_utils.write_file(private_key_path, private_key) 331 | file_utils.write_file(public_key_path, public_key) 332 | 333 | try: 334 | os.chmod(private_key_path, 0o600) 335 | utils.flash('SSH keypair generated.') 336 | except BaseException as exc: 337 | app.logger.info(exc) 338 | session['errors'].append('Could not set permissions to newly generated private key.') 339 | except BaseException as exc: 340 | app.logger.info(exc) 341 | session['errors'].append('Could not save generated SSH keypair.') 342 | except BaseException as exc: 343 | app.logger.info(exc) 344 | session['errors'].append('Could not generate new SSH keypair.') 345 | 346 | return redirect(url_for('edit_repo', repo_id = repo.id)) 347 | 348 | 349 | @app.route('/repo/remove-keypair/') 350 | @models.session 351 | @utils.requires_auth 352 | def remove_keypair(path): 353 | if not request.user.can_manage: 354 | return abort(403) 355 | 356 | repo = get_target_for(path) 357 | if not isinstance(repo, Repository): 358 | return abort(404) 359 | 360 | session['errors'] = [] 361 | 362 | private_key_path = utils.get_repo_private_key_path(repo) 363 | public_key_path = utils.get_repo_public_key_path(repo) 364 | 365 | try: 366 | file_utils.delete(private_key_path) 367 | file_utils.delete(public_key_path) 368 | utils.flash('SSH keypair removed.') 369 | except BaseException as exc: 370 | app.logger.info(exc) 371 | session['errors'].append('Could not remove SSH keypair.') 372 | 373 | return redirect(url_for('edit_repo', repo_id = repo.id)) 374 | 375 | 376 | @app.route('/build/') 377 | @models.session 378 | @utils.requires_auth 379 | def view_build(path): 380 | build = get_target_for(path) 381 | if not isinstance(build, Build): 382 | return abort(404) 383 | 384 | restart = request.args.get('restart', '').strip().lower() == 'true' 385 | if restart: 386 | if build.status != Build.Status_Building: 387 | build.delete_build() 388 | build.status = Build.Status_Queued 389 | build.date_started = None 390 | build.date_finished = None 391 | models.commit() 392 | enqueue(build) 393 | return redirect(build.url()) 394 | 395 | stop = request.args.get('stop', '').strip().lower() == 'true' 396 | if stop: 397 | if build.status == Build.Status_Queued: 398 | build.status = Build.Status_Stopped 399 | elif build.status == Build.Status_Building: 400 | terminate_build(build) 401 | return redirect(build.url()) 402 | 403 | return render_template('view_build.html', user=request.user, build=build) 404 | 405 | 406 | @app.route('/edit/repo', methods=['GET', 'POST'], defaults={'repo_id': None}) 407 | @app.route('/edit/repo/', methods=['GET', 'POST']) 408 | @models.session 409 | @utils.requires_auth 410 | def edit_repo(repo_id): 411 | if not request.user.can_manage: 412 | return abort(403) 413 | if repo_id is not None: 414 | repo = Repository.get(id=repo_id) 415 | else: 416 | repo = None 417 | 418 | errors = [] 419 | 420 | context = {} 421 | if repo and repo.name: 422 | if os.path.isfile(utils.get_repo_public_key_path(repo)): 423 | try: 424 | context['public_key'] = file_utils.read_file(utils.get_repo_public_key_path(repo)) 425 | except BaseException as exc: 426 | app.logger.info(exc) 427 | errors.append('Could not read public key for this repository.') 428 | 429 | if request.method == 'POST': 430 | secret = request.form.get('repo_secret', '') 431 | clone_url = request.form.get('repo_clone_url', '') 432 | repo_name = request.form.get('repo_name', '').strip() 433 | ref_whitelist = request.form.get('repo_ref_whitelist', '') 434 | build_script = request.form.get('repo_build_script', '') 435 | if len(repo_name) < 3 or repo_name.count('/') != 1: 436 | errors.append('Invalid repository name. Format must be owner/repo') 437 | if not clone_url: 438 | errors.append('No clone URL specified') 439 | other = Repository.get(name=repo_name) 440 | if (other and not repo) or (other and other.id != repo.id): 441 | errors.append('Repository {!r} already exists'.format(repo_name)) 442 | if not errors: 443 | if not repo: 444 | repo = Repository( 445 | name=repo_name, 446 | clone_url=clone_url, 447 | secret=secret, 448 | build_count=0, 449 | ref_whitelist=ref_whitelist) 450 | else: 451 | repo.name = repo_name 452 | repo.clone_url = clone_url 453 | repo.secret = secret 454 | repo.ref_whitelist = ref_whitelist 455 | try: 456 | utils.write_override_build_script(repo, build_script) 457 | except BaseException as exc: 458 | app.logger.info(exc) 459 | errors.append('Could not make change on build script') 460 | if not errors: 461 | return redirect(repo.url()) 462 | 463 | return render_template('edit_repo.html', user=request.user, repo=repo, errors=errors, **context) 464 | 465 | 466 | @app.route('/user/new', defaults={'user_id': None}, methods=['GET', 'POST']) 467 | @app.route('/user/', methods=['GET', 'POST']) 468 | @models.session 469 | @utils.requires_auth 470 | def edit_user(user_id): 471 | cuser = None 472 | if user_id is not None: 473 | cuser = User.get(id=user_id) 474 | if not cuser: 475 | return abort(404) 476 | if cuser.id != request.user.id and not request.user.can_manage: 477 | return abort(403) 478 | elif not request.user.can_manage: 479 | return abort(403) 480 | 481 | errors = [] 482 | if request.method == 'POST': 483 | if not cuser and not request.user.can_manage: 484 | return abort(403) 485 | 486 | user_name = request.form.get('user_name') 487 | password = request.form.get('user_password') 488 | can_manage = request.form.get('user_can_manage') == 'on' 489 | can_view_buildlogs = request.form.get('user_can_view_buildlogs') == 'on' 490 | can_download_artifacts = request.form.get('user_can_download_artifacts') == 'on' 491 | 492 | if not cuser: # Create a new user 493 | assert request.user.can_manage 494 | other = User.get(name=user_name) 495 | if other: 496 | errors.append('User {!r} already exists'.format(user_name)) 497 | elif len(user_name) == 0: 498 | errors.append('Username is empty') 499 | elif len(password) == 0: 500 | errors.append('Password is empty') 501 | else: 502 | cuser = User(name=user_name, passhash=utils.hash_pw(password), 503 | can_manage=can_manage, can_view_buildlogs=can_view_buildlogs, 504 | can_download_artifacts=can_download_artifacts) 505 | else: # Update user settings 506 | if password: 507 | cuser.passhash = utils.hash_pw(password) 508 | # The user can only update privileges if he has managing privileges. 509 | if request.user.can_manage: 510 | cuser.can_manage = can_manage 511 | cuser.can_view_buildlogs = can_view_buildlogs 512 | cuser.can_download_artifacts = can_download_artifacts 513 | if not errors: 514 | return redirect(cuser.url()) 515 | models.rollback() 516 | 517 | return render_template('edit_user.html', user=request.user, cuser=cuser, 518 | errors=errors) 519 | 520 | 521 | @app.route('/download//') 522 | @models.session 523 | @utils.requires_auth 524 | def download(build_id, data): 525 | if data not in (Build.Data_Artifact, Build.Data_Log): 526 | return abort(404) 527 | build = Build.get(id=build_id) 528 | if not build: 529 | return abort(404) 530 | if not build.check_download_permission(data, request.user): 531 | return abort(403) 532 | if not build.exists(data): 533 | return abort(404) 534 | mime = 'application/zip' if data == Build.Data_Artifact else 'text/plain' 535 | download_name = "{}-{}.{}".format(build.repo.name.replace("/", "_"), build.num, "zip" if data == Build.Data_Artifact else 'log') 536 | return utils.stream_file(build.path(data), name=download_name, mime=mime) 537 | 538 | 539 | @app.route('/delete') 540 | @models.session 541 | @utils.requires_auth 542 | def delete(): 543 | repo_id = request.args.get('repo_id', '') 544 | build_id = request.args.get('build_id', '') 545 | user_id = request.args.get('user_id', '') 546 | 547 | delete_target = None 548 | return_to = 'dashboard' 549 | if build_id: 550 | delete_target = Build.get(id=build_id) 551 | return_to = delete_target.repo.url() 552 | if not request.user.can_manage: 553 | return abort(403) 554 | elif repo_id: 555 | delete_target = Repository.get(id=repo_id) 556 | return_to = url_for('repositories') 557 | if not request.user.can_manage: 558 | return abort(403) 559 | elif user_id: 560 | delete_target = User.get(id=user_id) 561 | return_to = url_for('users') 562 | if delete_target and delete_target.id != request.user.id and not request.user.can_manage: 563 | return abort(403) 564 | 565 | if not delete_target: 566 | return abort(404) 567 | 568 | try: 569 | delete_target.delete() 570 | except Build.CanNotDelete as exc: 571 | models.rollback() 572 | utils.flash(str(exc)) 573 | referer = request.headers.get('Referer', return_to) 574 | return redirect(referer) 575 | 576 | utils.flash('{} deleted'.format(type(delete_target).__name__)) 577 | return redirect(return_to) 578 | 579 | 580 | @app.route('/build') 581 | @models.session 582 | @utils.requires_auth 583 | def build(): 584 | repo_id = request.args.get('repo_id', '') 585 | ref_name = request.args.get('ref', '') 586 | if not repo_id or not ref_name: 587 | return abort(400) 588 | if not request.user.can_manage: 589 | return abort(403) 590 | 591 | commit = '0' * 32 592 | repo = Repository.get(id=repo_id) 593 | build = Build( 594 | repo=repo, 595 | commit_sha=commit, 596 | num=repo.build_count, 597 | ref=ref_name, 598 | status=Build.Status_Queued, 599 | date_queued=datetime.now(), 600 | date_started=None, 601 | date_finished=None) 602 | repo.build_count += 1 603 | 604 | models.commit() 605 | enqueue(build) 606 | return redirect(repo.url()) 607 | 608 | @app.route('/ping-repo', methods=['POST']) 609 | @models.session 610 | @utils.requires_auth 611 | def ping_repo(): 612 | repo_url = request.form.get('url') 613 | repo_name = request.form.get('repo') 614 | 615 | repo = type('',(object,),{'name' : repo_name})() 616 | 617 | res = utils.ping_repo(repo_url, repo) 618 | if (res == 0): 619 | return 'ok', 200 620 | else: 621 | return 'fail', 404 622 | 623 | 624 | @app.route('/overrides/list/') 625 | @models.session 626 | @utils.requires_auth 627 | def overrides_list(path): 628 | if not request.user.can_manage: 629 | return abort(403) 630 | 631 | separator = "/" 632 | errors = [] 633 | errors = errors + session.pop('errors', []) 634 | context = {} 635 | 636 | repo_path, context['overrides_path'] = file_utils.split_url_path(path) 637 | context['repo'] = get_target_for(repo_path) 638 | context['list_path'] = url_for('overrides_list', path = path) 639 | context['edit_path'] = url_for('overrides_edit', path = path) 640 | context['delete_path'] = url_for('overrides_delete', path = path) 641 | context['download_path'] = url_for('overrides_download', path = path) 642 | context['upload_path'] = url_for('overrides_upload', path = path) 643 | if context['overrides_path'] != '' and context['overrides_path'] != None: 644 | context['parent_name'] = separator.join(context['overrides_path'].split(separator)[:-1]) 645 | context['parent_path'] = url_for('overrides_list', path = separator.join(path.split(separator)[:-1])) 646 | 647 | if not isinstance(context['repo'], Repository): 648 | return abort(404) 649 | 650 | try: 651 | cwd = os.path.join(utils.get_override_path(context['repo']), context['overrides_path'].replace('/', os.sep)) 652 | utils.makedirs(os.path.dirname(cwd)) 653 | context['files'] = file_utils.list_folder(cwd) 654 | except BaseException as exc: 655 | app.logger.info(exc) 656 | errors.append('Could not read overrides for this repository.') 657 | 658 | return render_template('overrides_list.html', user=request.user, **context, errors=errors) 659 | 660 | 661 | @app.route('/overrides/edit/', methods=['GET', 'POST']) 662 | @models.session 663 | @utils.requires_auth 664 | def overrides_edit(path): 665 | if not request.user.can_manage: 666 | return abort(403) 667 | 668 | separator = "/" 669 | context = {} 670 | errors = [] 671 | 672 | repo_path, context['overrides_path'] = file_utils.split_url_path(path) 673 | context['repo'] = get_target_for(repo_path) 674 | if not isinstance(context['repo'], Repository): 675 | return abort(404) 676 | 677 | file_path = os.path.join(utils.get_override_path(context['repo']), context['overrides_path'].replace('/', os.sep)) 678 | 679 | if request.method == 'POST': 680 | override_content = request.form.get('override_content') 681 | try: 682 | file_utils.write_file(file_path, override_content) 683 | utils.flash('Changes in file was saved.') 684 | except BaseException as exc: 685 | app.logger.info(exc) 686 | errors.append('Could not write into file.') 687 | 688 | dir_path_parts = path.split("/") 689 | context['dir_path'] = separator.join(dir_path_parts[:-1]) 690 | 691 | try: 692 | context['content'] = file_utils.read_file(file_path) 693 | except BaseException as exc: 694 | app.logger.info(exc) 695 | errors.append('Could not read file.') 696 | 697 | return render_template('overrides_edit.html', user=request.user, **context, errors=errors) 698 | 699 | 700 | OVERRIDES_ACTION_CREATEFOLDER = 'createNewFolder' 701 | OVERRIDES_ACTION_CREATEFILE = 'createNewFile' 702 | OVERRIDES_ACTION_RENAME = 'rename' 703 | 704 | 705 | @app.route('/overrides/delete/') 706 | @models.session 707 | @utils.requires_auth 708 | def overrides_delete(path): 709 | if not request.user.can_manage: 710 | return abort(403) 711 | 712 | separator = "/" 713 | 714 | repo_path, overrides_path = file_utils.split_url_path(path) 715 | repo = get_target_for(repo_path) 716 | if not isinstance(repo, Repository): 717 | return abort(404) 718 | 719 | return_path_parts = path.split(separator) 720 | return_path = separator.join(return_path_parts[:-1]) 721 | cwd = os.path.join(utils.get_override_path(repo), overrides_path.replace('/', os.sep)) 722 | 723 | session['errors'] = [] 724 | try: 725 | file_utils.delete(cwd) 726 | utils.flash('Object was deleted.') 727 | except BaseException as exc: 728 | app.logger.info(exc) 729 | session['errors'].append('Could not delete \'' + return_path_parts[-1] + '\'.') 730 | 731 | return redirect(url_for('overrides_list', path = return_path)) 732 | 733 | 734 | @app.route('/overrides/download/') 735 | @models.session 736 | @utils.requires_auth 737 | def overrides_download(path): 738 | if not request.user.can_manage: 739 | return abort(403) 740 | 741 | repo_path, overrides_path = file_utils.split_url_path(path) 742 | repo = get_target_for(repo_path) 743 | if not isinstance(repo, Repository): 744 | return abort(404) 745 | 746 | file_path = os.path.join(utils.get_override_path(repo), overrides_path.replace('/', os.sep)) 747 | return utils.stream_file(file_path, mime='application/octet-stream') 748 | 749 | 750 | @app.route('/overrides/upload/', methods=['GET', 'POST']) 751 | @models.session 752 | @utils.requires_auth 753 | def overrides_upload(path): 754 | if not request.user.can_manage: 755 | return abort(403) 756 | 757 | separator = "/" 758 | context = {} 759 | errors = [] + session.pop('errors', []) 760 | 761 | repo_path, context['overrides_path'] = file_utils.split_url_path(path) 762 | repo = get_target_for(repo_path) 763 | if not isinstance(repo, Repository): 764 | return abort(404) 765 | 766 | context['list_path'] = url_for('overrides_list', path = path) 767 | cwd = os.path.join(utils.get_override_path(repo), context['overrides_path'].replace('/', os.sep)) 768 | 769 | if request.method == 'POST': 770 | session['errors'] = [] 771 | files = request.files.getlist('upload_file') 772 | if not files: 773 | utils.flash('No file was uploaded.') 774 | else: 775 | file_uploads = [] 776 | for file in files: 777 | filepath = os.path.join(cwd, secure_filename(file.filename)) 778 | try: 779 | file.save(filepath) 780 | file_uploads.append("File '{}' was uploaded.".format(file.filename)) 781 | except BaseException as exc: 782 | app.logger.info(exc) 783 | session['errors'].append("Could not upload '{}'.".format(file.filename)) 784 | utils.flash(" ".join(file_uploads)) 785 | if not session['errors']: 786 | return redirect(url_for('overrides_list', path = path)) 787 | 788 | dir_path_parts = path.split("/") 789 | context['dir_path'] = separator.join(dir_path_parts[:-1]) 790 | 791 | return render_template('overrides_upload.html', user=request.user, **context, errors=errors) 792 | 793 | 794 | @app.route('/overrides/') 795 | @models.session 796 | @utils.requires_auth 797 | def overrides_actions(action): 798 | if not request.user.can_manage: 799 | return abort(403) 800 | 801 | separator = "/" 802 | session['errors'] = [] 803 | repo_id = request.args.get('repo_id', '') 804 | path = request.args.get('path', '') 805 | 806 | repo = Repository.get(id=repo_id) 807 | if not repo: 808 | return abort(404) 809 | 810 | if action == OVERRIDES_ACTION_CREATEFOLDER: 811 | name = secure_filename(request.args.get('name', '')) 812 | try: 813 | file_utils.create_folder(os.path.join(utils.get_override_path(repo), path.replace('/', os.sep)), name) 814 | utils.flash('Folder was created.') 815 | return redirect(url_for('overrides_list', path = separator.join([repo.name, path, name]).replace('//', '/'))) 816 | except BaseException as exc: 817 | app.logger.info(exc) 818 | session['errors'].append('Could not create folder.') 819 | return redirect(url_for('overrides_list', path = separator.join([repo.name, path]).replace('//', '/'))) 820 | elif action == OVERRIDES_ACTION_CREATEFILE: 821 | name = secure_filename(request.args.get('name', '')) 822 | try: 823 | file_utils.create_file(os.path.join(utils.get_override_path(repo), path.replace('/', os.sep)), name) 824 | utils.flash('File was created.') 825 | return redirect(url_for('overrides_edit', path = separator.join([repo.name, path, name]).replace('//', '/'))) 826 | except BaseException as exc: 827 | app.logger.info(exc) 828 | session['errors'].append('Could not create file.') 829 | return redirect(url_for('overrides_list', path = separator.join([repo.name, path]).replace('//', '/'))) 830 | elif action == OVERRIDES_ACTION_RENAME: 831 | name = request.args.get('name', '').replace('../', '') 832 | original_name = request.args.get('original_name', '').replace('../', '') 833 | new_path = os.path.join(utils.get_override_path(repo), path.replace('/', os.sep), name) 834 | original_path = os.path.join(utils.get_override_path(repo), path.replace('/', os.sep), original_name) 835 | 836 | try: 837 | file_utils.rename(original_path, new_path) 838 | utils.flash('Object was renamed.') 839 | except BaseException as exc: 840 | app.logger.info(exc) 841 | session['errors'].append('Could not rename \'' + original_name + '\'.') 842 | return redirect(url_for('overrides_list', path = separator.join([repo.name, path]).replace('//', '/'))) 843 | 844 | return abort(404) 845 | 846 | 847 | @app.errorhandler(403) 848 | def error_403(e): 849 | return render_template('403.html'), 403 850 | 851 | 852 | @app.errorhandler(404) 853 | def error_404(e): 854 | return render_template('404.html'), 404 855 | 856 | 857 | @app.errorhandler(500) 858 | def error_500(e): 859 | return render_template('500.html'), 500 860 | --------------------------------------------------------------------------------