From da7e653f80d5c1947012afb92e945eb355d355c4 Mon Sep 17 00:00:00 2001 From: Jiri Kyjovsky Date: Wed, 3 Apr 2024 19:22:03 +0200 Subject: [PATCH] wip --- build_aux/Containerfile.unittest | 1 + .../coprs/views/apiv3_ns/apiv3_builds.py | 719 +++++++++++------- .../coprs/views/apiv3_ns/schema/fields.py | 50 +- .../coprs/views/apiv3_ns/schema/schemas.py | 130 +++- 4 files changed, 607 insertions(+), 293 deletions(-) diff --git a/build_aux/Containerfile.unittest b/build_aux/Containerfile.unittest index 631bba13d..4966ff863 100644 --- a/build_aux/Containerfile.unittest +++ b/build_aux/Containerfile.unittest @@ -7,6 +7,7 @@ RUN dnf update -y && \ rpmdevtools \ python3-copr \ copr-cli \ + fzf \ dnf-plugins-core && \ dnf clean all diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py index 8e5c365b4..1679b504e 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/apiv3_builds.py @@ -13,9 +13,12 @@ from copr_common.enums import StatusEnum from coprs import db, forms, models from coprs.exceptions import (BadRequest, AccessRestricted) -from coprs.views.misc import api_login_required -from coprs.views.apiv3_ns import apiv3_ns, api, rename_fields_helper -from coprs.views.apiv3_ns.schema.schemas import build_model +from coprs.views.misc import restx_api_login_required +from coprs.views.apiv3_ns import api, rename_fields_helper, deprecated_route_method_type +from coprs.views.apiv3_ns.schema.schemas import build_model, pagination_build_model, source_chroot_model, \ + source_build_config_model, list_build_params, create_build_url_input_model, create_build_upload_input_model, \ + create_build_scm_input_model, create_build_distgit_input_model, create_build_pypi_input_model, \ + create_build_rubygems_input_model, create_build_custom_input_model, delete_builds_input_model, list_build_model from coprs.views.apiv3_ns.schema.docs import get_build_docs from coprs.logic.complex_logic import ComplexLogic from coprs.logic.builds_logic import BuildsLogic @@ -23,15 +26,11 @@ from . import ( get_copr, - file_upload, - query_params, - pagination, SubqueryPaginator, json2form, - GET, - POST, - PUT, - DELETE, + query_to_parameters, + restx_pagination, + restx_file_upload, ) from .json2form import get_form_compatible_data @@ -84,13 +83,44 @@ def rename_fields(input_dict): }) -def render_build(build): - return flask.jsonify(to_dict(build)) +def process_creating_new_build(copr, form, create_new_build): + if not form.validate_on_submit(): + raise BadRequest("Bad request parameters: {0}".format(form.errors)) + + if not flask.g.user.can_build_in(copr): + raise AccessRestricted("User {} is not allowed to build in the copr: {}" + .format(flask.g.user.username, copr.full_name)) + form.isolation.data = "unchanged" if form.isolation.data is None else form.isolation.data + + generic_build_options = { + 'chroot_names': form.selected_chroots, + 'background': form.background.data, + 'copr_dirname': form.project_dirname.data, + 'timeout': form.timeout.data, + 'bootstrap': form.bootstrap.data, + 'isolation': form.isolation.data, + 'after_build_id': form.after_build_id.data, + 'with_build_id': form.with_build_id.data, + 'packit_forge_project': form.packit_forge_project.data + } + + if form.enable_net.data is not None: + generic_build_options['enable_net'] = form.enable_net.data + + # From URLs it can be created multiple builds at once + # so it can return a list + build = create_new_build(generic_build_options) + db.session.commit() + + if type(build) == list: + builds = [build] if type(build) != list else build + return {"items": [to_dict(b) for b in builds], "meta": {}} + + return to_dict(build) @apiv3_builds_ns.route("/") class GetBuild(Resource): - @apiv3_builds_ns.doc(params=get_build_docs) @apiv3_builds_ns.marshal_with(build_model) def get(self, build_id): @@ -101,6 +131,7 @@ def get(self, build_id): build = ComplexLogic.get_build(build_id) result = to_dict(build) + # TODO: I think this workaround is bad usage of models... check it later # Workaround - `marshal_with` needs the input `build_id` to be present # in the returned dict. Don't worry, it won't get to the end user, it # will be stripped away. @@ -108,304 +139,414 @@ def get(self, build_id): return result -@apiv3_ns.route("/build/list/", methods=GET) -@pagination() -@query_params() -def get_build_list(ownername, projectname, packagename=None, status=None, **kwargs): - copr = get_copr(ownername, projectname) - - # WORKAROUND - # We can't filter builds by status directly in the database, because we - # use a logic in Build.status property to determine a build status. - # Therefore if we want to filter by `status`, we need to query all builds - # and filter them in the application and then return the desired number. - limit = kwargs["limit"] - paginator_limit = None if status else kwargs["limit"] - del kwargs["limit"] - - # Loading relationships straight away makes running `to_dict` somewhat - # faster, which adds up over time, and brings a significant speedup for - # large projects - query = BuildsLogic.get_multiple() - query = query.options( - joinedload(models.Build.build_chroots), - joinedload(models.Build.package), - joinedload(models.Build.copr), - ) - - subquery = query.filter(models.Build.copr == copr) - if packagename: - subquery = BuildsLogic.filter_by_package_name(subquery, packagename) - - paginator = SubqueryPaginator(query, subquery, models.Build, limit=paginator_limit, **kwargs) - - builds = paginator.map(to_dict) - - if status: - builds = [b for b in builds if b["state"] == status][:limit] - paginator.limit = limit - - return flask.jsonify(items=builds, meta=paginator.meta) +@apiv3_builds_ns.route("/list") +class ListBuild(Resource): + @restx_pagination + @query_to_parameters + @apiv3_builds_ns.doc(params=list_build_params) + @apiv3_builds_ns.marshal_with(pagination_build_model) + def get(self, ownername, projectname, packagename=None, status=None, **kwargs): + """ + List builds + List all builds in a Copr project. + """ + copr = get_copr(ownername, projectname) + + # WORKAROUND + # We can't filter builds by status directly in the database, because we + # use a logic in Build.status property to determine a build status. + # Therefore if we want to filter by `status`, we need to query all builds + # and filter them in the application and then return the desired number. + limit = kwargs["limit"] + paginator_limit = None if status else kwargs["limit"] + del kwargs["limit"] + + # Loading relationships straight away makes running `to_dict` somewhat + # faster, which adds up over time, and brings a significant speedup for + # large projects + query = BuildsLogic.get_multiple() + query = query.options( + joinedload(models.Build.build_chroots), + joinedload(models.Build.package), + joinedload(models.Build.copr), + ) + subquery = query.filter(models.Build.copr == copr) + if packagename: + subquery = BuildsLogic.filter_by_package_name(subquery, packagename) -@apiv3_ns.route("/build/source-chroot//", methods=GET) -def get_source_chroot(build_id): - build = ComplexLogic.get_build(build_id) - return flask.jsonify(to_source_chroot(build)) + paginator = SubqueryPaginator(query, subquery, models.Build, limit=paginator_limit, **kwargs) + builds = paginator.map(to_dict) -@apiv3_ns.route("/build/source-build-config//", methods=GET) -def get_source_build_config(build_id): - build = ComplexLogic.get_build(build_id) - return flask.jsonify(to_source_build_config(build)) + if status: + builds = [b for b in builds if b["state"] == status][:limit] + paginator.limit = limit + return {"items": builds, "meta": paginator.meta} -@apiv3_ns.route("/build/built-packages//", methods=GET) -def get_build_built_packages(build_id): - """ - Return built packages (NEVRA dicts) for a given build - """ - build = ComplexLogic.get_build(build_id) - return flask.jsonify(build.results_dict) +@apiv3_builds_ns.route("/source-log/") +class SourceChroot(Resource): + @apiv3_builds_ns.doc(params=get_build_docs) + @apiv3_builds_ns.marshal_with(source_chroot_model) + def get(self, build_id): + """ + Get source chroot + Get source chroot for a build. + """ + build = ComplexLogic.get_build(build_id) + return to_source_chroot(build) -@apiv3_ns.route("/build/cancel/", methods=PUT) -@api_login_required -def cancel_build(build_id): - build = ComplexLogic.get_build(build_id) - BuildsLogic.cancel_build(flask.g.user, build) - db.session.commit() - return render_build(build) - - -@apiv3_ns.route("/build/create/url", methods=POST) -@api_login_required -def create_from_url(): - copr = get_copr() - data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) - form = forms.BuildFormUrlFactory(copr.active_chroots)(data, meta={'csrf': False}) - - def create_new_build(options): - # create separate build for each package - pkgs = form.pkgs.data.split("\n") - return [BuildsLogic.create_new_from_url( - flask.g.user, copr, - url=pkg, - **options, - ) for pkg in pkgs] - return process_creating_new_build(copr, form, create_new_build) - - -@apiv3_ns.route("/build/create/upload", methods=POST) -@api_login_required -@file_upload() -def create_from_upload(): - copr = get_copr() - data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) - form = forms.BuildFormUploadFactory(copr.active_chroots)(data, meta={'csrf': False}) - - def create_new_build(options): - return BuildsLogic.create_new_from_upload( - flask.g.user, copr, - form.pkgs, - orig_filename=secure_filename(form.pkgs.data.filename), - **options, - ) - return process_creating_new_build(copr, form, create_new_build) +@apiv3_builds_ns.route("/source-build-config/") +class SourceBuildConfig(Resource): + @apiv3_builds_ns.doc(params=get_build_docs) + @apiv3_builds_ns.marshal_with(source_build_config_model) + def get(self, build_id): + """ + Get source build config + Get source build config for a build. + """ + build = ComplexLogic.get_build(build_id) + return to_source_build_config(build) -@apiv3_ns.route("/build/check-before-build", methods=POST) -@api_login_required -def check_before_build(): - """ - Check if a build can be submitted (if the project exists, you have - permissions, the chroot exists, etc). This is useful before trying to - upload a large SRPM and failing to do so. - """ - data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) - # Raises an exception if project doesn't exist - copr = get_copr() +@apiv3_builds_ns.route("/build-packages/") +class BuildPackages(Resource): + @apiv3_builds_ns.doc( + params=get_build_docs, + # not marshalable b/c the dict key is dynamic + responses={200: '{"chroot_name": any_result_dict_or_value}'} + ) + def get(self, build_id): + """ + Get built packages + Get built packages (NEVRA dicts) for a given build + """ + build = ComplexLogic.get_build(build_id) + return build.results_dict - # Raises an exception if CoprDir doesn't exist - if data.get("project_dirname"): - CoprDirsLogic.get_or_validate(copr, data["project_dirname"]) - # Permissions check - if not flask.g.user.can_build_in(copr): - msg = ("User '{0}' is not allowed to build in '{1}'" - .format(flask.g.user.name, copr.full_name)) - raise AccessRestricted(msg) - - # Validation, i.e. check if chroot names are valid - # pylint: disable=not-callable - factory = forms.BuildFormCheckFactory(copr.active_chroots) - form = factory(data, meta={'csrf': False}) - if not form.validate_on_submit(): - raise BadRequest("Bad request parameters: {0}".format(form.errors)) +@apiv3_builds_ns.route("/cancel/") +class CancelBuild(Resource): + @staticmethod + def _common(build_id): + build = ComplexLogic.get_build(build_id) + BuildsLogic.cancel_build(flask.g.user, build) + db.session.commit() + return to_dict(build) - return {"message": "It should be safe to submit a build like this"} - - -@apiv3_ns.route("/build/create/scm", methods=POST) -@api_login_required -def create_from_scm(): - copr = get_copr() - data = rename_fields(get_form_compatible_data(preserve=["chroots", "exclude_chroots"])) - form = forms.BuildFormScmFactory(copr.active_chroots)(data, meta={'csrf': False}) - - def create_new_build(options): - return BuildsLogic.create_new_from_scm( - flask.g.user, - copr, - scm_type=form.scm_type.data, - clone_url=form.clone_url.data, - committish=form.committish.data, - subdirectory=form.subdirectory.data, - spec=form.spec.data, - srpm_build_method=form.srpm_build_method.data, - **options, - ) - return process_creating_new_build(copr, form, create_new_build) - -@apiv3_ns.route("/build/create/distgit", methods=POST) -@api_login_required -def create_from_distgit(): - """ - route for v3.proxies.create_from_distgit() call - """ - copr = get_copr() - data = rename_fields(get_form_compatible_data(preserve=["chroots", "exclude_chroots"])) - # pylint: disable=not-callable - form = forms.BuildFormDistGitSimpleFactory(copr.active_chroots)(data, meta={'csrf': False}) - - def create_new_build(options): - return BuildsLogic.create_new_from_distgit( - flask.g.user, - copr, - package_name=form.package_name.data, - distgit_name=form.distgit.data, - distgit_namespace=form.namespace.data, - committish=form.committish.data, - **options, - ) - return process_creating_new_build(copr, form, create_new_build) - -@apiv3_ns.route("/build/create/pypi", methods=POST) -@api_login_required -def create_from_pypi(): - copr = get_copr() - data = MultiDict(json2form.without_empty_fields(json2form.get_input())) - form = forms.BuildFormPyPIFactory(copr.active_chroots)(data, meta={'csrf': False}) - - # TODO: automatically prepopulate all form fields with their defaults - if not form.python_versions.data: - form.python_versions.data = form.python_versions.default - - def create_new_build(options): - return BuildsLogic.create_new_from_pypi( - flask.g.user, - copr, - form.pypi_package_name.data, - form.pypi_package_version.data, - form.spec_generator.data, - form.spec_template.data, - form.python_versions.data, - **options, - ) - return process_creating_new_build(copr, form, create_new_build) - - -@apiv3_ns.route("/build/create/rubygems", methods=POST) -@api_login_required -def create_from_rubygems(): - copr = get_copr() - data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) - form = forms.BuildFormRubyGemsFactory(copr.active_chroots)(data, meta={'csrf': False}) - - def create_new_build(options): - return BuildsLogic.create_new_from_rubygems( - flask.g.user, - copr, - form.gem_name.data, - **options, - ) - return process_creating_new_build(copr, form, create_new_build) - - -@apiv3_ns.route("/build/create/custom", methods=POST) -@api_login_required -def create_from_custom(): - copr = get_copr() - data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) - form = forms.BuildFormCustomFactory(copr.active_chroots)(data, meta={'csrf': False}) - - def create_new_build(options): - return BuildsLogic.create_new_from_custom( - flask.g.user, - copr, - form.script.data, - form.chroot.data, - form.builddeps.data, - form.resultdir.data, - form.repos.data, - **options, - ) - return process_creating_new_build(copr, form, create_new_build) + @restx_api_login_required + @apiv3_builds_ns.doc(params=get_build_docs) + @apiv3_builds_ns.marshal_with(build_model) + def put(self, build_id): + """ + Cancel a build + Cancel a build by its id. + """ + return self._common(build_id) + @restx_api_login_required + @apiv3_builds_ns.doc(params=get_build_docs) + @apiv3_builds_ns.marshal_with(build_model) + @deprecated_route_method_type(apiv3_builds_ns, "POST", "PUT") + def post(self, build_id): + """ + Cancel a build + Cancel a build by its id. + """ + return self._common(build_id) -def process_creating_new_build(copr, form, create_new_build): - if not form.validate_on_submit(): - raise BadRequest("Bad request parameters: {0}".format(form.errors)) - if not flask.g.user.can_build_in(copr): - raise AccessRestricted("User {} is not allowed to build in the copr: {}" - .format(flask.g.user.username, copr.full_name)) - form.isolation.data = "unchanged" if form.isolation.data is None else form.isolation.data +@apiv3_builds_ns.route("/create/url") +class CreateFromUrl(Resource): + @restx_api_login_required + @apiv3_builds_ns.expect(create_build_url_input_model) + @apiv3_builds_ns.marshal_with(build_model) + def post(self): + """ + Create a build from URL + Create a build from a URL. + """ + copr = get_copr() + data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) + # pylint: disable-next=not-callable + form = forms.BuildFormUrlFactory(copr.active_chroots)(data, meta={'csrf': False}) + + def create_new_build(options): + # create separate build for each package + pkgs = form.pkgs.data.split("\n") + return [BuildsLogic.create_new_from_url( + flask.g.user, copr, + url=pkg, + **options, + ) for pkg in pkgs] + + return process_creating_new_build(copr, form, create_new_build) + + +@apiv3_builds_ns.route("/create/upload") +class CreateFromUpload(Resource): + @restx_file_upload + @restx_api_login_required + @apiv3_builds_ns.expect(create_build_upload_input_model) + @apiv3_builds_ns.marshal_with(build_model) + def post(self): + """ + Create a build from upload + Create a build from an uploaded file. + """ + copr = get_copr() + data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) + # pylint: disable-next=not-callable + form = forms.BuildFormUploadFactory(copr.active_chroots)(data, meta={'csrf': False}) + + def create_new_build(options): + return BuildsLogic.create_new_from_upload( + flask.g.user, copr, + form.pkgs, + orig_filename=secure_filename(form.pkgs.data.filename), + **options, + ) + + return process_creating_new_build(copr, form, create_new_build) + + +@apiv3_builds_ns.route("/create/scm") +class CreateFromScm(Resource): + @restx_api_login_required + @apiv3_builds_ns.expect(create_build_scm_input_model) + @apiv3_builds_ns.marshal_with(build_model) + def post(self): + """ + Create a build from SCM + Create a build from a source code management system. + """ + copr = get_copr() + data = rename_fields(get_form_compatible_data(preserve=["chroots", "exclude_chroots"])) + # pylint: disable-next=not-callable + form = forms.BuildFormScmFactory(copr.active_chroots)(data, meta={'csrf': False}) + + def create_new_build(options): + return BuildsLogic.create_new_from_scm( + flask.g.user, + copr, + scm_type=form.scm_type.data, + clone_url=form.clone_url.data, + committish=form.committish.data, + subdirectory=form.subdirectory.data, + spec=form.spec.data, + srpm_build_method=form.srpm_build_method.data, + **options, + ) + + return process_creating_new_build(copr, form, create_new_build) + + +@apiv3_builds_ns.route("/create/distgit") +class CreateFromDistGit(Resource): + @restx_api_login_required + @apiv3_builds_ns.expect(create_build_distgit_input_model) + @apiv3_builds_ns.marshal_with(build_model) + def post(self): + """ + Create a build from DistGit + Create a build from a DistGit repository. + """ + copr = get_copr() + data = rename_fields(get_form_compatible_data(preserve=["chroots", "exclude_chroots"])) + # pylint: disable-next=not-callable + form = forms.BuildFormDistGitSimpleFactory(copr.active_chroots)(data, meta={'csrf': False}) + + def create_new_build(options): + return BuildsLogic.create_new_from_distgit( + flask.g.user, + copr, + package_name=form.package_name.data, + distgit_name=form.distgit.data, + distgit_namespace=form.namespace.data, + committish=form.committish.data, + **options, + ) + + return process_creating_new_build(copr, form, create_new_build) + + +@apiv3_builds_ns.route("/create/pypi") +class CreateFromPyPi(Resource): + @restx_api_login_required + @apiv3_builds_ns.expect(create_build_pypi_input_model) + @apiv3_builds_ns.marshal_with(build_model) + def post(self): + """ + Create a build from PyPi + Create a build from a PyPi package. + """ + copr = get_copr() + data = MultiDict(json2form.without_empty_fields(json2form.get_input())) + # pylint: disable-next=not-callable + form = forms.BuildFormPyPIFactory(copr.active_chroots)(data, meta={'csrf': False}) + + # TODO: automatically prepopulate all form fields with their defaults + if not form.python_versions.data: + form.python_versions.data = form.python_versions.default + + def create_new_build(options): + return BuildsLogic.create_new_from_pypi( + flask.g.user, + copr, + form.pypi_package_name.data, + form.pypi_package_version.data, + form.spec_generator.data, + form.spec_template.data, + form.python_versions.data, + **options, + ) + + return process_creating_new_build(copr, form, create_new_build) + + +@apiv3_builds_ns.route("/create/rubygems") +class CreateFromRubyGems(Resource): + @restx_api_login_required + @apiv3_builds_ns.expect(create_build_rubygems_input_model) + @apiv3_builds_ns.marshal_with(build_model) + def post(self): + """ + Create a build from RubyGems + Create a build from a RubyGems package. + """ + copr = get_copr() + data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) + # pylint: disable-next=not-callable + form = forms.BuildFormRubyGemsFactory(copr.active_chroots)(data, meta={'csrf': False}) + + def create_new_build(options): + return BuildsLogic.create_new_from_rubygems( + flask.g.user, + copr, + form.gem_name.data, + **options, + ) + + return process_creating_new_build(copr, form, create_new_build) + + +@apiv3_builds_ns.route("/create/custom") +class CreateCustom(Resource): + @restx_api_login_required + @apiv3_builds_ns.expect(create_build_custom_input_model) + @apiv3_builds_ns.marshal_with(build_model) + def post(self): + """ + Create a build using custom method + Create a build using a custom method. + """ + copr = get_copr() + data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) + # pylint: disable-next=not-callable + form = forms.BuildFormCustomFactory(copr.active_chroots)(data, meta={'csrf': False}) + + def create_new_build(options): + return BuildsLogic.create_new_from_custom( + flask.g.user, + copr, + form.script.data, + form.chroot.data, + form.builddeps.data, + form.resultdir.data, + form.repos.data, + **options, + ) + + return process_creating_new_build(copr, form, create_new_build) + + +@apiv3_builds_ns.route("/delete/") +class DeleteBuild(Resource): + @restx_api_login_required + @apiv3_builds_ns.doc(params=get_build_docs) + @apiv3_builds_ns.marshal_with(build_model) + def delete(self, build_id): + """ + Delete a build + Delete a build by its id. + """ + build = ComplexLogic.get_build(build_id) + build_dict = to_dict(build) + BuildsLogic.delete_build(flask.g.user, build) + db.session.commit() + return build_dict + + +@apiv3_builds_ns.route("/delete/list") +class DeleteBuilds(Resource): + @staticmethod + def _common(): + data = get_form_compatible_data(preserve=["builds"]) + build_ids = data["builds"] + BuildsLogic.delete_builds(flask.g.user, build_ids) + db.session.commit() + return {"builds": build_ids} + + @restx_api_login_required + @apiv3_builds_ns.expect(delete_builds_input_model) + @apiv3_builds_ns.marshal_with(list_build_model) + @deprecated_route_method_type(apiv3_builds_ns, "POST", "DELETE") + def post(self): + """ + Delete builds + Delete builds specified by a list of IDs. + """ + return self._common() - generic_build_options = { - 'chroot_names': form.selected_chroots, - 'background': form.background.data, - 'copr_dirname': form.project_dirname.data, - 'timeout': form.timeout.data, - 'bootstrap': form.bootstrap.data, - 'isolation': form.isolation.data, - 'after_build_id': form.after_build_id.data, - 'with_build_id': form.with_build_id.data, - 'packit_forge_project': form.packit_forge_project.data - } + @restx_api_login_required + @apiv3_builds_ns.expect(delete_builds_input_model) + @apiv3_builds_ns.marshal_with(list_build_model) + def delete(self): + """ + Delete builds + Delete builds specified by a list of IDs. + """ + return self._common() - if form.enable_net.data is not None: - generic_build_options['enable_net'] = form.enable_net.data - # From URLs it can be created multiple builds at once - # so it can return a list - build = create_new_build(generic_build_options) - db.session.commit() +@apiv3_builds_ns.route("/check-before-build") +# this endoint is not meant to be used by the end user +@apiv3_builds_ns.hide +class CheckBeforeBuild(Resource): + @restx_api_login_required + @apiv3_builds_ns.doc( + responses={200: {"message": "It should be safe to submit a build like this"}} + ) + def post(self): + """ + Check before build + Check if a build can be submitted (if the project exists, you have + permissions, the chroot exists, etc). This is useful before trying to + upload a large SRPM and failing to do so. + """ + data = get_form_compatible_data(preserve=["chroots", "exclude_chroots"]) - if type(build) == list: - builds = [build] if type(build) != list else build - return flask.jsonify(items=[to_dict(b) for b in builds], meta={}) - return flask.jsonify(to_dict(build)) + # Raises an exception if project doesn't exist + copr = get_copr() + # Raises an exception if CoprDir doesn't exist + if data.get("project_dirname"): + CoprDirsLogic.get_or_validate(copr, data["project_dirname"]) -@apiv3_ns.route("/build/delete/", methods=DELETE) -@api_login_required -def delete_build(build_id): - build = ComplexLogic.get_build(build_id) - build_dict = to_dict(build) - BuildsLogic.delete_build(flask.g.user, build) - db.session.commit() - return flask.jsonify(build_dict) + # Permissions check + if not flask.g.user.can_build_in(copr): + msg = ("User '{0}' is not allowed to build in '{1}'" + .format(flask.g.user.name, copr.full_name)) + raise AccessRestricted(msg) + # Validation, i.e. check if chroot names are valid + # pylint: disable=not-callable + factory = forms.BuildFormCheckFactory(copr.active_chroots) + form = factory(data, meta={'csrf': False}) + if not form.validate_on_submit(): + raise BadRequest("Bad request parameters: {0}".format(form.errors)) -@apiv3_ns.route("/build/delete/list", methods=POST) -@api_login_required -def delete_builds(): - """ - Delete builds specified by a list of IDs. - """ - build_ids = flask.request.json["builds"] - BuildsLogic.delete_builds(flask.g.user, build_ids) - db.session.commit() - return flask.jsonify({"builds": build_ids}) + return {"message": "It should be safe to submit a build like this"} diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py index 92634ade8..2c0f7f9e0 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/fields.py @@ -456,6 +456,52 @@ def __init__(self, example=None, **kwargs): modulemd = Raw(example="YAML file", description="Modulemd YAML file") +status = String( + example="succeeded", + description="Status of the build", +) + +chroot_names = List( + String, + description="List of chroot names", + example=["fedora-37-x86_64", "fedora-rawhide-x86_64"], +) + +background = is_background + +copr_dirname = project_dirname + +after_build_id = Integer( + description="Build after the batch containing the Build ID build", + example=123, +) + +with_build_id = Integer( + description="Build in the same batch with the Build ID build", + example=123, +) + +packit_forge_project = String( + description="Forge project name that Packit passes", + example="github.com/fedora-copr/copr", + # hide this so we don't confuse our users in the API docs + # packit uses this internally to check whether given packit build is allowed + # to build from the source upstream project into tis copr project + # packit_forge_project in packit_forge_projects_allowed + mask=True, +) + +distgit = String( + description="Dist-git URL we build against", + example="fedora", +) + +namespace = String( + description="DistGit namescape", + example="@copr/copr", +) + + # TODO: these needs description chroot_repos = Raw() @@ -468,10 +514,10 @@ def __init__(self, example=None, **kwargs): memory_limit = Integer() -distgit = String() - scmurl = Url() +result_url = Url() + # TODO: specify those only in Repo schema? baseurl = Url() diff --git a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py index a529725a0..ae587ab6a 100644 --- a/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py +++ b/frontend/coprs_frontend/coprs/views/apiv3_ns/schema/schemas.py @@ -85,7 +85,14 @@ def schema_attrs_from_fields(cls) -> dict[str, Any]: return result_schema @staticmethod - def _convert_schema_class_dict_to_schema(d: dict) -> dict: + def _should_be_item_candidate_to_delete(key: str, value: Any) -> bool: + return (key.startswith("_") or not isinstance(value, Raw)) or ( + # masking feature is missing in marshaling + hasattr(value, "mask") and value.mask + ) + + @classmethod + def _convert_schema_class_dict_to_schema(cls, d: dict) -> dict: """ Returns the same dictionary that was passed as param, doesn't create copy of it. """ @@ -103,7 +110,7 @@ def _convert_schema_class_dict_to_schema(d: dict) -> dict: keys_to_delete = [] for key, value in d.items(): - if key.startswith("_") or not isinstance(value, Raw): + if cls._should_be_item_candidate_to_delete(key, value): keys_to_delete.append(key) for key_to_delete in keys_to_delete: @@ -594,6 +601,111 @@ class Monitor(Schema): packages: List = List(Nested(_module_package_model)) +@dataclass +class SourceChroot(Schema): + state: String + result_url: Url + + +@dataclass +class SourceBuildConfig(Schema): + source_type: String + source_dict: Raw + memory_limit: Integer + timeout: Integer + is_background: Boolean + + +@dataclass +class ListBuild(ParamsSchema): + ownername: String + projectname: String + packagename: String + status: String + + @property + def required_attrs(self) -> list: + return [self.ownername, self.projectname] + + +@dataclass +class _GenericBuildOptions: + chroot_names: List + background: Boolean + timeout: Integer + bootstrap: String + isolation: String + after_build_id: Integer + with_build_id: Integer + packit_forge_project: String + enable_net: Boolean + + +@dataclass +class _BuildDataCommon: + ownername: String + projectname: String + + +@dataclass +class CreateBuildUrl(_BuildDataCommon, _GenericBuildOptions, InputSchema): + project_dirname: String + pkgs: List = List( + Url, + description="List of urls to build from", + example=["https://example.com/some.src.rpm"], + ) + + +@dataclass +class CreateBuildUpload(_BuildDataCommon, _GenericBuildOptions, InputSchema): + project_dirname: String + pkgs: List = List(Raw, description="application/x-rpm files to build from") + + +@dataclass +class CreateBuildSCM(_BuildDataCommon, _GenericBuildOptions, _SourceDictScmFields, InputSchema): + project_dirname: String + scm_type: String + source_build_method: String + + +@dataclass +class CreateBuildDistGit(_BuildDataCommon, _GenericBuildOptions, InputSchema): + distgit: String + namespace: String + package_name: String + committish: String + project_dirname: String + + +@dataclass +class CreateBuildPyPI(_BuildDataCommon, _GenericBuildOptions, SourceDictPyPI, InputSchema): + project_dirname: String + + +@dataclass +class CreateBuildRubyGems(_BuildDataCommon, _GenericBuildOptions, InputSchema): + project_dirname: String + gem_name: String + + +@dataclass +class CreateBuildCustom(_BuildDataCommon, _GenericBuildOptions, InputSchema): + script: String + chroot: String + builddeps: String + resultdir: String + project_dirname: String + repos: List = List(Nested(_repo_model)) + + +@dataclass +class DeleteBuilds(InputSchema): + build_ids: List = List(Integer, description="List of build ids to delete") + + + # OUTPUT MODELS project_chroot_model = ProjectChroot.get_cls().model() project_chroot_build_config_model = ProjectChrootBuildConfig.get_cls().model() @@ -608,10 +720,14 @@ class Monitor(Schema): webhook_secret_model = WebhookSecret.get_cls().model() monitor_model = Monitor.get_cls().model() can_build_in_model = CanBuildSchema.get_cls().model() +source_chroot_model = SourceChroot.get_cls().model() +source_build_config_model = SourceBuildConfig.get_cls().model() +list_build_model = DeleteBuilds.get_cls().model() pagination_project_model = Pagination(items=List(Nested(project_model))).model() pagination_build_chroot_model = Pagination(items=List(Nested(build_chroot_model))).model() pagination_package_model = Pagination(items=List(Nested(package_model))).model() +pagination_build_model = Pagination(items=List(Nested(_build_model))).model() source_package_model = _source_package_model build_model = _build_model @@ -630,6 +746,15 @@ class Monitor(Schema): project_delete_input_model = ProjectDelete.get_cls().input_model() module_add_input_model = ModuleAdd.get_cls().input_model() +create_build_url_input_model = CreateBuildUrl.get_cls().input_model() +create_build_upload_input_model = CreateBuildUpload.get_cls().input_model() +create_build_scm_input_model = CreateBuildSCM.get_cls().input_model() +create_build_distgit_input_model = CreateBuildDistGit.get_cls().input_model() +create_build_pypi_input_model = CreateBuildPyPI.get_cls().input_model() +create_build_rubygems_input_model = CreateBuildRubyGems.get_cls().input_model() +create_build_custom_input_model = CreateBuildCustom.get_cls().input_model() +delete_builds_input_model = DeleteBuilds.get_cls().input_model() + # PARAMETER SCHEMAS package_get_params = PackageGet.get_cls().params_schema() @@ -642,3 +767,4 @@ class Monitor(Schema): build_chroot_params = BuildChrootParams.get_cls().params_schema() build_id_params = {"build_id": build_chroot_params["build_id"]} can_build_params = CanBuildParams.get_cls().params_schema() +list_build_params = ListBuild.get_cls().params_schema()