From c69132399901fb90a7dd96d0c500f5dd518e1746 Mon Sep 17 00:00:00 2001 From: Christophe Monniez Date: Wed, 13 Jul 2022 16:49:21 +0200 Subject: [PATCH] WIP --- runbot/controllers/__init__.py | 1 + runbot/controllers/jsonroutes.py | 118 ++++++++++++++++++++++++++ runbot/models/batch.py | 35 +++++++- runbot/models/build.py | 22 +++++ runbot/models/bundle.py | 16 ++++ runbot/models/commit.py | 41 +++++++++ runbot/models/project.py | 11 +++ runbot/models/repo.py | 16 ++++ runbot/security/runbot_security.xml | 30 +++++++ runbot/tests/__init__.py | 1 + runbot/tests/test_json_routes.py | 127 ++++++++++++++++++++++++++++ 11 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 runbot/controllers/jsonroutes.py create mode 100644 runbot/tests/test_json_routes.py diff --git a/runbot/controllers/__init__.py b/runbot/controllers/__init__.py index 96d149ab..d498305e 100644 --- a/runbot/controllers/__init__.py +++ b/runbot/controllers/__init__.py @@ -3,3 +3,4 @@ from . import frontend from . import hook from . import badge +from . import jsonroutes diff --git a/runbot/controllers/jsonroutes.py b/runbot/controllers/jsonroutes.py new file mode 100644 index 00000000..f8b197c4 --- /dev/null +++ b/runbot/controllers/jsonroutes.py @@ -0,0 +1,118 @@ +import json + +from functools import wraps +from odoo.exceptions import AccessError +from odoo.http import Controller, request, route +from odoo.osv import expression + +RECORDS_PER_PAGE = 100 + +def to_json(fn): + @wraps(fn) + def decorator(*args, **kwargs): + headers = [('Content-Type', 'application/json'), + ('Cache-Control', 'no-store')] + try: + return request.make_response(json.dumps(fn(*args, **kwargs)._get_description(), indent=4, default=str), headers) + except AccessError: + response = request.make_response(json.dumps('unauthorized'), headers) + response.status = 403 + return response + return decorator + +class RunbotJsonRoutes(Controller): + + @route(['/runbot/json/projects', + '/runbot/json/projects/'], type='http', auth='public') + @to_json + def projects(self, project_id=None, **kwargs): + if project_id: + projects = request.env['runbot.project'].browse(project_id) + else: + domain = ([('group_ids', '=', False)]) if request.env.user._is_public() else [] + projects = request.env['runbot.project'].search(domain) + return projects + + @route(['/runbot/json/bundles/', + '/runbot/json/projects//bundles'], type='http', auth='public') + @to_json + def bundles(self, project_id=None, bundle_id=None, page=0, **kwargs): + offset = int(page) * RECORDS_PER_PAGE + domain = [] + if bundle_id: + bundles = request.env['runbot.bundle'].browse(bundle_id) + else: + domain = [('project_id', '=', project_id)] + if 'sticky' in kwargs: + domain = expression.AND([domain, [('sticky', '=', kwargs['sticky'])]]) + if 'name' in kwargs: + name_query = kwargs['name'] + name_query = name_query if len(name_query) < 60 else name_query[:60] + domain = expression.AND([domain, [('name', 'ilike', name_query)]]) + bundles = request.env['runbot.bundle'].search(domain, order='id desc', limit=RECORDS_PER_PAGE, offset=offset) + return bundles + + @route(['/runbot/json/bundles//batches', + '/runbot/json/batches/'], type='http', auth='public') + @to_json + def batches(self, bundle_id=None, batch_id=None, page=0, **kwargs): + offset = int(page) * RECORDS_PER_PAGE + if batch_id: + batches = request.env['runbot.batch'].browse(batch_id) + else: + domain = [('bundle_id', '=', bundle_id)] if bundle_id else [] + domain += [('state', '=', kwargs['state'])] if 'state' in kwargs else [] + batches = request.env['runbot.batch'].search(domain, order="id desc", limit=RECORDS_PER_PAGE, offset=offset) + return batches + + @route(['/runbot/json/batches//commits', + '/runbot/json/commits/'], type='http', auth='public') + @to_json + def commits(self, commit_id=None, batch_id=None, page=0, **kwargs): + if commit_id: + commits = request.env['runbot.commit'].browse(commit_id) + else: + commits = request.env['runbot.batch'].browse(batch_id).commit_ids + return commits + + @route(['/runbot/json/commits//commit_links', + '/runbot/json/commit_links/'], type='http', auth='public') + @to_json + def commit_links(self, commit_id=None, commit_link_id=None, page=0, **kwargs): + if commit_link_id: + commit_links = request.env['runbot.commit.link'].browse(commit_link_id) + else: + domain = [('commit_id', '=', commit_id)] + commit_links = request.env['runbot.commit.link'].search(domain) + return commit_links + + @route(['/runbot/json/repos', + '/runbot/json/repos/'], type='http', auth='public') + @to_json + def repos(self, repo_id=None, page=0, **kwargs): + if repo_id: + repos = request.env['runbot.repo'].browse(repo_id) + else: + domain = [] + repos = request.env['runbot.repo'].search(domain) + return repos + + @route(['/runbot/json/batches//slots', + '/runbot/json/batch_slots/'], type='http', auth='public') + @to_json + def batch_slots(self, batch_id=None, slot_id=None, page=0, **kwargs): + if slot_id: + slots = request.env['runbot.batch.slot'].browse(slot_id) + else: + slots = request.env['runbot.batch'].browse(batch_id).slot_ids + return slots + + @route(['/runbot/json/batches//builds', + '/runbot/json/builds/'], type='http', auth='public') + @to_json + def builds(self, build_id=None, batch_id=None, page=0, **kwargs): + if build_id: + builds = request.env['runbot.build'].browse(build_id) + else: + builds = request.env['runbot.batch'].browse(batch_id).all_build_ids + return builds diff --git a/runbot/models/batch.py b/runbot/models/batch.py index 9d162756..5c0d9c18 100644 --- a/runbot/models/batch.py +++ b/runbot/models/batch.py @@ -176,7 +176,6 @@ class Batch(models.Model): lambda t: not t.version_domain or \ self.bundle_id.version_id.filtered_domain(t.get_version_domain()) ) - pushed_repo = self.commit_link_ids.mapped('commit_id.repo_id') dependency_repos = triggers.mapped('dependency_ids') all_repos = triggers.mapped('repo_ids') | dependency_repos @@ -327,7 +326,6 @@ class Batch(models.Model): 'used_custom_trigger': bool(trigger_custom), } params_value['builds_reference_ids'] = trigger._reference_builds(bundle) - params = self.env['runbot.build.params'].create(params_value) build = self.env['runbot.build'] @@ -412,6 +410,23 @@ class Batch(models.Model): 'level': level, }) + def _get_description(self): + return[ + { + 'id': r.id, + 'url': f'{r.get_base_url()}/runbot/json/batches/{r.id}', + 'state': r.state, + 'last_update': r.last_update, + 'bundle_name' : r.bundle_id.name, + 'bundle_url': f'{r.get_base_url()}/runbot/json/bundles/{r.bundle_id.id}', + 'commits_url': f'{r.get_base_url()}/runbot/json/batches/{r.id}/commits', + 'commits': [info for commit in r.commit_ids for info in commit._get_description()], + 'slots_url': f'{r.get_base_url()}/runbot/json/batches/{r.id}/slots', + 'slots' : [info for slot in r.slot_ids for info in slot._get_description()], + } + for r in self + ] + class BatchLog(models.Model): _name = 'runbot.batch.log' @@ -467,3 +482,19 @@ class BatchSlot(models.Model): self.batch_id._log(f'Trigger {self.trigger_id.name} was started by {self.env.user.name}') self.link_type, self.build_id = self.batch_id._create_build(self.params_id) return self.build_id + + def _get_description(self): + return[ + { + 'id': r.id, + 'url': f'{r.get_base_url()}/runbot/json/batch_slots/{r.id}', + 'trigger_name': r.trigger_id.name, + 'link_type': r.link_type, + 'batch_url': f'{r.get_base_url()}/runbot/json/batches/{r.batch_id.id}', + 'build_url': f'{r.get_base_url()}/runbot/json/builds/{r.build_id.id}' if r.build_id else False, + 'active': r.active, + 'skipped': r.skipped, + 'build': r.build_id._get_description()[0] if r.build_id else False, + } + for r in self + ] diff --git a/runbot/models/build.py b/runbot/models/build.py index 7e4cb489..c749915b 100644 --- a/runbot/models/build.py +++ b/runbot/models/build.py @@ -1202,3 +1202,25 @@ class BuildResult(models.Model): def parse_config(self): return set(findall(self._server("tools/config.py"), '--[\w-]+', )) + + def _get_description(self): + return[ + { + 'id': r.id, + 'dest': r.dest, + 'url': f'{r.get_base_url()}/runbot/json/builds/{r.id}', + 'parent_build_url': f'{r.get_base_url()}/runbot/json/builds/{r.parent_id}' if r.parent_id else False, + 'params_url': f'{r.get_base_url()}/runbot/json/build_params/{r.params_id.id}', + 'version': r.version_id.name, + 'config': r.config_id.name, + 'trigger': r.trigger_id.name, + 'global_state': r.global_state, + 'local_state': r.local_state, + 'global_result': r.global_result, + 'local_result': r.local_result, + 'triggered_result': r.triggered_result, + 'host': r.host, + 'children': [info for child in r.children_ids for info in child._get_description()], + } + for r in self + ] diff --git a/runbot/models/bundle.py b/runbot/models/bundle.py index 4c86d3cf..76f0984a 100644 --- a/runbot/models/bundle.py +++ b/runbot/models/bundle.py @@ -246,3 +246,19 @@ class Bundle(models.Model): for branch in self.branch_ids.sorted(key=lambda b: (b.is_pr)): branch_groups[branch.remote_id.repo_id].append(branch) return branch_groups + + def _get_description(self): + return[ + { + 'id': r.id, + 'url': f'{r.get_base_url()}/runbot/json/bundles/{r.id}', + 'name': r.name, + 'sticky': r.sticky, + 'version_number': r.version_number, + 'project_url': f'{r.get_base_url()}/runbot/json/projects/{r.project_id.id}', + 'last_batch_url': f'{r.get_base_url()}/runbot/json/batches/{r.last_batch.id}', + 'last_batch' : r.last_batch._get_description()[0] if r.last_batch else False, + 'batches_url': f'{r.get_base_url()}/runbot/json/bundles/{r.id}/batches' + } + for r in self + ] diff --git a/runbot/models/commit.py b/runbot/models/commit.py index 3bce09d0..3bddbbd9 100644 --- a/runbot/models/commit.py +++ b/runbot/models/commit.py @@ -153,6 +153,23 @@ class Commit(models.Model): 'to_process': True, }) + def _get_description(self): + return[ + { + 'id': r.id, + 'url': f'{r.get_base_url()}/runbot/json/commits/{r.id}', + 'hash': r.name, + 'repo': r.repo_id.name, + 'repo_url': f'{r.get_base_url()}/runbot/json/repos/{r.repo_id.id}', + 'author': r.author, + 'author_email': r.author_email, + 'committer': r.committer, + 'committer_email': r.committer_email, + 'subject': r.subject, + 'commit_links_url': f'{r.get_base_url()}/runbot/json/commits/{r.id}/commit_links', + } + for r in self + ] class CommitLink(models.Model): _name = 'runbot.commit.link' @@ -171,6 +188,30 @@ class CommitLink(models.Model): diff_add = fields.Integer('# line added') diff_remove = fields.Integer('# line removed') + def _get_description(self): + return[ + { + 'id': r.id, + 'url': f'{r.get_base_url()}/runbot/json/commit_links/{r.id}', + 'commit_url': f'{r.get_base_url()}/runbot/json/commits/{r.id}', + 'hash': r.commit_id.name, + 'match_type': r.match_type, + 'author': r.commit_id.author, + 'author_email': r.commit_id.author_email, + 'committer': r.commit_id.committer, + 'committer_email': r.commit_id.committer_email, + 'subject': r.commit_id.subject, + 'base_commit': r.base_commit_id.name, + 'base_commit_url': f'{r.get_base_url()}/runbot/json/commits/{r.base_commit_id.id}' if r.base_commit_id else False, + 'merge_base_commit': r.merge_base_commit_id.name, + 'merge_base_commit_url': f'{r.get_base_url()}/runbot/json/commits/{r.merge_base_commit_id.id}' if r.merge_base_commit_id else False, + 'branch_url': f'{r.get_base_url()}/runbot/json/branches/{r.id}', + 'branch_name': r.branch_id.name, + 'github_branch_url': f'https://{r.branch_id.remote_id.base_url}/commit/{r.commit_id.name}', + } + for r in self + ] + class CommitStatus(models.Model): _name = 'runbot.commit.status' diff --git a/runbot/models/project.py b/runbot/models/project.py index b4836ca2..0db9c1b3 100644 --- a/runbot/models/project.py +++ b/runbot/models/project.py @@ -42,6 +42,17 @@ class Project(models.Model): project.dummy_bundle_id = bundle return projects + def _get_description(self): + return[ + { + 'id': r.id, + 'url': f'{r.get_base_url()}/runbot/json/projects/{r.id}', + 'name': r.name, + 'keep_sticky_running': r.keep_sticky_running, + 'bundles_url': f'{r.get_base_url()}/runbot/json/projects/{r.id}/bundles' + } + for r in self + ] class Category(models.Model): _name = 'runbot.category' diff --git a/runbot/models/repo.py b/runbot/models/repo.py index e2659121..46800d11 100644 --- a/runbot/models/repo.py +++ b/runbot/models/repo.py @@ -563,6 +563,22 @@ class Repo(models.Model): if file.startswith(base_path): return file[len(base_path):].strip('/').split('/')[0] + def _get_description(self): + return[ + { + 'id': r.id, + 'url': f'{r.get_base_url()}/runbot/json/repos/{r.id}', + 'name': r.name, + 'project': r.project_id.name, + 'project_url': f'{r.get_base_url()}/runbot/json/projects/{r.project_id.id}', + 'hook_time': r.hook_time, + 'hook_time_string': datetime.datetime.fromtimestamp(r.hook_time), + 'last_processed_hook_time': r.last_processed_hook_time, + 'last_processed_hook_time_string': datetime.datetime.fromtimestamp(r.last_processed_hook_time), + } + for r in self + ] + class RefTime(models.Model): _name = 'runbot.repo.reftime' diff --git a/runbot/security/runbot_security.xml b/runbot/security/runbot_security.xml index 1aa25c6e..471aa54d 100644 --- a/runbot/security/runbot_security.xml +++ b/runbot/security/runbot_security.xml @@ -130,5 +130,35 @@ + + User can read bundle from public projects or specific groups + + ['|', ('project_id.group_ids.users', 'in', user.id), ('project_id.group_ids', '=', False)] + + + + User can read batch from public projects or specific groups + + ['|', ('bundle_id.project_id.group_ids.users', 'in', user.id), ('bundle_id.project_id.group_ids', '=', False)] + + + + User can read commits from public projects or specific groups + + ['|', ('repo_id.project_id.group_ids.users', 'in', user.id), ('repo_id.project_id.group_ids', '=', False)] + + + + User can read batch slot from public projects or specific groups + + ['|', ('batch_id.bundle_id.project_id.group_ids.users', 'in', user.id), ('batch_id.bundle_id.project_id.group_ids', '=', False)] + + + + User can read build from public projects or specific groups + + ['|', ('params_id.project_id.group_ids.users', 'in', user.id), ('params_id.project_id.group_ids', '=', False)] + + diff --git a/runbot/tests/__init__.py b/runbot/tests/__init__.py index 188387ca..5d36fda8 100644 --- a/runbot/tests/__init__.py +++ b/runbot/tests/__init__.py @@ -15,3 +15,4 @@ from . import test_commit from . import test_upgrade from . import test_dockerfile from . import test_host +from . import test_json_routes diff --git a/runbot/tests/test_json_routes.py b/runbot/tests/test_json_routes.py new file mode 100644 index 00000000..5c21cf46 --- /dev/null +++ b/runbot/tests/test_json_routes.py @@ -0,0 +1,127 @@ +import json + +from odoo import fields +from odoo.tests.common import HttpCase + +from .common import RunbotCase + + +class TestJsonRoutes(RunbotCase, HttpCase): + + def setUp(self): + super().setUp() + self.additionnal_setup() + self.private_group = self.env['res.groups'].create({ + 'name': 'Test Private', + }) + self.private_project = self.Project.create({ + 'name': 'Private Test Project', + 'group_ids': [fields.Command.link(self.private_group.id)], + }) + + self.private_repo_server = self.Repo.create({ + 'name': 'private_server', + 'project_id': self.private_project.id, + 'server_files': 'server.py', + 'addons_paths': 'addons,core/addons' + }) + + self.private_remote_server = self.Remote.create({ + 'name': 'foo@example.com:base/server', + 'repo_id': self.private_repo_server.id, + 'token': '456', + }) + + self.initial_private_server_commit = self.Commit.create({ + 'name': 'afafafaf', + 'repo_id': self.private_repo_server.id, + 'date': '2020-01-01', + 'subject': 'Initial Private Commit', + 'author': 'foofoo', + 'author_email': 'foofoo@somewhere.com' + }) + + self.private_branch_server = self.Branch.create({ + 'name': '13.0', + 'remote_id': self.private_remote_server.id, + 'is_pr': False, + 'head': self.initial_private_server_commit.id, + }) + self.branch_server.bundle_id.is_base = True + + self.Trigger.create({ + 'name': 'Private Repo Server trigger', + 'repo_ids': [(4, self.private_repo_server.id)], + 'config_id': self.default_config.id, + 'project_id': self.private_project.id, + }) + + def check_json_route(self, route, expected_status): + response = self.url_open(route) + self.assertEqual(response.status_code, expected_status) + res = json.loads(response.content) + if expected_status == 403: + self.assertEqual(res, 'unauthorized') + return res + + def test_json_flow_public_user(self): + # test that a public user can get public project informations + projects_infos = self.check_json_route('/runbot/json/projects', 200) + project_names = [p['name'] for p in projects_infos] + self.assertIn(self.project.name, project_names) + + # test that a public user cannot get a private project informations + self.assertNotIn('Private Project', project_names) + self.check_json_route(f'/runbot/json/projects/{self.private_project.id}', 403) + + bundles_infos = self.check_json_route(projects_infos[0]['bundles_url'], 200) + bundles_names = [b['name'] for b in bundles_infos] + self.assertIn(self.branch_server.bundle_id.name, bundles_names) + + # check that public user cannot access private project bundles + private_bundle = self.Bundle.search([('name', '=', '13.0'), ('project_id', '=', self.private_project.id)]) + self.check_json_route(f'/runbot/json/bundles/{private_bundle.id}', 403) + + private_batch = self.private_branch_server.bundle_id._force() + private_batch._prepare() + batches_infos = self.check_json_route(f'/runbot/json/bundles/{self.branch_server.bundle_id.id}/batches', 200) + batch_ids = [ b['id'] for b in batches_infos] + self.assertEqual(len(batch_ids), 1) + self.assertNotIn(private_batch.id, batch_ids) + + # Let's verify that the batches infos contains the commits informations too + for commit in batches_infos[0]['commits']: + self.assertIn(commit['hash'], ['aaaaaaa', 'cccccc']) + + # check that a public user cannot access private project batches + private_batches_infos = self.check_json_route(f'/runbot/json/bundles/{self.private_branch_server.bundle_id.id}/batches', 200) + self.assertEqual(private_batches_infos, []) + + commits_infos = self.check_json_route(batches_infos[0]['commits_url'], 200) + self.check_json_route(commits_infos[0]['url'], 200) + + # check that a public user cannot access private project commits + self.check_json_route(f'/runbot/json/batches/{private_batch.id}/commits', 403) + self.check_json_route(f'/runbot/json/commits/{self.initial_private_server_commit.id}', 403) + + commit_links_infos = self.check_json_route(commits_infos[0]['commit_links_url'], 200) + self.check_json_route(commit_links_infos[0]['url'], 200) + + # check that a public user cannot access private project commit links + private_commit_link = self.env['runbot.commit.link'].search([('commit_id', '=', self.initial_private_server_commit.id)], limit=1) + self.check_json_route(f'/runbot/json/commit_links/{private_commit_link.id}', 403) + + server_slot_infos = list(filter(lambda slot: slot['trigger_name'] == 'Server trigger', self.check_json_route(batches_infos[0]['slots_url'], 200))) + + # check that a public user cannot access private project batch slots + self.check_json_route(f'/runbot/json/batches/{private_batch.id}/slots', 403) + private_slot = self.env['runbot.batch.slot'].search([('batch_id', '=', private_batch.id)], limit=1) + self.check_json_route(f'/runbot/json/batch_slots/{private_slot.id}', 403) + + server_build_infos = self.check_json_route(server_slot_infos[0]['build_url'], 200) + self.assertEqual(server_build_infos[0]['trigger'], 'Server trigger') + + # check that a public user cannot access private project build + self.check_json_route(f'/runbot/json/batches/{private_batch.id}/builds', 403) + private_build = self.env['runbot.build'].search([('params_id.project_id','=', self.private_project.id)]) + self.check_json_route(f'/runbot/json/builds/{private_build.id}', 403)