#1525 Support more fine-grained bootstrap/image configuration
Merged 4 years ago by praiskup. Opened 4 years ago by praiskup.
Unknown source bootstrap-rfe  into  master

@@ -472,16 +472,6 @@

          rlRun "find . -type f | grep 'build.log'" 0

          cd - && rm -r $MYTMPDIR

  

-         # test use_bootstrap_container setting

-         rlRun "copr-cli create ${NAME_PREFIX}BootstrapProject --use-bootstrap on --chroot $CHROOT"

-         rlAssertEquals "" `curl --silent ${FRONTEND_URL}/api/coprs/${NAME_PREFIX}BootstrapProject/detail/ |jq '.detail.use_bootstrap_container'` true

-         rlRun -s "copr-cli build ${NAME_PREFIX}BootstrapProject $HELLO --nowait"

-         rlRun "parse_build_id"

-         rlRun "copr watch-build $BUILD_ID"

-         rlRun "curl $BACKEND_URL/results/${NAME_PREFIX}BootstrapProject/$CHROOT/`printf %08d $BUILD_ID`-hello/configs.tar.gz | tar xz -O '*configs/child.cfg' | grep \"config_opts\['use_bootstrap'\] = True\""

-         rlRun "copr-cli modify ${NAME_PREFIX}BootstrapProject --use-bootstrap off"

-         rlAssertEquals "" `curl --silent ${FRONTEND_URL}/api/coprs/${NAME_PREFIX}BootstrapProject/detail/ |jq '.detail.use_bootstrap_container'` false

- 

          ## test building in copr dirs

          rlRun "copr-cli create --chroot $CHROOT ${NAME_PREFIX}CoprDirTest"

          rlRun "copr-cli add-package-scm ${NAME_PREFIX}CoprDirTest --name example --clone-url $COPR_HELLO_GIT" 0

file modified
+1 -1
@@ -6,7 +6,7 @@

  %global with_python2 1

  %endif

  

- %global min_python_copr_version 1.105.1.dev

+ %global min_python_copr_version 1.105.2.dev

  

  Name:       copr-cli

  Version:    1.89

@@ -11,6 +11,21 @@

  include('/etc/mock/{{chroot}}.cfg')

  

  config_opts['root'] = '{{ rootdir }}'

+ 

+ {%- if bootstrap == "on" %}

+ config_opts['use_bootstrap'] = True

+ config_opts['use_bootstrap_image'] = False

+ {%- elif bootstrap == "off" %}

+ config_opts['use_bootstrap'] = False

+ config_opts['use_bootstrap_image'] = False

+ {%- elif bootstrap in ["image", "custom_image"] %}

+ config_opts['use_bootstrap'] = True

+ config_opts['use_bootstrap_image'] = True

+ {%- if bootstrap_image %}

+ config_opts['bootstrap_image'] = "{{ bootstrap_image }}"

+ {%- endif %}

+ {%- endif %}

+ 

  {%- if additional_packages %}

  config_opts['chroot_additional_packages'] = '

  {%- for pkg in additional_packages -%}

file modified
+64 -8
@@ -55,6 +55,14 @@

      None: None,

  }

  

+ BOOTSTRAP_MAP = {

+     "default": "default",

+     "on": "on",

+     "off": "off",

+     "image": "image",

+     None: "default",

+ }

+ 

  no_config_warning = """

  ================= WARNING: =======================

  File '{0}' is missing or incorrect.
@@ -414,7 +422,7 @@

              enable_net=ON_OFF_MAP[args.enable_net],

              persistent=args.persistent,

              auto_prune=ON_OFF_MAP[args.auto_prune],

-             use_bootstrap_container=ON_OFF_MAP[args.use_bootstrap_container],

+             bootstrap=BOOTSTRAP_MAP[args.bootstrap],

              delete_after_days=args.delete_after_days,

              multilib=ON_OFF_MAP[args.multilib],

              module_hotfixes=ON_OFF_MAP[args.module_hotfixes],
@@ -436,7 +444,7 @@

              unlisted_on_hp=ON_OFF_MAP[args.unlisted_on_hp],

              enable_net=ON_OFF_MAP[args.enable_net],

              auto_prune=ON_OFF_MAP[args.auto_prune],

-             use_bootstrap_container=ON_OFF_MAP[args.use_bootstrap_container],

+             bootstrap=BOOTSTRAP_MAP[args.bootstrap],

              chroots=args.chroots,

              delete_after_days=args.delete_after_days,

              multilib=ON_OFF_MAP[args.multilib],
@@ -589,11 +597,15 @@

  

          :param args: argparse arguments provided by the user

          """

+ 

+         if args.bootstrap_image:

+             args.bootstrap = 'image'

          owner, copr, chroot = self.parse_chroot_path(args.coprchroot)

-         project_chroot = self.client.project_chroot_proxy.edit(

+         self.client.project_chroot_proxy.edit(

              ownername=owner, projectname=copr, chrootname=chroot,

              comps=args.upload_comps, delete_comps=args.delete_comps,

-             additional_packages=args.packages, additional_repos=args.repos

+             additional_packages=args.packages, additional_repos=args.repos,

+             bootstrap=args.bootstrap, bootstrap_image=args.bootstrap_image,

          )

          print("Edit chroot operation was successful.")

  
@@ -937,8 +949,20 @@

      parser_create.add_argument("--auto-prune", choices=["on", "off"], default="on",

                                 help="If auto-deletion of project's obsoleted builds should be enabled (default is on).\

                                 This option can only be specified by a COPR admin.")

-     parser_create.add_argument("--use-bootstrap", choices=["on", "off"], dest="use_bootstrap_container",

-                                help="If mock bootstrap container is used to initialize the buildroot.")

+ 

+     parser_create.add_argument(

+         "--bootstrap",

+         choices=["default", "on", "off", "image"],

+         help=(

+             "Configure Mock's bootstrap feature (consult 'man mock' for more "

+             "info).  'on'/'off' enables/disables bootstrap.  The 'default' "

+             "variant uses pre-configured setup from mock-core-configs.  The "

+             "'image' variant enforces the bootstrap initialization from "

+             "the pre-configured container image (defined in "

+             "mock-core-configs.rpm)."

+         ),

+     )

+ 

      parser_create.add_argument("--delete-after-days", default=None, metavar='DAYS',

                                 help="Delete the project after the specfied period of time")

      parser_create.add_argument("--module-hotfixes", choices=["on", "off"], default="off",
@@ -974,8 +998,13 @@

      parser_modify.add_argument("--auto-prune", choices=["on", "off"],

                                 help="If auto-deletion of project's obsoleted builds should be enabled.\

                                 This option can only be specified by a COPR admin.")

-     parser_modify.add_argument("--use-bootstrap", choices=["on", "off"], dest="use_bootstrap_container",

-                                help="If mock bootstrap container is used to initialize the buildroot.")

+ 

+     parser_modify.add_argument(

+         "--bootstrap",

+         choices=["default", "on", "off", "image"],

+         help=("Configure Mock's bootstrap feature, "

+               "See 'create --help' for more info."))

+ 

      parser_modify.add_argument("--delete-after-days", default=None, metavar='DAYS',

                                 help=("Delete the project after the specfied "

                                       "period of time, empty or -1 disables, "
@@ -1109,11 +1138,22 @@

      parser_build_parent.add_argument("--background", dest="background", action="store_true", default=False,

                                       help="Mark the build as a background job. It will have lesser priority than regular builds.")

  

+     parser_build_parent.add_argument(

+         "--bootstrap",

+         choices=["unchanged", "default", "on", "off", "image"],

+         help=("Configure Mock's bootstrap feature, "

+               "default is 'unchanged' so the configuration from Copr project "

+               "and Copr chroot is used for this build. "

+               "The 'default' variant resets the project/chroot configuration "

+               "to the pre-configured setup from mock-core-configs. "

+               "See 'create --help' for more info."))

+ 

      # create the parser for the "build" (url/upload) command

      parser_build = subparsers.add_parser("build", parents=[parser_build_parent],

                                           help="Build packages to a specified copr")

      parser_build.add_argument("pkgs", nargs="+",

                                help="filename of SRPM or URL of packages to build")

+ 

      parser_build.set_defaults(func="action_build")

  

      # create the parser for the "buildpypi" command
@@ -1208,6 +1248,22 @@

                                        help="space separated string of package names to be added to buildroot")

      parser_edit_chroot.add_argument("--repos",

                                        help="space separated string of additional repo urls for chroot")

+ 

+     parser_edit_chroot.add_argument(

+         "--bootstrap",

+         choices=["unchanged", "default", "on", "off", "image"],

+         help=("Configure Mock's bootstrap feature, "

+               "default is 'unchanged' so the configuration from Copr project "

+               "is used for this chroot. "

+               "The 'default' variant resets the project configuration "

+               "to the pre-configured setup from mock-core-configs. "

+               "See 'create --help' for more info."))

+ 

+     parser_edit_chroot.add_argument(

+         "--bootstrap-image",

+         help=("Use a custom container image for initializing Mock's "

+               "bootstrap (Implies --bootstrap=image)"))

+ 

      parser_edit_chroot.set_defaults(func="action_edit_chroot")

  

      parser_get_chroot = subparsers.add_parser("get-chroot", help="Get chroot of a project")

file modified
+2 -2
@@ -370,7 +370,7 @@

          "instructions": "instruction string", "chroots": ["f20", "f21"],

          "additional_repos": ["repo1", "repo2"],

          "unlisted_on_hp": None, "devel_mode": None, "enable_net": False,

-         "use_bootstrap_container": None,

+         "bootstrap": "default",

          "delete_after_days": None,

          "multilib": False,

          "module_hotfixes": False,
@@ -401,7 +401,7 @@

          "chroots": ["fedora-rawhide-x86_64", "fedora-rawhide-i386"],

          "additional_repos": ["repo1", "repo2"],

          "unlisted_on_hp": None, "devel_mode": None, "enable_net": False,

-         "use_bootstrap_container": None,

+         'bootstrap': 'default',

          "delete_after_days": None,

          "multilib": True,

          "module_hotfixes": False,

@@ -72,6 +72,7 @@

  BuildRequires: python3-click

  BuildRequires: python3-CommonMark

  BuildRequires: python3-blinker

+ BuildRequires: python3-beautifulsoup4

  BuildRequires: python3-copr-common >= 0.7

  BuildRequires: python3-dateutil

  BuildRequires: python3-decorator

@@ -0,0 +1,68 @@

+ """

+ add bootstrap-config columns

+ 

+ Revision ID: 63db6872060f

+ Revises: de903581465c

+ Create Date: 2020-06-29 08:59:07.525039

+ """

+ 

+ import sqlalchemy as sa

+ from alembic import op

+ 

+ 

+ revision = '63db6872060f'

+ down_revision = 'de903581465c'

+ 

+ def upgrade():

+     op.add_column('copr', sa.Column('bootstrap', sa.Text()))

+     op.add_column('copr_chroot', sa.Column('bootstrap', sa.Text()))

+     op.add_column('copr_chroot', sa.Column('bootstrap_image', sa.Text()))

+     op.add_column('build', sa.Column('bootstrap', sa.Text()))

+ 

+ 

+     op.execute("""

+     UPDATE

+         copr

+     SET

+         bootstrap = 'on'

+     WHERE

+         use_bootstrap_container = true

+     """)

+ 

+     op.execute("""

+     UPDATE

+         copr

+     SET

+         bootstrap = 'off'

+     WHERE

+         use_bootstrap_container = false

+     """)

+ 

+     op.drop_column('copr', 'use_bootstrap_container')

+ 

+ def downgrade():

+     op.add_column('copr', sa.Column('use_bootstrap_container', sa.Boolean(),

+                                     nullable=False, server_default='f'))

+ 

+     op.execute("""

+     UPDATE

+         copr

+     SET

+         use_bootstrap_container = true

+     WHERE

+         bootstrap = 'on'

+     """)

+ 

+     op.execute("""

+     UPDATE

+         copr

+     SET

+         use_bootstrap_container = false

+     WHERE

+         bootstrap = 'off'

+     """)

+ 

+     op.drop_column('copr', 'bootstrap')

+     op.drop_column('copr_chroot', 'bootstrap')

+     op.drop_column('copr_chroot', 'bootstrap_image')

+     op.drop_column('build', 'bootstrap')

@@ -59,6 +59,61 @@

          raise exceptions.UnknownSourceTypeException("Invalid source type")

  

  

+ def create_mock_bootstrap_field(level):

+     """

+     Select-box for the bootstrap configuration in chroot/project form

+     """

+ 

+     choices = []

+     default_choices = [

+         ('default', 'Use default configuration from mock-core-configs.rpm'),

+         ('off', 'Disable'),

+         ('on', 'Enable'),

+         ('image', 'Initialize by default pre-configured container image'),

+     ]

+ 

+     if level == 'chroot':

+         choices.append(("unchanged", "Use project settings"))

+         choices.extend(default_choices)

+         choices.append(('custom_image',

+                         'Initialize by custom bootstrap image (specified '

+                         'in the "Mock bootstrap image" field below)'))

+ 

+     elif level == 'build':

+         choices.append(("unchanged", "Use project/chroot settings"))

+         choices.extend(default_choices)

+ 

+     else:

+         choices.extend(default_choices)

+ 

+     return wtforms.SelectField(

+         "Mock bootstrap",

+         choices=choices,

+         validators=[wtforms.validators.Optional()],

+         # Replace "None" with None (needed on Fedora <= 32)

+         filters=[NoneFilter(None)],

+     )

+ 

+ 

+ def create_mock_bootstrap_image_field():

I think these create_*_field() functions could probably be done better with custom fields
https://wtforms.readthedocs.io/en/2.3.x/fields/#custom-fields
but this works too.

+     """

+     Mandatory bootstrap-image field when the bootstrap select-box is set to a

+     custom image option.

+     """

+     return wtforms.TextField(

+         "Mock bootstrap image",

+         validators=[

+             wtforms.validators.Optional(),

+             wtforms.validators.Regexp(

+                 r"^\w+(:\w+)?$",

+                 message=("Enter valid bootstrap image id "

+                          "(<name>[:<tag>], e.g. fedora:33)."))],

+         filters=[

+             lambda x: None if not x else x

+         ],

+     )

+ 

+ 

  class MultiCheckboxField(wtforms.SelectMultipleField):

      widget = wtforms.widgets.ListWidget(prefix_label=False)

      option_widget = wtforms.widgets.CheckboxInput()
@@ -277,6 +332,12 @@

              return helpers.PermissionEnum("request")

          return helpers.PermissionEnum("nothing")

  

+ def _optional_checkbox_filter(data):

+     if data in [True, 'true']:

+         return True

+     if data in [False, 'false']:

+         return False

+     return None

  

  class CoprFormFactory(object):

  
@@ -367,14 +428,12 @@

                      newer build (with respect to package version) and it is

                      older than 14 days""")

  

-             use_bootstrap_container = wtforms.BooleanField(

-                     "Enable mock's use_bootstrap_container experimental feature",

-                     description="""This will make the build slower but it has an

-                     advantage that the dnf _from_ the given chroot will be used

-                     to setup the chroot (otherwise host system dnf and rpm is

-                     used)""",

-                     default=False,

-                     false_values=FALSE_VALUES)

+             use_bootstrap_container = wtforms.StringField(

+                 "backward-compat-only: old bootstrap",

+                 validators=[wtforms.validators.Optional()],

+                 filters=[_optional_checkbox_filter])

+ 

+             bootstrap = create_mock_bootstrap_field("project")

  

              follow_fedora_branching = wtforms.BooleanField(

                      "Follow Fedora branching",
@@ -534,6 +593,7 @@

              enable_net = wtforms.BooleanField(false_values=FALSE_VALUES)

              background = wtforms.BooleanField(false_values=FALSE_VALUES)

              project_dirname = wtforms.StringField(default=None)

+             bootstrap = create_mock_bootstrap_field("build")

  

          F.chroots_list = list(map(lambda x: x.name, active_chroots))

          F.chroots_list.sort()
@@ -1046,6 +1106,7 @@

          F.enable_net = wtforms.BooleanField(false_values=FALSE_VALUES)

          F.background = wtforms.BooleanField(default=False, false_values=FALSE_VALUES)

          F.project_dirname = wtforms.StringField(default=None)

+         F.bootstrap = create_mock_bootstrap_field("build")

  

          # Overrides BasePackageForm.package_name, it is usually unused for

          # building
@@ -1199,6 +1260,20 @@

      with_opts = wtforms.StringField("With options")

      without_opts = wtforms.StringField("Without options")

  

+     bootstrap = create_mock_bootstrap_field("chroot")

+     bootstrap_image = create_mock_bootstrap_image_field()

+ 

+     def validate(self, *args, **kwargs):  # pylint: disable=signature-differs

+         """ We need to special-case custom_image configuration """

+         result = super().validate(*args, **kwargs)

+         if self.bootstrap.data != "custom_image":

+             return result

+         if not self.bootstrap_image.data:

+             self.bootstrap_image.errors.append(

+                 "Custom image is selected, but not specified")

+             return False

+         return result

+ 

  

  class CoprChrootExtend(FlaskForm):

      extend = wtforms.StringField("Chroot name")
@@ -1288,6 +1363,7 @@

      disable_createrepo = wtforms.BooleanField(validators=[wtforms.validators.Optional()], false_values=FALSE_VALUES)

      unlisted_on_hp = wtforms.BooleanField(validators=[wtforms.validators.Optional()], false_values=FALSE_VALUES)

      auto_prune = wtforms.BooleanField(validators=[wtforms.validators.Optional()], false_values=FALSE_VALUES)

+     bootstrap = create_mock_bootstrap_field("project")

      use_bootstrap_container = wtforms.BooleanField(validators=[wtforms.validators.Optional()], false_values=FALSE_VALUES)

      follow_fedora_branching = wtforms.BooleanField(validators=[wtforms.validators.Optional()], false_values=FALSE_VALUES)

      follow_fedora_branching = wtforms.BooleanField(default=True, false_values=FALSE_VALUES)

@@ -598,6 +598,7 @@

              batch=batch,

              srpm_url=srpm_url,

              copr_dirname=copr_dirname,

+             bootstrap=build_options.get("bootstrap"),

          )

  

          if "timeout" in build_options:
@@ -609,7 +610,7 @@

      def add(cls, user, pkgs, copr, source_type=None, source_json=None,

              repos=None, chroots=None, timeout=None, enable_net=True,

              git_hashes=None, skip_import=False, background=False, batch=None,

-             srpm_url=None, copr_dirname=None):

+             srpm_url=None, copr_dirname=None, bootstrap=None):

  

          if chroots is None:

              chroots = []
@@ -660,6 +661,7 @@

              batch=batch,

              srpm_url=srpm_url,

              copr_dir=copr_dir,

+             bootstrap=bootstrap,

          )

  

          if timeout:

@@ -388,6 +388,7 @@

          for i in copr.copr_chroots:

              if i.mock_chroot.name == chroot_id:

                  chroot = i

+                 break

          if not chroot:

              return {}

  
@@ -412,15 +413,35 @@

          repos.extend(cls.get_additional_repo_views(copr.repos_list, chroot_id))

          repos.extend(cls.get_additional_repo_views(chroot.repos_list, chroot_id))

  

-         return {

+         config_dict = {

              'project_id': copr.repo_id,

              'additional_packages': packages.split(),

              'repos': repos,

              'chroot': chroot_id,

-             'use_bootstrap_container': copr.use_bootstrap_container,

              'with_opts': chroot.with_opts.split(),

              'without_opts': chroot.without_opts.split(),

          }

+         config_dict.update(chroot.bootstrap_setup)

+         return config_dict

+ 

+     @classmethod

+     def build_bootstrap_setup(cls, build_config, build):

+         """ Get bootstrap setup from build_config, and override it by build """

+         build_record = {}

+         build_record["bootstrap"] = build_config.get("bootstrap", "default")

+         build_record["bootstrap_image"] = build_config.get("bootstrap_image")

+ 

+         # config overrides per-build

+         if build.bootstrap_set:

+             build_record["bootstrap"] = build.bootstrap

+ 

+         # drop unnecessary (default) fields

+         if build_record["bootstrap"] == "default":

+             del build_record['bootstrap']

+             del build_record['bootstrap_image']

+         elif build_record["bootstrap"] != "custom_image":

+             del build_record['bootstrap_image']

+         return build_record

  

      @classmethod

      def get_additional_repo_views(cls, repos_list, chroot_id):

@@ -218,7 +218,8 @@

      @classmethod

      def add(cls, user, name, selected_chroots, repos=None, description=None,

              instructions=None, check_for_duplicates=False, group=None, persistent=False,

-             auto_prune=True, use_bootstrap_container=False, follow_fedora_branching=False, **kwargs):

+             auto_prune=True, bootstrap=None, follow_fedora_branching=False,

+             **kwargs):

  

          if not flask.g.user.admin and flask.g.user != user:

              msg = ("You were authorized as '{0}' user without permissions to access "
@@ -242,7 +243,7 @@

                             created_on=int(time.time()),

                             persistent=persistent,

                             auto_prune=auto_prune,

-                            use_bootstrap_container=use_bootstrap_container,

+                            bootstrap=bootstrap,

                             follow_fedora_branching=follow_fedora_branching,

                             **kwargs)

  
@@ -652,7 +653,8 @@

      @classmethod

      def create_chroot(cls, user, copr, mock_chroot, buildroot_pkgs=None, repos=None, comps=None, comps_name=None,

                        with_opts="", without_opts="",

-                       delete_after=None, delete_notify=None, module_toggle=""):

+                       delete_after=None, delete_notify=None, module_toggle="",

+                       bootstrap=None, bootstrap_image=None):

          """

          :type user: models.User

          :type mock_chroot: models.MockChroot
@@ -667,7 +669,8 @@

  

          chroot = models.CoprChroot(copr=copr, mock_chroot=mock_chroot)

          cls._update_chroot(buildroot_pkgs, repos, comps, comps_name, chroot,

-                            with_opts, without_opts, delete_after, delete_notify, module_toggle)

+                            with_opts, without_opts, delete_after, delete_notify,

+                            module_toggle, bootstrap, bootstrap_image)

  

          # reassign old build_chroots, if the chroot is re-created

          get_old = logic.builds_logic.BuildChrootsLogic.by_copr_and_mock_chroot
@@ -678,7 +681,8 @@

  

      @classmethod

      def update_chroot(cls, user, copr_chroot, buildroot_pkgs=None, repos=None, comps=None, comps_name=None,

-                       with_opts="", without_opts="", delete_after=None, delete_notify=None, module_toggle=""):

+                       with_opts="", without_opts="", delete_after=None, delete_notify=None, module_toggle="",

+                       bootstrap=None, bootstrap_image=None):

          """

          :type user: models.User

          :type copr_chroot: models.CoprChroot
@@ -688,12 +692,14 @@

              "Only owners and admins may update their projects.")

  

          cls._update_chroot(buildroot_pkgs, repos, comps, comps_name,

-                            copr_chroot, with_opts, without_opts, delete_after, delete_notify, module_toggle)

+                            copr_chroot, with_opts, without_opts, delete_after, delete_notify, module_toggle,

+                            bootstrap, bootstrap_image)

          return copr_chroot

  

      @classmethod

      def _update_chroot(cls, buildroot_pkgs, repos, comps, comps_name,

-                        copr_chroot, with_opts, without_opts, delete_after, delete_notify, module_toggle):

+                        copr_chroot, with_opts, without_opts, delete_after, delete_notify, module_toggle,

+                        bootstrap, bootstrap_image):

          if buildroot_pkgs is not None:

              copr_chroot.buildroot_pkgs = buildroot_pkgs

  
@@ -720,6 +726,16 @@

          if module_toggle is not None:

              copr_chroot.module_toggle = module_toggle

  

+         if bootstrap is not None:

+             copr_chroot.bootstrap = bootstrap

+ 

+         if bootstrap_image is not None:

+             # By CLI/API we can set custom_image, and keep bootstrap unset.  In

+             # such case set also bootstrap to correct value.

+             if not bootstrap:

+                 copr_chroot.bootstrap = 'custom_image'

+             copr_chroot.bootstrap_image = bootstrap_image

+ 

          db.session.add(copr_chroot)

  

      @classmethod

@@ -296,8 +296,7 @@

      # if backend deletion script should be run for the project's builds

      auto_prune = db.Column(db.Boolean, default=True, nullable=False, server_default="1")

  

-     # use mock's bootstrap container feature

-     use_bootstrap_container = db.Column(db.Boolean, default=False, nullable=False, server_default="0")

+     bootstrap = db.Column(db.Text, default="default")

  

      # if chroots for the new branch should be auto-enabled and populated from rawhide ones

      follow_fedora_branching = db.Column(db.Boolean, default=True, nullable=False, server_default="1")
@@ -921,6 +920,8 @@

      source_status = db.Column(db.Integer, default=StatusEnum("waiting"))

      srpm_url = db.Column(db.Text)

  

+     bootstrap = db.Column(db.Text)

+ 

      # relations

      user_id = db.Column(db.Integer, db.ForeignKey("user.id"), index=True)

      user = db.relationship("User", backref=db.backref("builds"))
@@ -1315,6 +1316,13 @@

      def source_is_uploaded(self):

          return self.source_type == helpers.BuildSourceEnum('upload')

  

+     @property

+     def bootstrap_set(self):

+         """ Is bootstrap config from project/chroot overwritten by build? """

+         if not self.bootstrap:

+             return False

+         return self.bootstrap != "unchanged"

+ 

  

  class DistGitBranch(db.Model, helpers.Serializer):

      """
@@ -1452,6 +1460,9 @@

      delete_after = db.Column(db.DateTime, index=True)

      delete_notify = db.Column(db.DateTime, index=True)

  

+     bootstrap = db.Column(db.Text)

+     bootstrap_image = db.Column(db.Text)

+ 

      def update_comps(self, comps_xml):

          if isinstance(comps_xml, str):

              data = comps_xml.encode("utf-8")
@@ -1513,6 +1524,20 @@

          d["mock_chroot"] = self.mock_chroot.name

          return d

  

+     @property

+     def bootstrap_setup(self):

+         """ Get Copr+CoprChroot consolidated bootstrap configuration """

+         settings = {}

+         settings['bootstrap'] = self.copr.bootstrap

+ 

+         if self.bootstrap and self.bootstrap != 'unchanged':

+             # overwrite project default with chroot config

+             settings['bootstrap'] = self.bootstrap

+             if settings['bootstrap'] == 'custom_image':

+                 settings['bootstrap_image'] = self.bootstrap_image

+         if settings['bootstrap'] in [None, "default"]:

+             return {}

+         return settings

  

  class BuildChroot(db.Model, helpers.Serializer):

      """

@@ -546,11 +546,35 @@

  </table>

  {% endmacro %}

  

+ {% macro render_bootstrap_options(form, build=False) %}

+ {{ render_field(form.bootstrap, placeholder='default') }}

+ {% if form.bootstrap_image %}

+ <div id="bootstrap_image_wrapper">

+ {{ render_field(form.bootstrap_image, placeholder='Used when "custom image" is set in Mock Bootstrap. Enter in <distribution>:<version> format (e.g. fedora:32)') }}

+ </div>

+ <script>

+   $(document).ready(function() {

+     if ($("#bootstrap").val() != "custom_image") {

+       $("#bootstrap_image_wrapper").hide();

+     }

+     $("#bootstrap").change(function() {

+       if (this.value == "custom_image") {

+         $("#bootstrap_image_wrapper").show();

+       }

+       else {

+         $("#bootstrap_image_wrapper").hide();

+       }

+     });

+   });

+ </script>

+ {% endif %}

+ {% endmacro %}

+ 

  

  {% macro render_additional_build_options(form, copr) %}

    <div class="form-group">

-     <label class="col-sm-2 control-label" for="textInput-markup" style="text-align: left">

-       Chroots

+     <label class="col-sm-2 control-label" for="textInput-markup">

+       Chroots:

      </label>

      <div class="col-sm-10" id="chroots">

      {% for group_set, chs in form.chroots_sets.items() %}
@@ -569,8 +593,8 @@

    </div>

    {{ form.csrf_token }}

    <div class="form-group">

-     <label class="col-sm-2 control-label" for="textInput-markup" style="text-align: left">

-       Timeout

+     <label class="col-sm-2 control-label" for="textInput-markup" >

+       Timeout:

      </label>

      <div class="col-sm-6">

        <input id="timeout" class="form-control" name="timeout" type="text" value="{{ form.timeout.default }}" >
@@ -579,9 +603,12 @@

        </li>

      </div>

    </div>

+ 

+   {{ render_field(form.bootstrap, placeholder='default') }}

+ 

    <div class="form-group">

-     <label class="col-sm-2 control-label" for="textInput-markup" style="text-align: left">

-     Other Options

+     <label class="col-sm-2 control-label" for="textInput-markup">

+     Other options:

      </label>

      <div class="col-sm-10">

        <div class="checkbox">

@@ -1,4 +1,6 @@

- {% from "_helpers.html" import render_field, render_checkbox_field, render_form_errors, copr_details_href, copr_url %}

+ {% from "_helpers.html" import render_field, render_checkbox_field,

+ render_form_errors, copr_details_href, copr_url, render_dropdown_field,

+ render_bootstrap_options %}

  

  {% macro copr_form(form, view, copr = None, username = None, group = None, comments = None) %}

    {# if using for updating, we need to pass name to url_for, but otherwise we need to pass nothing #}
@@ -142,7 +144,6 @@

          [form.auto_prune, g.user.admin],

          [form.unlisted_on_hp],

          [form.persistent, g.user.admin],

-         [form.use_bootstrap_container],

          [form.follow_fedora_branching],

          [form.multilib],

          [form.module_hotfixes],
@@ -153,6 +154,9 @@

              placeholder='Optional',

              info='Delete the project after the specfied period of time (empty = disabled)') }}

  

+ 

+     {{ render_bootstrap_options(form) }}

+ 

      {{ render_field(form.runtime_dependencies, rows=5, cols=50, placeholder='Optional - URL to additional yum repos, which can be used as runtime dependencies. Space separated. This should be baseurl from .repo file. E.g.: http://copr-be.cloud.fedoraproject.org/results/rhughes/f20-gnome-3-12/fedora-$releasever-$basearch/') }}

      </div>

    </div>

@@ -1,5 +1,6 @@

  {% extends "coprs/detail.html" %}

- {% from "_helpers.html" import render_field, copr_url, copr_name %}

+ {% from "_helpers.html" import render_field, copr_url, copr_name,

+     render_bootstrap_options %}

  

  {% block title %}Editing {{ copr_name(copr) }}/{{ chroot.name }}{% endblock %}

  {%block project_breadcrumb%}
@@ -56,6 +57,8 @@

         )

      }}

  

+     {{ render_bootstrap_options(form) }}

+ 

      <div class="form-group">

        <label class="col-sm-2 control-label" for="textInput-markup">

          comps.xml

@@ -139,9 +139,14 @@

          if "auto_prune" in flask.request.form:

              auto_prune = form.auto_prune.data

  

-         use_bootstrap_container = True

+         # This is old route (apiv1) which nobody should use, so we don't

+         # implement the 'bootstrap' and 'bootstrap_image' options here.

+         use_bootstrap_container = None

          if "use_bootstrap_container" in flask.request.form:

              use_bootstrap_container = form.use_bootstrap_container.data

+         bootstrap = None

+         if use_bootstrap_container is not None:

+             bootstrap = "on" if use_bootstrap_container else "off"

  

          try:

              copr = CoprsLogic.add(
@@ -158,7 +163,7 @@

                  group=group,

                  persistent=form.persistent.data,

                  auto_prune=auto_prune,

-                 use_bootstrap_container=use_bootstrap_container,

+                 bootstrap=bootstrap,

              )

              infos.append("New project was successfully created.")

  
@@ -344,7 +349,7 @@

          "persistent": copr.persistent,

          "unlisted_on_hp": copr.unlisted_on_hp,

          "auto_prune": copr.auto_prune,

-         "use_bootstrap_container": copr.use_bootstrap_container,

+         "use_bootstrap_container": copr.bootstrap == "on",

      }

      return flask.jsonify(output)

  
@@ -522,6 +527,10 @@

  @api_req_with_copr

  def copr_new_build_scm(copr):

      form = forms.BuildFormScmFactory(copr.active_chroots)(meta={'csrf': False})

+     # We just want 'disable=duplicate-code' because this is a C&P with APIv3,

+     # though it doesn't actually work with PyLint, see

+     # https://github.com/PyCQA/pylint/issues/214

+     # pylint: disable=all

  

      def create_new_build():

          return BuildsLogic.create_new_from_scm(
@@ -697,7 +706,7 @@

      if "auto_prune" in flask.request.form:

          copr.auto_prune = form.auto_prune.data

      if "use_bootstrap_container" in flask.request.form:

-         copr.use_bootstrap_container = form.use_bootstrap_container.data

+         copr.bootstrap = "on" if form.use_bootstrap_container.data else "off"

      if "chroots" in  flask.request.form:

          coprs_logic.CoprChrootsLogic.update_from_names(

              flask.g.user, copr, form.chroots.data)

@@ -20,11 +20,10 @@

  def build_config(build_chroot):

      config = BuildConfigLogic.generate_build_config(build_chroot.build.copr, build_chroot.name)

      copr_chroot = CoprChrootsLogic.get_by_name_safe(build_chroot.build.copr, build_chroot.name)

-     return {

+     dict_data = {

          "repos": config.get("repos"),

          "additional_repos": BuildConfigLogic.generate_additional_repos(copr_chroot),

          "additional_packages": config.get("additional_packages"),

-         "use_bootstrap_container": config.get("use_bootstrap_container"),

          "with_opts": config.get("with_opts"),

          "without_opts": config.get("without_opts"),

          "memory_limit": build_chroot.build.memory_reqs,
@@ -32,6 +31,9 @@

          "enable_net": build_chroot.build.enable_net,

          "is_background": build_chroot.build.is_background,

      }

+     dict_data.update(

+         BuildConfigLogic.build_bootstrap_setup(config, build_chroot.build))

+     return dict_data

  

  

  @apiv3_ns.route("/build-chroot/<int:build_id>/<chrootname>", methods=GET)

@@ -130,16 +130,13 @@

      data = get_form_compatible_data()

      form = forms.BuildFormUrlFactory(copr.active_chroots)(data, meta={'csrf': False})

  

-     def create_new_build():

+     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,

-             chroot_names=form.selected_chroots,

-             background=form.background.data,

-             copr_dirname=form.project_dirname.data,

-             timeout=form.timeout.data,

+             **options,

          ) for pkg in pkgs]

      return process_creating_new_build(copr, form, create_new_build)

  
@@ -152,15 +149,12 @@

      data = get_form_compatible_data()

      form = forms.BuildFormUploadFactory(copr.active_chroots)(data, meta={'csrf': False})

  

-     def create_new_build():

+     def create_new_build(options):

          return BuildsLogic.create_new_from_upload(

              flask.g.user, copr,

              f_uploader=lambda path: form.pkgs.data.save(path),

              orig_filename=secure_filename(form.pkgs.data.filename),

-             chroot_names=form.selected_chroots,

-             background=form.background.data,

-             copr_dirname=form.project_dirname.data,

-             timeout=form.timeout.data,

+             **options,

          )

      return process_creating_new_build(copr, form, create_new_build)

  
@@ -172,7 +166,7 @@

      data = rename_fields(get_form_compatible_data())

      form = forms.BuildFormScmFactory(copr.active_chroots)(data, meta={'csrf': False})

  

-     def create_new_build():

+     def create_new_build(options):

          return BuildsLogic.create_new_from_scm(

              flask.g.user,

              copr,
@@ -182,10 +176,7 @@

              subdirectory=form.subdirectory.data,

              spec=form.spec.data,

              srpm_build_method=form.srpm_build_method.data,

-             chroot_names=form.selected_chroots,

-             background=form.background.data,

-             copr_dirname=form.project_dirname.data,

-             timeout=form.timeout.data,

+             **options,

          )

      return process_creating_new_build(copr, form, create_new_build)

  
@@ -200,7 +191,7 @@

      # pylint: disable=not-callable

      form = forms.BuildFormDistGitSimpleFactory(copr.active_chroots)(data, meta={'csrf': False})

  

-     def create_new_build():

+     def create_new_build(options):

          return BuildsLogic.create_new_from_distgit(

              flask.g.user,

              copr,
@@ -208,9 +199,7 @@

              distgit_name=form.distgit.data,

              distgit_namespace=form.namespace.data,

              committish=form.committish.data,

-             chroot_names=form.selected_chroots,

-             copr_dirname=form.project_dirname.data,

-             background=form.background.data,

+             **options,

          )

      return process_creating_new_build(copr, form, create_new_build)

  
@@ -225,7 +214,7 @@

      if not form.python_versions.data:

          form.python_versions.data = form.python_versions.default

  

-     def create_new_build():

+     def create_new_build(options):

          return BuildsLogic.create_new_from_pypi(

              flask.g.user,

              copr,
@@ -233,10 +222,7 @@

              form.pypi_package_version.data,

              form.spec_template.data,

              form.python_versions.data,

-             form.selected_chroots,

-             background=form.background.data,

-             copr_dirname=form.project_dirname.data,

-             timeout=form.timeout.data,

+             **options,

          )

      return process_creating_new_build(copr, form, create_new_build)

  
@@ -248,15 +234,12 @@

      data = get_form_compatible_data()

      form = forms.BuildFormRubyGemsFactory(copr.active_chroots)(data, meta={'csrf': False})

  

-     def create_new_build():

+     def create_new_build(options):

          return BuildsLogic.create_new_from_rubygems(

              flask.g.user,

              copr,

              form.gem_name.data,

-             form.selected_chroots,

-             background=form.background.data,

-             copr_dirname=form.project_dirname.data,

-             timeout=form.timeout.data,

+             **options,

          )

      return process_creating_new_build(copr, form, create_new_build)

  
@@ -268,7 +251,7 @@

      data = get_form_compatible_data()

      form = forms.BuildFormCustomFactory(copr.active_chroots)(data, meta={'csrf': False})

  

-     def create_new_build():

+     def create_new_build(options):

          return BuildsLogic.create_new_from_custom(

              flask.g.user,

              copr,
@@ -276,10 +259,7 @@

              form.chroot.data,

              form.builddeps.data,

              form.resultdir.data,

-             chroot_names=form.selected_chroots,

-             background=form.background.data,

-             copr_dirname=form.project_dirname.data,

-             timeout=form.timeout.data,

+             **options,

          )

      return process_creating_new_build(copr, form, create_new_build)

  
@@ -292,9 +272,17 @@

          raise AccessRestricted("User {} is not allowed to build in the copr: {}"

                                 .format(flask.g.user.username, copr.full_name))

  

+     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,

+     }

+ 

      # From URLs it can be created multiple builds at once

      # so it can return a list

-     build = create_new_build()

+     build = create_new_build(generic_build_options)

      db.session.commit()

  

      if type(build) == list:

@@ -25,16 +25,19 @@

  

  def to_build_config_dict(project_chroot):

      config = BuildConfigLogic.generate_build_config(project_chroot.copr, project_chroot.name)

-     return {

+     config_dict = {

          "chroot": project_chroot.name,

          "repos": config["repos"],

          "additional_repos": BuildConfigLogic.generate_additional_repos(project_chroot),

          "additional_packages": (project_chroot.buildroot_pkgs or "").split(),

-         "use_bootstrap_container": project_chroot.copr.use_bootstrap_container,

          "enable_net": project_chroot.copr.enable_net,

          "with_opts":  str_to_list(project_chroot.with_opts),

          "without_opts": str_to_list(project_chroot.without_opts),

      }

+     for option in ['bootstrap', 'bootstrap_image']:

+         if option in config:

+             config_dict[option] = config[option]

+     return config_dict

  

  

  def rename_fields(input):
@@ -101,6 +104,8 @@

          CoprChrootsLogic.remove_comps(flask.g.user, chroot)

      CoprChrootsLogic.update_chroot(

          flask.g.user, chroot, buildroot_pkgs, repos, comps=comps_xml, comps_name=comps_name,

-         with_opts=with_opts, without_opts=without_opts)

+         with_opts=with_opts, without_opts=without_opts,

+         bootstrap=form.bootstrap.data,

+         bootstrap_image=form.bootstrap_image.data)

      db.session.commit()

      return flask.jsonify(to_dict(chroot))

@@ -29,7 +29,7 @@

          "chroot_repos": CoprsLogic.get_yum_repos(copr, empty=True),

          "additional_repos": copr.repos_list,

          "enable_net": copr.build_enable_net,

-         "use_bootstrap_container": copr.use_bootstrap_container,

+         "bootstrap": copr.bootstrap,

          "module_hotfixes": copr.module_hotfixes,

      }

  
@@ -127,6 +127,14 @@

          raise BadRequest(form.errors)

      validate_chroots(get_input_dict(), MockChrootsLogic.get_multiple())

  

+     bootstrap = None

+     # backward compatibility

+     use_bootstrap_container = form.use_bootstrap_container.data

+     if use_bootstrap_container is not None:

+         bootstrap = "on" if use_bootstrap_container else "off"

+     if form.bootstrap.data is not None:

+         bootstrap = form.bootstrap.data

+ 

      try:

          copr = CoprsLogic.add(

              name=form.name.data.strip(),
@@ -141,7 +149,7 @@

              group=group,

              persistent=form.persistent.data,

              auto_prune=form.auto_prune.data,

-             use_bootstrap_container=form.use_bootstrap_container.data,

+             bootstrap=bootstrap,

              homepage=form.homepage.data,

              contact=form.contact.data,

              disable_createrepo=form.disable_createrepo.data,

@@ -125,10 +125,13 @@

          build_config = BuildConfigLogic.generate_build_config(task.build.copr, task.mock_chroot.name)

          build_record["repos"] = build_config.get("repos")

          build_record["buildroot_pkgs"] = build_config.get("additional_packages")

-         build_record["use_bootstrap_container"] = build_config.get("use_bootstrap_container")

          build_record["with_opts"] = build_config.get("with_opts")

          build_record["without_opts"] = build_config.get("without_opts")

  

+         bch_bootstrap = BuildConfigLogic.build_bootstrap_setup(

+             build_config, task.build)

+         build_record.update(bch_bootstrap)

+ 

      except Exception as err:

          app.logger.exception(err)

          return None

@@ -119,6 +119,7 @@

          build_options = {

              "enable_net": form.enable_net.data,

              "timeout": form.timeout.data,

+             "bootstrap": form.bootstrap.data,

          }

  

          try:

@@ -19,26 +19,28 @@

  @login_required

  @req_with_copr

  def chroot_edit(copr, chrootname):

-     return render_chroot_edit(copr, chrootname)

- 

+     """ Route for editing CoprChroot's """

+     chroot = ComplexLogic.get_copr_chroot_safe(copr, chrootname)

+     form = forms.ChrootForm(buildroot_pkgs=chroot.buildroot_pkgs, repos=chroot.repos,

+                             module_toggle=chroot.module_toggle, with_opts=chroot.with_opts,

+                             without_opts=chroot.without_opts,

+                             bootstrap=chroot.bootstrap,

+                             bootstrap_image=chroot.bootstrap_image)

+     return render_chroot_edit(form, copr, chroot)

  

- def render_chroot_edit(copr, chroot_name):

-     chroot = ComplexLogic.get_copr_chroot_safe(copr, chroot_name)

  

-     # todo: get COPR_chroot, not mock chroot, WTF?!

-     # form = forms.ChrootForm(buildroot_pkgs=copr.buildroot_pkgs(chroot))

+ def render_chroot_edit(form, copr, chroot):

+     """

+     Using a pre-filled form, copr and chroot instances, render the

+     edit_chroot.html template.

+     """

  

-     form = forms.ChrootForm(buildroot_pkgs=chroot.buildroot_pkgs, repos=chroot.repos,

-                             module_toggle=chroot.module_toggle, with_opts=chroot.with_opts,

-                             without_opts=chroot.without_opts)

-     # FIXME - test if chroot belongs to copr

      if flask.g.user.can_build_in(copr):

          return render_template("coprs/detail/edit_chroot.html",

                                 form=form, copr=copr, chroot=chroot)

-     else:

-         raise AccessRestricted(

-             "You are not allowed to modify chroots in project {0}."

-             .format(copr.name))

+     raise AccessRestricted(

+         "You are not allowed to modify chroots in project {0}."

+         .format(copr.name))

  

  

  @coprs_ns.route("/<username>/<coprname>/update_chroot/<chrootname>/", methods=["POST"])
@@ -46,11 +48,7 @@

  @login_required

  @req_with_copr

  def chroot_update(copr, chrootname):

-     return process_chroot_update(copr, chrootname)

- 

- 

- def process_chroot_update(copr, chroot_name):

- 

+     chroot_name = chrootname

      form = forms.ChrootForm()

      chroot = ComplexLogic.get_copr_chroot_safe(copr, chroot_name)

  
@@ -59,38 +57,39 @@

              "You are not allowed to modify chroots in project {0}."

              .format(copr.name))

  

-     if form.validate_on_submit():

-         if "submit" in flask.request.form:

-             action = flask.request.form["submit"]

-             if action == "update":

-                 comps_name = comps_xml = None

- 

-                 if form.comps.has_file():

-                     comps_xml = form.comps.data.stream.read()

-                     comps_name = form.comps.data.filename

- 

-                 coprs_logic.CoprChrootsLogic.update_chroot(

-                     flask.g.user, chroot,

-                     form.buildroot_pkgs.data,

-                     form.repos.data,

-                     comps=comps_xml, comps_name=comps_name,

-                     with_opts=form.with_opts.data, without_opts=form.without_opts.data,

-                     module_toggle=form.module_toggle.data

-                 )

- 

-             elif action == "delete_comps":

-                 CoprChrootsLogic.remove_comps(flask.g.user, chroot)

- 

-             flask.flash(

-                 "Buildroot {0} in project {1} has been updated successfully.".format(

-                     chroot_name, copr.name), 'success')

- 

-             db.session.commit()

-         return flask.redirect(url_for_copr_edit(copr))

- 

-     else:

-         flask.flash(form.errors, "error")

-         return render_chroot_edit(copr, chroot_name)

+     if not form.validate_on_submit():

+         flask.flash(form.errors, "error")  # pylint: disable=no-member

+         return render_chroot_edit(form, copr, chroot)

+ 

+     if "submit" in flask.request.form:

+         action = flask.request.form["submit"]

+         if action == "update":

+             comps_name = comps_xml = None

+ 

+             if form.comps.has_file():

+                 comps_xml = form.comps.data.stream.read()

+                 comps_name = form.comps.data.filename

+ 

+             coprs_logic.CoprChrootsLogic.update_chroot(

+                 flask.g.user, chroot,

+                 form.buildroot_pkgs.data,

+                 form.repos.data,

+                 comps=comps_xml, comps_name=comps_name,

+                 with_opts=form.with_opts.data, without_opts=form.without_opts.data,

+                 module_toggle=form.module_toggle.data,

+                 bootstrap=form.bootstrap.data,

+                 bootstrap_image=form.bootstrap_image.data,

+             )

+ 

+         elif action == "delete_comps":

+             CoprChrootsLogic.remove_comps(flask.g.user, chroot)

+ 

+         flask.flash(

+             "Buildroot {0} in project {1} has been updated successfully.".format(

+                 chroot_name, copr.name), 'success')

+ 

+         db.session.commit()

+     return flask.redirect(url_for_copr_edit(copr))

  

  

  @coprs_ns.route("/<username>/<coprname>/chroot/<chrootname>/comps/")

@@ -191,11 +191,11 @@

                  group=group,

                  persistent=form.persistent.data,

                  auto_prune=(form.auto_prune.data if flask.g.user.admin else True),

-                 use_bootstrap_container=form.use_bootstrap_container.data,

                  follow_fedora_branching=form.follow_fedora_branching.data,

                  delete_after_days=form.delete_after_days.data,

                  multilib=form.multilib.data,

                  runtime_dependencies=form.runtime_dependencies.data.replace("\n", " "),

+                 bootstrap=form.bootstrap.data,

              )

  

              db.session.commit()
@@ -503,12 +503,12 @@

      copr.disable_createrepo = form.disable_createrepo.data

      copr.build_enable_net = form.build_enable_net.data

      copr.unlisted_on_hp = form.unlisted_on_hp.data

-     copr.use_bootstrap_container = form.use_bootstrap_container.data

      copr.follow_fedora_branching = form.follow_fedora_branching.data

      copr.delete_after_days = form.delete_after_days.data

      copr.multilib = form.multilib.data

      copr.module_hotfixes = form.module_hotfixes.data

      copr.runtime_dependencies = form.runtime_dependencies.data.replace("\n", " ")

+     copr.bootstrap = form.bootstrap.data

      if flask.g.user.admin:

          copr.auto_prune = form.auto_prune.data

      else:

@@ -6,6 +6,7 @@

  from functools import wraps

  import datetime

  import uuid

+ from unittest import mock

  

  import pytest

  import decorator
@@ -19,13 +20,14 @@

  from coprs.logic.coprs_logic import BranchesLogic, CoprChrootsLogic

  from coprs.logic.dist_git_logic import DistGitLogic

  

- from unittest import mock

- 

+ from tests.request_test_api import WebUIRequests, API3Requests

  

  class CoprsTestCase(object):

  

-     # made available by TransactionDecorator

+     # These are made available by TransactionDecorator() decorator

      test_client = None

+     transaction_user = None

+     transaction_username = None

  

      original_config = coprs.app.config.copy()

  
@@ -70,6 +72,9 @@

          self.rmodel_TSE_coprs_general_mc.return_value.get_count.return_value = 0

          self.rmodel_TSE_coprs_general_mc.return_value.add_event.return_value = None

  

+         self.web_ui = WebUIRequests(self)

+         self.api3 = API3Requests(self)

+ 

      def teardown_method(self, method):

          # delete just data, not the tables

          self.db.session.rollback()
@@ -842,10 +847,12 @@

      def __call__(self, fn):

          @wraps(fn)

          def wrapper(fn, fn_self, *args):

-             username = getattr(fn_self, self.user).username

+             user = getattr(fn_self, self.user)

+             fn_self.transaction_user = user

+             fn_self.transaction_username = user.username

              with fn_self.tc as fn_self.test_client:

                  with fn_self.test_client.session_transaction() as session:

-                     session["openid"] = username

+                     session["openid"] = user.username

                  return fn(fn_self, *args)

          return decorator.decorator(wrapper, fn)

  

@@ -0,0 +1,214 @@

+ """

+ Library that simplifies interacting with Frontend routes.

+ """

+ 

+ from bs4 import BeautifulSoup

+ 

+ 

+ def parse_web_form_error(html_text):

+     """ return the list of form errors from failed form page """

+     soup = BeautifulSoup(html_text, "html.parser")

+     alerts = soup.findAll('div', class_='alert alert-danger')

+     assert len(alerts) == 1

+     div = alerts[0]

+     return [li.text for li in div.find_all("li")]

+ 

+ 

+ class _RequestsInterface:

+     success_expected = True

+ 

+     def __init__(self, test_class_object):

+         self.test_class_object = test_class_object

+ 

+     @property

+     def client(self):

+         """ Initialized flask http client """

+         return self.test_class_object.test_client

+ 

+     @property

+     def transaction_username(self):

+         """

+         The name of the user we work with;  this only works if

+         TransactionDecorator() is used

+         """

+         return self.test_class_object.transaction_username

+ 

+     def new_project(self, name, chroots, bootstrap=None):

+         """ Request Copr project creation.  Return the resonse. """

+         raise NotImplementedError

+ 

+     def edit_chroot(self, project, chroot, bootstrap=None,

+                     bootstrap_image=None, owner=None):

+         """ Modify CoprChroot """

+         raise NotImplementedError

+ 

+     def create_distgit_package(self, project, pkgname):

+         """ Modify CoprChroot """

+         raise NotImplementedError

+ 

+     def submit_url_build(self, project, urls=None, build_options=None):

+         """ Submit build using a Source RPM or SPEC URL """

+         if urls is None:

+             urls = "https://example.com/some.src.rpm"

+         return self._submit_url_build(project, urls, build_options)

+ 

+     def _submit_url_build(self, project, urls, build_options):

+         raise NotImplementedError

+ 

+ 

+ class WebUIRequests(_RequestsInterface):

+     """ Mimic Web UI request behavior """

+ 

+     def new_project(self, name, chroots, bootstrap=None):

+         data = {"name": name}

+         for ch in chroots:

+             data[ch] = 'y'

+         if bootstrap is not None:

+             data["bootstrap"] = bootstrap

+         resp = self.client.post(

+             "/coprs/{0}/new/".format(self.transaction_username),

+             data=data,

+             follow_redirects=False,

+         )

+         # Errors are shown on the same page (HTTP 200), while successful

+         # form submit is redirected (HTTP 302).

+         assert resp.status_code == 302 if self.success_expected else 200

+         return resp

+ 

+     def edit_chroot(self, project, chroot, bootstrap=None,

+                     bootstrap_image=None, owner=None):

+         """ Change CoprChroot using the web-UI """

+         route = "/coprs/{user}/{project}/update_chroot/{chroot}/".format(

+             user=owner or self.transaction_username,

+             project=project,

+             chroot=chroot,

+         )

+ 

+         # this is hack, submit needs to have a value, check

+         # the chroot_update() route for more info

+         data = {"submit": "update"}

+ 

+         if bootstrap is not None:

+             data["bootstrap"] = bootstrap

+         if bootstrap_image is not None:

+             data["bootstrap_image"] = bootstrap_image

+ 

+         resp = self.client.post(route, data=data)

+         if self.success_expected:

+             assert resp.status_code == 302

+         return resp

+ 

+     def create_distgit_package(self, project, pkgname):

+         data = {

+             "package_name": pkgname,

+         }

+         route = "/coprs/{user}/{project}/package/new/distgit".format(

+             user=self.transaction_username,

+             project=project,

+         )

+         resp = self.client.post(route, data=data)

+         assert resp.status_code == 302

+         return resp

+ 

+     @staticmethod

+     def _form_data_from_build_options(build_options):

+         form_data = {}

+         chroots = build_options.get("chroots")

+         if chroots:

+             for ch in chroots:

+                 form_data[ch] = 'y'

+ 

+         for attr in ["bootstrap"]:

+             value = build_options.get(attr)

+             if value is None:

+                 continue

+             form_data[attr] = value

+ 

+         return form_data

+ 

+     def _submit_url_build(self, project, urls, build_options):

+         """ Submit build by Web-UI from a src.rpm link """

+         form_data = self._form_data_from_build_options(build_options)

+         form_data["pkgs"] = urls

+         route = "/coprs/{user}/{project}/new_build/".format(

+             user=self.transaction_username,

+             project=project,

+         )

+         resp = self.client.post(route, data=form_data)

+         if resp.status_code != 302:

+             print(parse_web_form_error(resp.data))

+         assert resp.status_code == 302

+         return resp

+ 

+ class API3Requests(_RequestsInterface):

+     """

+     Mimic python-copr API requests

+ 

+     To successfully use this, the testing method needs to

+     - use the TransactionDecorator()

+     - use f_users_api fixture

+     """

+ 

+     def post(self, url, content):

+         """ Post API3 form under "user" """

+         return self.test_class_object.post_api3_with_auth(

+             url, content, self.test_class_object.transaction_user)

+ 

+     def get(self, url, content):

+         """ Get API3 url with authenticated user """

+         return self.test_class_object.get_api3_with_auth(

+             url, content, self.test_class_object.transaction_user)

+ 

+     def new_project(self, name, chroots, bootstrap=None):

+         route = "/api_3/project/add/{}".format(self.transaction_username)

+         data = {

+             "name": name,

+             "chroots": chroots,

+         }

+         if bootstrap is not None:

+             data["bootstrap"] = bootstrap

+         resp = self.post(route, data)

+         return resp

+ 

+     def edit_chroot(self, project, chroot, bootstrap=None,

+                     bootstrap_image=None, owner=None):

+         route = "/api_3/project-chroot/edit/{owner}/{project}/{chroot}".format(

+             owner=owner or self.transaction_username,

+             project=project,

+             chroot=chroot,

+         )

+         data = {}

+         if bootstrap is not None:

+             data["bootstrap"] = bootstrap

+         if bootstrap_image is not None:

+             data["bootstrap_image"] = bootstrap_image

+         resp = self.post(route, data)

+         return resp

+ 

+     @staticmethod

+     def _form_data_from_build_options(build_options):

+         form_data = {}

+         for arg in ["chroots", "bootstrap"]:

+             if arg not in build_options:

+                 continue

+             if build_options[arg] is None:

+                 continue

+             form_data[arg] = build_options[arg]

+         return form_data

+ 

+     def _submit_url_build(self, project, urls, build_options):

+         route = "/api_3/build/create/url"

+         data = {

+             "ownername": self.transaction_username,

+             "projectname": project,

+             "pkgs": urls,

+         }

+         data.update(self._form_data_from_build_options(build_options))

+         resp = self.post(route, data)

+         return resp

+ 

+     def create_distgit_package(self, project, pkgname):

+         route = "/api_3/package/add/{}/{}/{}/distgit".format(

+             self.transaction_username, project, pkgname)

+         resp = self.post(route, {"package_name": pkgname})

+         return resp

@@ -0,0 +1,71 @@

+ """

+ Coverage for stuff related to CoprChroots

+ """

+ 

+ from bs4 import BeautifulSoup

+ import pytest

+ 

+ from coprs import db

+ from coprs.models import Copr

+ 

+ from tests.coprs_test_case import CoprsTestCase, TransactionDecorator

+ 

+ 

+ class TestCoprChroots(CoprsTestCase):

+     @TransactionDecorator("u1")

+     @pytest.mark.usefixtures("f_users", "f_users_api", "f_mock_chroots", "f_db")

+     def test_edit_chroot_form(self):

+         chroot = "fedora-rawhide-i386"

+         project = "test"

+         self.web_ui.new_project(project, [chroot],

+                                 bootstrap="on")

+         route = "/coprs/{}/{}/edit_chroot/{}/".format(

+             self.transaction_username, project, chroot,

+         )

+         def get_selected(html):

+             soup = BeautifulSoup(html, "html.parser")

+             return (soup.find("select", id="bootstrap")

+                     .find("option", attrs={'selected': True}))

+ 

+         resp = self.test_client.get(route)

+         assert get_selected(resp.data) is None

+ 

+         self.web_ui.edit_chroot("test", chroot, bootstrap="on")

+ 

+         resp = self.test_client.get(route)

+         assert get_selected(resp.data)["value"] == "on"

+ 

+     @TransactionDecorator("u2")

+     @pytest.mark.usefixtures("f_users", "f_users_api", "f_mock_chroots", "f_db")

+     def test_edit_chroot_permission(self):

+         chroot = "fedora-rawhide-i386"

+         project = "test"

+         self.web_ui.new_project(project, [chroot],

+                                 bootstrap="on")

+         copr = Copr.query.one()

+         copr.user = self.u1

+         db.session.commit()

+         route = "/coprs/{}/{}/edit_chroot/{}/".format(

+             self.u1.username, project, chroot,

+         )

+         resp = self.test_client.get(route)

+         assert resp.status_code == 403

+ 

+         self.web_ui.success_expected = False

+         resp = self.web_ui.edit_chroot(project, chroot, bootstrap="off",