--- /dev/null
+*.pyc
+*.db
+__pycache__/
+*.egg-info/
--- /dev/null
+stages:
+ - test
+ - build
+
+test:
+ stage: test
+ image: python:3.6
+ script:
+ - pip install -e .
+ - python test.py
+
+build:
+ stage: build
+ image: docker:stable
+ services:
+ - docker:dind
+ variables:
+ DOCKER_HOST: tcp://docker:2375
+ DOCKER_DRIVER: overlay2
+ script:
+ - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
+ - docker build -t $CI_REGISTRY_IMAGE:latest .
+ - docker tag $CI_REGISTRY_IMAGE:latest $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
+ - docker push $CI_REGISTRY_IMAGE:latest
+ - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
+ only:
+ refs:
+ - master
--- /dev/null
+builder-ui
--- /dev/null
+FROM python:3.6
+
+COPY . /app
+WORKDIR /app
+RUN pip install gunicorn
+RUN pip install .
+
+CMD gunicorn -b 0.0.0.0:8080 ui.app:app
--- /dev/null
+ui/migrations
\ No newline at end of file
--- /dev/null
+alembic==1.0.1
+certifi==2018.10.15
+chardet==3.0.4
+Click==7.0
+Flask==1.0.2
+Flask-Caching==1.4.0
+Flask-Migrate==2.3.0
+Flask-SQLAlchemy==2.3.2
+Flask-Testing==0.7.1
+idna==2.7
+itsdangerous==1.1.0
+Jinja2==2.10
+Mako==1.0.7
+MarkupSafe==1.0
+python-dateutil==2.7.5
+python-editor==1.0.3
+requests==2.20.0
+six==1.11.0
+SQLAlchemy==1.2.12
+urllib3==1.24
+Werkzeug==0.14.1
--- /dev/null
+import setuptools
+
+import setuptools
+
+with open("README.md", "r") as fh:
+ long_description = fh.read()
+with open("requirements.txt", "r") as fh:
+ requirements = fh.read().split()
+setuptools.setup(
+ name="ui",
+ version="0.0.1",
+ description="LineageOS Builds",
+ url="https://gitlab.com/lineageos/builder/ui.git",
+ author_email="infra@lineageos.org",
+ author="LineageOS Infrastructure Team",
+ long_description=long_description,
+ long_description_content_type="text/markdown",
+ package_dir={"ui": "ui"},
+ packages=setuptools.find_packages(),
+ classifiers=("Programming Language :: Python 3"),
+ install_requires=requirements,
+)
+
--- /dev/null
+import os
+import unittest
+import flask_migrate
+import flask_testing
+
+from ui import config
+from ui.app import app
+from ui.models import Build, Runner, db
+
+class UsesApp(flask_testing.TestCase):
+ def create_app(self):
+ return app
+
+class UsesModels(UsesApp):
+ def setUp(self):
+ flask_migrate.upgrade(revision="head")
+
+ def tearDown(self):
+ flask_migrate.downgrade(revision="base")
+
+class TestModels(UsesModels):
+
+ def test_build(self):
+ build = Build(build_id=1)
+ db.session.add(build)
+ db.session.commit()
+
+ assert build in db.session
+
+ def test_get_or_create_build_by_id(self):
+ build = Build(build_id=1)
+ db.session.add(build)
+ db.session.commit()
+
+ assert Build.get_or_create_by_id(1).build_id == 1
+ assert Build.get_or_create_by_id(2).build_id == 2
+
+ def test_runner(self):
+ runner = Runner(runner_id="foobar")
+ db.session.add(runner)
+ db.session.commit()
+
+ assert runner in db.session
+
+ def test_get_or_create_runner_by_id(self):
+ runner = Runner(runner_id="foobar", runner_name="foobar")
+ db.session.add(runner)
+ db.session.commit()
+
+ assert Runner.get_or_create_by_id('foobar').runner_name == "foobar"
+ assert Runner.get_or_create_by_id("foobaz").runner_name == None
+
+ def test_runner_build_rel(self):
+ runner = Runner(runner_id="foobar", runner_name="foobar")
+ build = Build(build_id=1, build_runner=runner)
+ db.session.add(runner)
+ db.session.add(build)
+ db.session.commit()
+
+ assert build.build_runner == runner
+ assert build in runner.builds
+
+class TestWebhooks(UsesModels):
+
+ def test_invalid_token(self):
+ with open("tests/pipeline_webhook.json", "r") as f:
+ response = self.client.post("/webhook", headers={'X-Gitlab-Event': 'Pipeline Hook', 'X-Gitlab-Token': 'invalidsecret'}, data=f.read(), content_type='application/json')
+ assert response.status_code == 403
+
+ def test_pipeline_webhook(self):
+ with open("tests/pipeline_webhook.json", "r") as f:
+ response = self.client.post("/webhook", headers={'X-Gitlab-Event': 'Pipeline Hook', 'X-Gitlab-Token': 'secret'}, data=f.read(), content_type='application/json')
+ assert response.status_code == 200
+ build = Build.query.filter_by(build_id=1).first()
+ assert build.build_id == 1
+ assert build.build_runner_id == "foobar"
+
+if __name__ == "__main__":
+ unittest.main()
--- /dev/null
+{
+ "object_kind": "pipeline",
+ "object_attributes":{
+ "id": 1,
+ "status": "success",
+ "stages":[
+ "build",
+ "sign"
+ ],
+ "duration": 63,
+ "variables": [
+ {
+ "key": "VERSION",
+ "value": "lineage-15.1"
+ },
+ {
+ "key": "DEVICE",
+ "value": "mako"
+ },
+ {
+ "key": "TYPE",
+ "value": "userdebug"
+ }
+ ]
+ },
+ "builds":[
+ {
+ "id": 380,
+ "stage": "build",
+ "name": "build",
+ "status": "success",
+ "created_at": "2016-08-12 15:23:28 UTC",
+ "when": "manual",
+ "manual": true,
+ "user":{
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": ""
+ },
+ "runner": "foobar"
+ },
+ {
+ "id": 377,
+ "stage": "sign",
+ "name": "sign",
+ "status": "success",
+ "created_at": "2016-08-12 15:23:28 UTC",
+ "runner": "foobar"
+ }
+ ]
+}
--- /dev/null
+import os
+
+import requests
+from flask import Flask, render_template, request, abort
+from flask_caching import Cache
+from flask_migrate import Migrate
+from flask_sqlalchemy import SQLAlchemy
+
+from ui import gitlab, config, models
+
+app = Flask(__name__)
+app.config.from_object(config)
+cache = Cache(app)
+models.db.init_app(app)
+migrate = Migrate(app, models.db)
+
+
+headers = {'Private-Token': os.environ.get('GITLAB_TOKEN', '')}
+
+@app.route('/')
+def main():
+ return render_template('index.html')
+
+@app.route("/webhook", methods=('POST',))
+def process_webhook():
+ gitlab.webhooks.process(request)
+ return "OK", 200
+
+if __name__ == '__main__':
+ app.run(host='0.0.0.0', port=5000)
--- /dev/null
+import os
+
+SQLALCHEMY_DATABASE_URI = os.environ.get("SQLALCHEMY_DATABASE_URI", 'sqlite:////tmp/ui.db')
+CACHE_TYPE = 'simple'
+
+GITLAB_WEBHOOK_TOKEN = 'secret'
+
+PRESERVE_CONTEXT_ON_EXCEPTION = False
--- /dev/null
+from . import api, webhooks
--- /dev/null
+from datetime import datetime
+import requests
+import os
+
+GITLAB_BASE = "https://gitlab.com/api/v4/"
+
+def create_pipeline(project, trigger, data):
+ resp = requests.post(f"{GITLAB_BASE}projects/{project}/trigger/pipeline?token={token}", data=data)
+ if resp.status_code != 201:
+ raise Exception("ERROR", req.status_code, req.json())
+ return resp.json()
--- /dev/null
+from flask import abort
+
+from ui import config
+from ui.gitlab import api
+from ui.models import Build, Runner, db
+
+def process(request):
+ if request.headers.get('X-Gitlab-Token', None) != config.GITLAB_WEBHOOK_TOKEN:
+ abort(403)
+ webhook_type = request.headers.get('X-Gitlab-Event')
+ data = request.get_json()
+ if webhook_type == 'Pipeline Hook':
+ pipeline = data.get('object_attributes')
+ stages = data.get('builds')
+ build = Build.get_or_create_by_id(pipeline.get('id'))
+ build.status = pipeline.get('status')
+ build.duration = pipeline.get('duration')
+ for variable in pipeline.get("variables"):
+ if variable.get('key') == "VERSION":
+ build.build_version = variable.get("value")
+ elif variable.get("key") == "DEVICE":
+ build.build_device = variable.get("value")
+ elif variable.get("key") == "TYPE":
+ build.build_type = variable.get("value")
+ build_stage = {}
+ for stage in stages:
+ if stage.get('name') == 'build':
+ build_stage = stage
+ if build_stage.get("runner"):
+ build.build_runner = Runner.get_or_create_by_id(build_stage['runner'])
+ db.session.add(build)
+ db.session.commit()
--- /dev/null
+Generic single-database configuration.
\ No newline at end of file
--- /dev/null
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
--- /dev/null
+from __future__ import with_statement
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+from logging.config import fileConfig
+import logging
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+from flask import current_app
+config.set_main_option('sqlalchemy.url',
+ current_app.config.get('SQLALCHEMY_DATABASE_URI'))
+target_metadata = current_app.extensions['migrate'].db.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(url=url)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+
+ # this callback is used to prevent an auto-migration from being generated
+ # when there are no changes to the schema
+ # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+ def process_revision_directives(context, revision, directives):
+ if getattr(config.cmd_opts, 'autogenerate', False):
+ script = directives[0]
+ if script.upgrade_ops.is_empty():
+ directives[:] = []
+ logger.info('No changes in schema detected.')
+
+ engine = engine_from_config(config.get_section(config.config_ini_section),
+ prefix='sqlalchemy.',
+ poolclass=pool.NullPool)
+
+ connection = engine.connect()
+ context.configure(connection=connection,
+ target_metadata=target_metadata,
+ process_revision_directives=process_revision_directives,
+ **current_app.extensions['migrate'].configure_args)
+
+ try:
+ with context.begin_transaction():
+ context.run_migrations()
+ finally:
+ connection.close()
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
--- /dev/null
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
--- /dev/null
+"""empty message
+
+Revision ID: c3a5cacf3bff
+Revises:
+Create Date: 2018-10-28 14:17:59.970010
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'c3a5cacf3bff'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('runner',
+ sa.Column('runner_id', sa.String(), autoincrement=False, nullable=False),
+ sa.Column('runner_name', sa.String(), nullable=True),
+ sa.PrimaryKeyConstraint('runner_id')
+ )
+ op.create_table('build',
+ sa.Column('build_id', sa.Integer(), autoincrement=False, nullable=False),
+ sa.Column('build_status', sa.String(), nullable=True),
+ sa.Column('build_device', sa.String(), nullable=True),
+ sa.Column('build_version', sa.String(), nullable=True),
+ sa.Column('build_type', sa.String(), nullable=True),
+ sa.Column('build_date', sa.Date(), nullable=True),
+ sa.Column('build_runner_id', sa.String(), nullable=True),
+ sa.ForeignKeyConstraint(['build_runner_id'], ['runner.runner_id'], ),
+ sa.PrimaryKeyConstraint('build_id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('build')
+ op.drop_table('runner')
+ # ### end Alembic commands ###
--- /dev/null
+from flask_sqlalchemy import SQLAlchemy
+
+db = SQLAlchemy()
+
+class Build(db.Model):
+ build_id = db.Column(db.Integer, primary_key=True, autoincrement=False)
+ build_status = db.Column(db.String)
+ build_device = db.Column(db.String)
+ build_version = db.Column(db.String)
+ build_type = db.Column(db.String)
+ build_date = db.Column(db.Date)
+ build_runner_id = db.Column(db.String, db.ForeignKey('runner.runner_id'))
+ build_runner = db.relationship('Runner', backref=db.backref('builds', lazy=True))
+
+ def __repr__(self):
+ return f"{self.build_id} {self.build_device} {self.build_version} {self.build_type} {self.build_runner_id}"
+
+ @classmethod
+ def get_or_create_by_id(cls, build_id):
+ build = cls.query.filter_by(build_id=build_id).first()
+ return build if build else cls(build_id=build_id)
+
+class Runner(db.Model):
+ runner_id = db.Column(db.String, primary_key=True, autoincrement=False)
+ runner_name = db.Column(db.String)
+
+ @classmethod
+ def get_or_create_by_id(cls, runner_id):
+ runner = cls.query.filter_by(runner_id=runner_id).first()
+ if not runner:
+ runner = cls(runner_id=runner_id)
+ db.session.add(runner)
+ db.session.commit()
+ return runner
--- /dev/null
+ <!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>LineageOS Builds</title>
+ </head>
+ <body>
+ <table>
+ <tr>
+ <th>ID</th>
+ <th>Status</th>
+ <th>Device</th>
+ <th>Version</th>
+ <th>Type</th>
+ <th>Date</th>
+ </tr>
+ {% for build in builds %}
+ <tr>
+ <td>{{build.id}}</td>
+ <td>{{build.status}}</td>
+ <td>{{build.device}}</td>
+ <td>{{build.version}}</td>
+ <td>{{build.type}}</td>
+ <td>{{build.date}}</td>
+ </tr>
+ {% endfor %}
+ </table>
+ </body>
+</html>
+