Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions tools/generate-module-dependencies/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A small utility to keep Go module replace directives consistent across the repos

- Reads dependency definitions from the project-level `dependency-replacements.yaml`.
- Renders the list of `replace` directives using a template.
- Injects the rendered block into target files (e.g., `go.mod`) between well-known markers.
- Injects the rendered block into target files (e.g., `go.mod` or OCB builder config YAML files) between well-known markers.
- Runs `go mod tidy` for affected modules.

Generated blocks are wrapped with:
Expand All @@ -19,7 +19,14 @@ BEGIN GENERATED REPLACES - DO NOT EDIT MANUALLY ... END GENERATED REPLACES
changed manually within these markers will be overwritten during the next run.

Please note that local replacement directives (ie pointing a dependency to a local module) are _not_ meant to be included
in `dependency-replacements.yaml`. These must be included separately in `go.mod` files, outside of the template boundaries
in `dependency-replacements.yaml`. These must be included separately in `go.mod` files, outside of the template boundaries.

## Supported File Types

The tool supports two file types:

- **`mod`**: Go module files (`go.mod`)
- **`ocb`**: OpenTelemetry Collector Builder (OCB) config YAML files

## Usage

Expand All @@ -31,10 +38,31 @@ in `dependency-replacements.yaml`. These must be included separately in `go.mod`

All inputs come from `dependency-replacements.yaml`, which defines:
- Modules to update (name, path, file_type).
- `file_type` can be `mod` for `go.mod` files or `ocb` for OCB builder config YAML files
- Replace entries (dependency, replacement, optional comment).

Comments are normalized (single-line) and included above the corresponding `replace` directive in generated output.

### Example `dependency-replacements.yaml`

```yaml
modules:
- name: main
path: go.mod
file_type: mod
- name: collector
path: collector/builder-config.yaml
file_type: ocb

replaces:
- dependency: example.com/package
replacement: example.com/fork v1.0.0
comment: Test replace for example.com/package
- dependency: github.com/test/dependency
replacement: github.com/test/fork v1.0.0
comment: Another test replace
```

## Troubleshooting

- If a start marker exists without an end marker (or vice versa), generation fails—ensure both markers are present or absent together.
Expand Down
53 changes: 29 additions & 24 deletions tools/generate-module-dependencies/cmd/sync-mod.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,34 @@ import (
"github.com/spf13/cobra"
)

var generateAndApplyReplaces = &cobra.Command{
Use: "generate",
Short: "Generates replace directives as specified in the input dependency-replacements.yaml",
Run: func(cmd *cobra.Command, args []string) {
pathToYaml := cmd.Flag("dependency-yaml").Value.String()
pathToRoot := cmd.Flag("project-root").Value.String()

fileHelper, err := helpers.NewFileHelper(pathToYaml, pathToRoot)
if err != nil {
log.Fatalf("Failed to create file helper: %v", err)
}

projectReplaces, err := fileHelper.LoadProjectReplaces()
if err != nil {
log.Fatalf("Failed to load project replaces: %v", err)
}

modByReplaceStr := internal.GenerateReplaces(fileHelper, projectReplaces)
internal.ApplyReplaces(fileHelper, projectReplaces, modByReplaceStr)
internal.TidyModules(fileHelper, projectReplaces)
},
func newGenerateCommand() *cobra.Command {
generateAndApplyReplaces := &cobra.Command{
Use: "generate",
Short: "Generates replace directives as specified in the input dependency-replacements.yaml",
Run: func(cmd *cobra.Command, args []string) {
pathToYaml := cmd.Flag("dependency-yaml").Value.String()
pathToRoot := cmd.Flag("project-root").Value.String()

fileHelper, err := helpers.NewFileHelper(pathToYaml, pathToRoot)
if err != nil {
log.Fatalf("Failed to create file helper: %v", err)
}

projectReplaces, err := fileHelper.LoadProjectReplaces()
if err != nil {
log.Fatalf("Failed to load project replaces: %v", err)
}

modByReplaceStr := internal.GenerateReplaces(fileHelper, projectReplaces)
internal.ApplyReplaces(fileHelper, projectReplaces, modByReplaceStr)
internal.TidyModules(fileHelper, projectReplaces)
},
}

generateAndApplyReplaces.Flags().String("dependency-yaml", "", "Relative path to the dependency-replacements.yaml file")
generateAndApplyReplaces.Flags().String("project-root", "", "Relative path to the project root")

return generateAndApplyReplaces
}

func Execute() {
Expand All @@ -44,9 +51,7 @@ func NewRootCommand() *cobra.Command {
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
}

rootCmd.AddCommand(generateAndApplyReplaces)
generateAndApplyReplaces.Flags().String("dependency-yaml", "", "Relative path to the dependency-replacements.yaml file")
generateAndApplyReplaces.Flags().String("project-root", "", "Relative path to the project root")
rootCmd.AddCommand(newGenerateCommand())

return rootCmd
}
88 changes: 76 additions & 12 deletions tools/generate-module-dependencies/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,25 @@ type testCase struct {
testdataDir string
}

var testCases = []testCase{
{
name: "Basic",
testdataDir: "basic-mod",
},
{
name: "UpdateExisting",
testdataDir: "update-existing-mod",
},
}

func TestE2E(t *testing.T) {
func TestE2EMod(t *testing.T) {
currentDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current working directory: %v", err)
}

command := cmd.NewRootCommand()

var testCases = []testCase{
{
name: "Basic",
testdataDir: "basic-mod",
},
{
name: "UpdateExisting",
testdataDir: "update-existing-mod",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testdataDir := filepath.Join(currentDir, "testdata", tc.testdataDir)
Expand Down Expand Up @@ -77,3 +77,67 @@ func TestE2E(t *testing.T) {
})
}
}

func TestE2EOCB(t *testing.T) {
currentDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current working directory: %v", err)
}

command := cmd.NewRootCommand()

var testCases = []testCase{
{
name: "Basic",
testdataDir: "basic-ocb",
},
{
name: "UpdateExisting",
testdataDir: "update-existing-ocb",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testdataDir := filepath.Join(currentDir, "testdata", tc.testdataDir)
builderYamlPath := filepath.Join(testdataDir, "test-builder-config.yaml")
expectedPath := filepath.Join(testdataDir, "test-builder-config-expected.yaml")
dependencyYaml := filepath.Join("testdata", tc.testdataDir, "dependency-replacements-test.yaml")
projectRoot := filepath.Join("testdata", tc.testdataDir)

originalGoMod, err := os.ReadFile(builderYamlPath)
if err != nil {
t.Fatalf("Failed to read original builder yaml: %v", err)
}

// Restore the original builder yaml after the test
defer func() {
if err := os.WriteFile(builderYamlPath, originalGoMod, 0644); err != nil {
t.Errorf("Failed to restore original builder yaml: %v", err)
}
}()

command.SetArgs([]string{"generate", "--dependency-yaml", dependencyYaml, "--project-root", projectRoot})
err = command.Execute()
if err != nil {
t.Fatalf("Failed to run command: %v", err)
}

expectedContent, err := os.ReadFile(expectedPath)
if err != nil {
t.Fatalf("Failed to read expected builder yaml: %v", err)
}
expectedGoMod := strings.TrimSpace(string(expectedContent))

actualContent, err := os.ReadFile(builderYamlPath)
if err != nil {
t.Fatalf("Failed to read actual builder yaml: %v", err)
}
actualGoMod := strings.TrimSpace(string(actualContent))

if actualGoMod != expectedGoMod {
t.Errorf("builder yaml content mismatch.\nExpected:\n%s\n\nActual:\n%s", expectedGoMod, actualGoMod)
}
})
}
}
30 changes: 22 additions & 8 deletions tools/generate-module-dependencies/internal/apply-replaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ func getCommentMarker(fileType types.FileType) (string, error) {
switch fileType {
case types.FileTypeMod:
return "//", nil
case types.FileTypeOCB:
return "#", nil
default:
return "", fmt.Errorf("unknown file_type %q", fileType)
return "", fmt.Errorf("Unknown file_type %q (expected %q or %q)", fileType, types.FileTypeMod, types.FileTypeOCB)
}
}

Expand All @@ -67,8 +69,8 @@ func getMarkers(fileType types.FileType) (startMarker, endMarker string, err err

// Upserts the generated block using the markers, or lack thereof, as a guide
func upsertGeneratedBlock(targetContent, replacement, startMarker, endMarker string) (string, error) {
startIdx := strings.Index(targetContent, startMarker)
startFound := startIdx != -1
lineStart := strings.Index(targetContent, startMarker)
startFound := lineStart != -1
Comment on lines 71 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it could be simpler if we:

  • Used Split to split the targetContent into slice of lines
  • Used Contains to find the line with start and end marker
  • Replaced the middle part of the slice
  • Used Join to join it all back into text

This way we don't need to have the complexity of lineStart etc.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's true 👍 let me give that a shot, sounds like it'd be more readable that way


if !startFound {
// No start marker: if the end marker exists anywhere, it's invalid.
Expand All @@ -81,16 +83,28 @@ func upsertGeneratedBlock(targetContent, replacement, startMarker, endMarker str
return targetContent + "\n" + replacement, nil
}

searchFrom := startIdx + len(startMarker)
// Find the start of the line containing the start marker
for lineStart > 0 && targetContent[lineStart-1] != '\n' {
lineStart--
}

searchFrom := lineStart + len(startMarker)
Comment on lines +86 to +91
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After moving lineStart backwards to the beginning of the line (lines 87-89), the calculation searchFrom := lineStart + len(startMarker) is incorrect. The original marker position needs to be preserved before moving lineStart backwards. The searchFrom position should be calculated from the original marker index (where the marker was found) plus the marker length, not from the beginning of the line. This will cause incorrect parsing of the end marker position.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this, but maybe we can do the suggestion I've written about and this logic would go away 🤔

endRel := strings.Index(targetContent[searchFrom:], endMarker)
if endRel == -1 {
// Start marker exists without an end marker, which is invalid
return "", fmt.Errorf("found start marker without end marker")
}

endIdx := searchFrom + endRel
endOfMarker := endIdx + len(endMarker)
lineEnd := searchFrom + endRel + len(endMarker)

// Find the end of the line containing the end marker (or end of file)
for lineEnd < len(targetContent) && targetContent[lineEnd] != '\n' {
lineEnd++
}
// Include the newline if present
if lineEnd < len(targetContent) {
lineEnd++
}

// Replace [startIdx, endOfMarker) with replacement.
return targetContent[:startIdx] + replacement + targetContent[endOfMarker:], nil
return targetContent[:lineStart] + replacement + targetContent[lineEnd:], nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ func (d *FileHelper) TemplatePath(fileType types.FileType) (string, error) {
switch fileType {
case types.FileTypeMod:
templateName = "replaces-mod.tpl"
case types.FileTypeOCB:
templateName = "replaces-ocb.tpl"
default:
err = fmt.Errorf("Unknown file_type %q (expected %q)", fileType, types.FileTypeMod)
err = fmt.Errorf("Unknown file_type %q (expected %q or %q)", fileType, types.FileTypeMod, types.FileTypeOCB)
}
return filepath.Join(d.ScriptDir, templateName), err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type FileType string

const (
FileTypeMod FileType = "mod"
FileTypeOCB FileType = "ocb"
)

func (ft FileType) String() string {
Expand All @@ -26,8 +27,11 @@ func (ft *FileType) UnmarshalYAML(value *yaml.Node) error {
case FileTypeMod:
*ft = FileTypeMod
return nil
case FileTypeOCB:
*ft = FileTypeOCB
return nil
default:
return fmt.Errorf("invalid Module.file_type %q (expected %q)", s, FileTypeMod)
return fmt.Errorf("invalid Module.file_type %q (expected %q or %q)", s, FileTypeMod, FileTypeOCB)
}
}

Expand Down
8 changes: 8 additions & 0 deletions tools/generate-module-dependencies/replaces-ocb.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# BEGIN GENERATED REPLACES - DO NOT EDIT MANUALLY
{{- range . }}
{{- if .Comment }}
# {{ .Comment }}
{{- end }}
- {{ .Dependency }} => {{ .Replacement }}
{{- end }}
# END GENERATED REPLACES
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
modules:
- name: test-collector
path: test-builder-config.yaml
file_type: ocb

replaces:
- comment: Test replace for example.com/package
dependency: example.com/package
replacement: example.com/fork v1.0.0

- comment: Another test replace
dependency: github.com/test/dependency
replacement: github.com/test/fork v1.0.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
dist:
module: github.com/test
name: test
description: test distribution
version: v1.0.0

extensions:
- gomod: go.opentelemetry.io/collector/extension/zpagesextension v0.139.0

exporters:
- gomod: go.opentelemetry.io/collector/exporter/debugexporter v0.139.0

receivers:
- gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.139.0

replaces:
# local replacements
- github.com/some-dependencys/some-dep => ./some-dep
# BEGIN GENERATED REPLACES - DO NOT EDIT MANUALLY
# Test replace for example.com/package
- example.com/package => example.com/fork v1.0.0
# Another test replace
- github.com/test/dependency => github.com/test/fork v1.0.0
# END GENERATED REPLACES
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
dist:
module: github.com/test
name: test
description: test distribution
version: v1.0.0

extensions:
- gomod: go.opentelemetry.io/collector/extension/zpagesextension v0.139.0

exporters:
- gomod: go.opentelemetry.io/collector/exporter/debugexporter v0.139.0

receivers:
- gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.139.0

replaces:
# local replacements
- github.com/some-dependencys/some-dep => ./some-dep
# BEGIN GENERATED REPLACES - DO NOT EDIT MANUALLY

# END GENERATED REPLACES
Loading
Loading