Skip to content

Commit

Permalink
Mega-Hack: "go.work" Support
Browse files Browse the repository at this point in the history
Caveats:
* allows for dependency bleed between different go modules in the workspace - this is sorta by design for this stepping stone, but is far from ideal
* doesn't support replace statements in go.work yet (but that shouldn't be to hard to add)

continue the megahack experiment: handle replace in go.work

Lets provide a warning if differing versions are discovered

Improve duplicate Version Warning Message

* include both core + meta version segments in warning. 0.0.0 vs 0.0.0 isn't useful
* include label were offending versions reside

ensure error message is valid for all three scenarios

1) if external dependencies are out of sync, and something go work sync can handle, inform the user to run `go work sync`
2) if in-repo dependencies managed by go.work are out of sync inform the user to manually correct
3) if external dependencies have the same core version, but a different meta version tell the user to manually correct

transition warning to error and touchup message

Add tests for handling go.work files

These tests aren't exhaustive, but they match the testing done for go.mod
files. In the future, it would likely be good to add additional more
comprehensive tests.
  • Loading branch information
stefanpenner authored and Stefan Penner committed Feb 7, 2024
1 parent 55b3ce6 commit 86da7c5
Show file tree
Hide file tree
Showing 9 changed files with 340 additions and 63 deletions.
109 changes: 100 additions & 9 deletions internal/bzlmod/go_deps.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@
# limitations under the License.

load("//internal:go_repository.bzl", "go_repository")
load(":go_mod.bzl", "deps_from_go_mod", "sums_from_go_mod")
load(":go_mod.bzl", "deps_from_go_mod", "parse_go_work", "sums_from_go_mod", "sums_from_go_work")
load(
":default_gazelle_overrides.bzl",
"DEFAULT_BUILD_EXTRA_ARGS_BY_PATH",
"DEFAULT_BUILD_FILE_GENERATION_BY_PATH",
"DEFAULT_DIRECTIVES_BY_PATH",
)
load(":semver.bzl", "semver")
load(":semver.bzl", "humanize_comparable_version", "semver")
load(
":utils.bzl",
"drop_nones",
Expand All @@ -44,6 +44,13 @@ the required directives to the "default_gazelle_overrides.bzl" file at \
https://github.com/bazelbuild/bazel-gazelle/tree/master/internal/bzlmod/default_gazelle_overrides.bzl.
"""

# TODO: megahack
def go_work_from_label(module_ctx, go_work_label):
"""Loads deps from a go.work file"""
go_work_path = module_ctx.path(go_work_label)
go_work_content = module_ctx.read(go_work_path)
return parse_go_work(go_work_content, go_work_label)

def _fail_on_non_root_overrides(module_ctx, module, tag_class):
if module.is_root:
return
Expand All @@ -68,7 +75,8 @@ def _fail_on_unmatched_overrides(override_keys, resolutions, override_name):
unmatched_overrides = [path for path in override_keys if path not in resolutions]
if unmatched_overrides:
fail("Some {} did not target a Go module with a matching path: {}".format(
override_name, ", ".join(unmatched_overrides)
override_name,
", ".join(unmatched_overrides),
))

def _check_directive(directive):
Expand Down Expand Up @@ -194,6 +202,55 @@ _go_repository_config = repository_rule(
},
)

def fail_if_duplicate_modules_have_different_versions(version, previous, module_tag, module_name_to_go_dot_mod_label, go_works):
"""
Check if duplicate modules have different versions, and fail with a useful error message if they do.
Args:
version: The version of the module.
previous: The previous module object.
module_tag: The module tag.
module_name_to_go_dot_mod_label: A dictionary mapping module paths to go.mod labels.
go_works: A list of go_work objects representing use statements in the go.work file.
previous: The previous module object.
"""

if not previous:
# no previous module, so no possible error
return

if not previous or version == previous.version:
# version is the same, skip because we won't error
return

# When using go.work, duplicate depenency versions are possible.
# This can cause issues, so we fail with a hopefully actionable error.
current_label = module_tag.parent_label
previous_label = previous.module_tag.parent_label

corrective_measure = None
default_corrective_mesasure = "To correct this:\n 1. manually update: all go.mod files to ensure the versions of '{}' are the same.\n 2. run: go work sync.".format(module_tag.path)

if previous.version[0] == version[0] or str(current_label).endswith("go.work") or str(previous_label).endswith("go.work"):
corrective_measure = default_corrective_mesasure
else:
label = module_name_to_go_dot_mod_label.get(module_tag.path)

if label:
# if the label is present that means the module_tag is of a go.mod file, which means the correct action may be different.

# if the duplicate module in question is provided by go.work use statement then only manual intervention can fix it
# from_file_tags on go_work represents use statements in the go.work file
for from_file_tags in [go_work.from_file_tags for go_work in go_works]:
for from_file_tag in from_file_tags:
if from_file_tag.go_mod == label:
corrective_measure = default_corrective_mesasure
break
else:
corrective_measure = "To correct this, run: go work sync."

fail("Multiple versions of {} found:\n - {} contains {}\n - {} contains {}.\n{}".format(module_tag.path, current_label, humanize_comparable_version(version), previous_label, humanize_comparable_version(previous.version), corrective_measure))

def _noop(_):
pass

Expand Down Expand Up @@ -251,9 +308,37 @@ def _go_deps_impl(module_ctx):
", ".join([str(tag.go_mod) for tag in module.tags.from_file]),
),
)

additional_module_tags = []
from_file_tags = []
go_works = []
module_name_to_go_dot_mod_label = {}

for from_file_tag in module.tags.from_file:
module_path, module_tags_from_go_mod, go_mod_replace_map = deps_from_go_mod(module_ctx, from_file_tag.go_mod)
if from_file_tag.go_mod:
from_file_tags.append(from_file_tag)
elif from_file_tag.go_work:
# TODO: megahack
go_work = go_work_from_label(module_ctx, from_file_tag.go_work)
go_works.append(go_work)

# this ensures go.work replacements as considered
additional_module_tags += [
with_replaced_or_new_fields(tag, _is_dev_dependency = False)
for tag in go_work.module_tags
]

for entry, new_sum in sums_from_go_work(module_ctx, from_file_tag.go_work).items():
_safe_insert_sum(sums, entry, new_sum)

replace_map.update(go_work.replace_map)
from_file_tags = from_file_tags + go_work.from_file_tags
else:
fail("Either \"go_mod\" or \"go_work\" must be specified in \"go_deps.from_file\" tags.")

for from_file_tag in from_file_tags:
module_path, module_tags_from_go_mod, go_mod_replace_map, module_name = deps_from_go_mod(module_ctx, from_file_tag.go_mod)
module_name_to_go_dot_mod_label[module_name] = from_file_tag.go_mod
is_dev_dependency = _is_dev_dependency(module_ctx, from_file_tag)
additional_module_tags += [
with_replaced_or_new_fields(tag, _is_dev_dependency = is_dev_dependency)
Expand Down Expand Up @@ -304,12 +389,12 @@ def _go_deps_impl(module_ctx):
# transitive dependencies have also been declared - we may end up
# resolving them to higher versions, but only compatible ones.
paths = {}

for module_tag in module.tags.module + additional_module_tags:
if module_tag.path in paths:
fail("Duplicate Go module path \"{}\" in module \"{}\".".format(module_tag.path, module.name))
if not module_tag.path in paths:
paths[module_tag.path] = None
if module_tag.path in bazel_deps:
continue
paths[module_tag.path] = None
raw_version = _canonicalize_raw_version(module_tag.version)

# For modules imported from a go.sum, we know which ones are direct
Expand All @@ -325,6 +410,12 @@ def _go_deps_impl(module_ctx):
root_module_direct_deps[_repo_name(module_tag.path)] = None

version = semver.to_comparable(raw_version)
previous = paths.get(module_tag.path)

# rather then failing, we could do MVS here, or some other heuristic
fail_if_duplicate_modules_have_different_versions(version, previous, module_tag, module_name_to_go_dot_mod_label, go_works)
paths[module_tag.path] = struct(version = version, module_tag = module_tag)

if module_tag.path not in module_resolutions or version > module_resolutions[module_tag.path].version:
module_resolutions[module_tag.path] = struct(
repo_name = _repo_name(module_tag.path),
Expand All @@ -341,7 +432,6 @@ def _go_deps_impl(module_ctx):
# in the module resolutions and swapping out the entry.
for path, replace in replace_map.items():
if path in module_resolutions:

# If the replace directive specified a version then we only
# apply it if the versions match.
if replace.from_version:
Expand Down Expand Up @@ -500,7 +590,8 @@ _config_tag = tag_class(

_from_file_tag = tag_class(
attrs = {
"go_mod": attr.label(mandatory = True),
"go_mod": attr.label(mandatory = False),
"go_work": attr.label(mandatory = False),
},
)

Expand Down
Loading

0 comments on commit 86da7c5

Please sign in to comment.