Merge ~tintou/git-build-recipe:master into git-build-recipe:master

Proposed by Corentin Noël
Status: Needs review
Proposed branch: ~tintou/git-build-recipe:master
Merge into: git-build-recipe:master
Diff against target: 230 lines (+92/-25)
2 files modified
gitbuildrecipe/recipe.py (+55/-19)
gitbuildrecipe/tests/test_recipe.py (+37/-6)
Reviewer Review Type Date Requested Status
Colin Watson (community) Needs Fixing
Review via email: mp+351699@code.launchpad.net

Commit message

Handle submodules

It is possible to fetch the submodules before building the package by specifying submodule-strategy {normal, recursive} in the header of the recipe

The default behavior is not changed

Description of the change

I had to refactor the handling of the `deb-version` optional variable as there are now two optional variables.
by default, there is no additional command run, if the submodule strategy is changed then the repository is synced and updated (the behavior is inspired from what is described in https://docs.gitlab.com/ee/ci/yaml/README.html#git-submodule-strategy )

I haven't tested it on a real repository though.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

Most of these are nits, but I'd really prefer to have end-to-end functional tests before merging this (although I've tested it locally and it does seem to work fine).

review: Needs Fixing
Revision history for this message
Colin Watson (cjwatson) wrote :

Hm. There are going to be some more serious problems too.

URL filtering
-------------

In general, when we have user-supplied URLs, we normally filter them so that people can't e.g. pass in file:/// URLs and use those to construct some kind of exploit against the local system. For URLs in the recipe itself, we handle those by parsing the recipe in Launchpad and only allowing references to repositories hosted on Launchpad. But for submodules, that typically doesn't work; we'll need to expect that repositories have references to external URLs, and it's hard to filter those in advance (especially for the recursive strategy).

Now, it's possible we can get away without caring about this, since the builders on which we run git-build-recipe are throwaway VMs that don't have any secrets. But it needs to be documented for the benefit of other users of git-build-recipe, and it would be good if you could do some research to find if there's any reasonable way to filter submodule URLs before cloning them.

Recipe parsing in Launchpad
---------------------------

Launchpad currently takes the shortcut of parsing the supplied recipe using bzr-builder, and then mangling it slightly to account for the minor differences in git-build-recipe's format. We'll need to check whether that process needs any changes to account for this extension to the format. I think we ought to check that before landing this, because perhaps there will turn out to be some way to extend the format that's more compatible than others.

External access from Launchpad builders
---------------------------------------

The bad news is that this is the hard bit. The good news is that it doesn't need to block landing this code; it may well make the feature less useful until something's done about it, but the fixes for this won't be in git-build-recipe.

In general, Launchpad builders don't have access to the external internet. So this will for submodules that are hosted on Launchpad (using lp: or https://git.launchpad.net/ in submodule URL configuration), but it won't work for external URLs.

We'll need to have a policy discussion about whether we want to allow these on Launchpad. I'm leaning towards yes, because git submodules are pinned by their commit ID which is pretty good protection against the usual problems with external access for things like build reproducibility, but we still need to have the discussion in the Launchpad team and reach an agreement.

After that, there would be technical work to be done to implement this. We could reuse the proxy that we use to allow snap builders to have external internet access, perhaps dispatching it only to recipes that have a submodule-strategy. That would require changes in lib/lp/code/model/recipebuilder.py (similar to lib/lp/snappy/model/snapbuildbehaviour.py) in lp:launchpad, and to the corresponding bits of lp:launchpad-buildd.

Revision history for this message
Sergey Ponomarev (stokito) wrote :

Hello, is any progress on this? We need it and will be happy if you finally fix that.
Thank in advance

Revision history for this message
Dimitri John Ledkov (xnox) wrote :

About URL filtering

gitconfig allows to globally rewrite urls/protocols, which is under the control of the system/user, and not of the repository being cloned.

such that one could rewrite them to invalid ones, or force all of them to go through a custom protocol which then implements the filtering.

But that would require careful engineering & security penetration testing.

Unmerged commits

b1f7355... by Corentin Noël

Handle submodules

It is possible to fetch the submodules before building the package by specifying submodule-strategy {normal, recursive} in the header of the recipe

The default behavior is not changed

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/gitbuildrecipe/recipe.py b/gitbuildrecipe/recipe.py
index d1700f2..3f012ff 100644
--- a/gitbuildrecipe/recipe.py
+++ b/gitbuildrecipe/recipe.py
@@ -281,6 +281,11 @@ class CheckoutFailed(GitCommandFailed):
281 _cmd = "checkout"281 _cmd = "checkout"
282282
283283
284class SubmoduleFailed(GitCommandFailed):
285
286 _cmd = "submodule"
287
288
284class MergeFailed(GitCommandFailed):289class MergeFailed(GitCommandFailed):
285290
286 _cmd = "merge"291 _cmd = "merge"
@@ -320,6 +325,20 @@ def pull_or_clone(base_branch, target_path):
320 base_branch.git_call("checkout", base_branch.commit)325 base_branch.git_call("checkout", base_branch.commit)
321 except subprocess.CalledProcessError as e:326 except subprocess.CalledProcessError as e:
322 raise CheckoutFailed(e.output)327 raise CheckoutFailed(e.output)
328 if base_branch.submodule_strategy is not None:
329 if base_branch.submodule_strategy == "recursive":
330 try:
331 base_branch.git_call("submodule", "sync", "--recursive")
332 base_branch.git_call("submodule", "update", "--init",
333 "--recursive")
334 except subprocess.CalledProcessError as e:
335 raise SubmoduleFailed(e.output)
336 else:
337 try:
338 base_branch.git_call("submodule", "sync")
339 base_branch.git_call("submodule", "update", "--init")
340 except subprocess.CalledProcessError as e:
341 raise SubmoduleFailed(e.output)
323342
324343
325def fetch_branches(child_branch):344def fetch_branches(child_branch):
@@ -608,7 +627,7 @@ class RecipeBranch:
608 `self.url`.627 `self.url`.
609 """628 """
610629
611 def __init__(self, name, url, revspec=None):630 def __init__(self, name, url, submodule_strategy=None, revspec=None):
612 """Create a `RecipeBranch`.631 """Create a `RecipeBranch`.
613632
614 :param name: The name for the branch, or None if it is the root.633 :param name: The name for the branch, or None if it is the root.
@@ -618,6 +637,7 @@ class RecipeBranch:
618 """637 """
619 self.name = name638 self.name = name
620 self.url = url639 self.url = url
640 self.submodule_strategy = submodule_strategy
621 self.revspec = revspec641 self.revspec = revspec
622 self.child_branches = []642 self.child_branches = []
623 self.commit = None643 self.commit = None
@@ -796,13 +816,14 @@ class RecipeBranch:
796class BaseRecipeBranch(RecipeBranch):816class BaseRecipeBranch(RecipeBranch):
797 """The `RecipeBranch` that is at the root of a recipe."""817 """The `RecipeBranch` that is at the root of a recipe."""
798818
799 def __init__(self, url, deb_version, format_version, revspec=None):819 def __init__(self, url, deb_version, format_version,
820 submodule_strategy=None, revspec=None):
800 """Create a `BaseRecipeBranch`.821 """Create a `BaseRecipeBranch`.
801822
802 :param deb_version: The template to use for the version number.823 :param deb_version: The template to use for the version number.
803 Should be None for anything except the root branch.824 Should be None for anything except the root branch.
804 """825 """
805 super().__init__(None, url, revspec=revspec)826 super().__init__(None, url, submodule_strategy, revspec=revspec)
806 self.deb_version = deb_version827 self.deb_version = deb_version
807 self.format_version = format_version828 self.format_version = format_version
808 if not urlparse(url).scheme:829 if not urlparse(url).scheme:
@@ -910,8 +931,9 @@ class RecipeParser:
910 self.current_indent_level = 0931 self.current_indent_level = 0
911 self.seen_nicks = set()932 self.seen_nicks = set()
912 self.seen_paths = {".": 1}933 self.seen_paths = {".": 1}
913 version, deb_version = self.parse_header()934 version, deb_version, submodule_strategy = self.parse_header()
914 self.version = version935 self.version = version
936 self.submodule_strategy = submodule_strategy
915 last_instruction = None937 last_instruction = None
916 active_branches = []938 active_branches = []
917 last_branch = None939 last_branch = None
@@ -940,7 +962,8 @@ class RecipeParser:
940 revspec = self.parse_optional_revspec()962 revspec = self.parse_optional_revspec()
941 self.new_line()963 self.new_line()
942 last_branch = BaseRecipeBranch(964 last_branch = BaseRecipeBranch(
943 url, deb_version, self.version, revspec=revspec)965 url, deb_version, self.version, self.submodule_strategy,
966 revspec=revspec)
944 active_branches = [last_branch]967 active_branches = [last_branch]
945 last_instruction = ""968 last_instruction = ""
946 else:969 else:
@@ -968,7 +991,8 @@ class RecipeParser:
968 else:991 else:
969 revspec = self.parse_optional_revspec()992 revspec = self.parse_optional_revspec()
970 self.new_line(instruction)993 self.new_line(instruction)
971 last_branch = RecipeBranch(branch_id, url, revspec=revspec)994 last_branch = RecipeBranch(branch_id, url,
995 self.submodule_strategy, revspec=revspec)
972 if instruction == NEST_INSTRUCTION:996 if instruction == NEST_INSTRUCTION:
973 active_branches[-1].nest_branch(location, last_branch)997 active_branches[-1].nest_branch(location, last_branch)
974 elif instruction == MERGE_INSTRUCTION:998 elif instruction == MERGE_INSTRUCTION:
@@ -994,9 +1018,14 @@ class RecipeParser:
994 if version > self.NEWEST_VERSION:1018 if version > self.NEWEST_VERSION:
995 self.throw_parse_error("Unknown format: '%s'" % str(version))1019 self.throw_parse_error("Unknown format: '%s'" % str(version))
996 self.take_chars(len(version_str))1020 self.take_chars(len(version_str))
997 deb_version = self.parse_optional_deb_version()1021 options = self.parse_optional_values()
1022 deb_version = options.get('deb-version', None)
1023 submodule_strategy = options.get('submodule-strategy', None)
1024 if submodule_strategy not in (None, 'normal', 'recursive'):
1025 self.throw_parse_error("Unknown submodule strategy: '%s'" %
1026 str(submodule_strategy))
998 self.new_line()1027 self.new_line()
999 return version, deb_version1028 return version, deb_version, submodule_strategy
10001029
1001 def parse_instruction(self, permitted_instructions=None):1030 def parse_instruction(self, permitted_instructions=None):
1002 if self.version < 0.2:1031 if self.version < 0.2:
@@ -1079,17 +1108,24 @@ class RecipeParser:
1079 self.parse_whitespace("the revspec")1108 self.parse_whitespace("the revspec")
1080 return self.take_to_whitespace("the revspec")1109 return self.take_to_whitespace("the revspec")
10811110
1082 def parse_optional_deb_version(self):1111 def parse_optional_values(self):
1083 self.parse_whitespace("'deb-version'", require=False)1112 options = dict()
1084 actual = self.peek_to_whitespace()1113 expected_options = ("deb-version", "submodule-strategy")
1085 if actual is None:1114 for i in range(len(expected_options)):
1086 # End of line, no version1115 self.parse_whitespace("next option", require=False)
1087 return None1116 actual = self.peek_to_whitespace()
1088 if actual != "deb-version":1117 if actual is None:
1089 self.throw_expecting_error("deb-version", actual)1118 # End of line
1090 self.take_chars(len("deb-version"))1119 continue;
1091 self.parse_whitespace("a value for 'deb-version'")1120 else:
1092 return self.take_to_whitespace("a value for 'deb-version'")1121 if actual not in expected_options:
1122 self.throw_expecting_error("' or '".join(expected_options),
1123 actual)
1124 self.take_chars(len(actual))
1125 self.parse_whitespace("a value for '%s'" % actual)
1126 options[actual] = self.take_to_whitespace(
1127 "a value for '%s'" % actual)
1128 return options
10931129
1094 def parse_optional_revspec(self):1130 def parse_optional_revspec(self):
1095 self.parse_whitespace(None, require=False)1131 self.parse_whitespace(None, require=False)
diff --git a/gitbuildrecipe/tests/test_recipe.py b/gitbuildrecipe/tests/test_recipe.py
index ed7634d..fdee41e 100644
--- a/gitbuildrecipe/tests/test_recipe.py
+++ b/gitbuildrecipe/tests/test_recipe.py
@@ -95,6 +95,17 @@ class RecipeParserTests(GitTestCase):
95 branch = "http://foo.org/\n"95 branch = "http://foo.org/\n"
96 self.get_recipe(header + branch)96 self.get_recipe(header + branch)
9797
98 def test_parses_reversed_strategy_deb_version(self):
99 header = ("# git-build-recipe format 0.4 submodule-strategy normal "
100 "deb-version " + self.deb_version + "\n")
101 branch = "http://foo.org/\n"
102 self.get_recipe(header + branch)
103
104 def test_parses_missing_deb_version_with_strategy(self):
105 header = "# git-build-recipe format 0.4 submodule-strategy normal\n"
106 branch = "http://foo.org/\n"
107 self.get_recipe(header + branch)
108
98 def tests_rejects_non_comment_to_start(self):109 def tests_rejects_non_comment_to_start(self):
99 self.assertParseError(1, 1, "Expecting '#', got 'g'",110 self.assertParseError(1, 1, "Expecting '#', got 'g'",
100 self.get_recipe, "git-build-recipe")111 self.get_recipe, "git-build-recipe")
@@ -128,9 +139,9 @@ class RecipeParserTests(GitTestCase):
128 self.get_recipe,139 self.get_recipe,
129 "# git-build-recipe format 10000 deb-version 1\n")140 "# git-build-recipe format 10000 deb-version 1\n")
130141
131 def test_rejects_invalid_deb_version_marker(self):142 def test_rejects_invalid_parameter(self):
132 self.assertParseError(1, 31, "Expecting 'deb-version', "143 self.assertParseError(1, 31, "Expecting 'deb-version' or "
133 "got 'deb'", self.get_recipe,144 "'submodule-strategy', got 'deb'", self.get_recipe,
134 "# git-build-recipe format 0.1 deb")145 "# git-build-recipe format 0.1 deb")
135146
136 def tests_rejects_no_deb_version_value(self):147 def tests_rejects_no_deb_version_value(self):
@@ -138,9 +149,19 @@ class RecipeParserTests(GitTestCase):
138 "a value for 'deb-version'", self.get_recipe,149 "a value for 'deb-version'", self.get_recipe,
139 "# git-build-recipe format 0.1 deb-version")150 "# git-build-recipe format 0.1 deb-version")
140151
141 def tests_rejects_extra_text_after_deb_version(self):152 def test_unknown_submodule_strategy(self):
142 self.assertParseError(1, 45, "Expecting the end of the line, "153 self.assertParseError(1, 53, "Unknown submodule strategy: 'foo'",
143 "got 'foo'", self.get_recipe,154 self.get_recipe,
155 "# git-build-recipe format 0.4 submodule-strategy foo\n")
156
157 def tests_rejects_no_submodule_strategy_value(self):
158 self.assertParseError(1, 49, "End of line while looking for "
159 "a value for 'submodule-strategy'", self.get_recipe,
160 "# git-build-recipe format 0.4 submodule-strategy")
161
162 def tests_rejects_extra_parameter(self):
163 self.assertParseError(1, 45, "Expecting 'deb-version' or "
164 "'submodule-strategy', got 'foo'", self.get_recipe,
144 "# git-build-recipe format 0.1 deb-version 1 foo")165 "# git-build-recipe format 0.1 deb-version 1 foo")
145166
146 def tests_rejects_indented_base_branch(self):167 def tests_rejects_indented_base_branch(self):
@@ -539,6 +560,16 @@ class BuildTreeTests(GitTestCase):
539 self.assertEqual(commit, target.rev_parse("HEAD"))560 self.assertEqual(commit, target.rev_parse("HEAD"))
540 self.assertEqual(commit, base_branch.commit)561 self.assertEqual(commit, base_branch.commit)
541562
563 def test_build_tree_single_branch_recursive(self):
564 source = GitRepository("source")
565 commit = source.commit("one")
566 base_branch = BaseRecipeBranch("source", "1", 0.2, "recursive")
567 build_tree(base_branch, "target")
568 self.assertThat("target", PathExists())
569 target = GitRepository("target", allow_create=False)
570 self.assertEqual(commit, target.rev_parse("HEAD"))
571 self.assertEqual(commit, base_branch.commit)
572
542 def test_build_tree_single_branch_dir_not_branch(self):573 def test_build_tree_single_branch_dir_not_branch(self):
543 source = GitRepository("source")574 source = GitRepository("source")
544 source.commit("one")575 source.commit("one")

Subscribers

People subscribed via source and target branches