Skip to content

Commit

Permalink
[builder] Add strict versioning (open-telemetry#9897)
Browse files Browse the repository at this point in the history
**Description:** <Describe what has changed.>
Adds strict version checking in the builder. This enables users to
ensure that the versions specified in the builder config are the
versions used in the go.mod when building the collector binary. This can
be disabled with --skip-strict-versioning.

**Link to tracking Issue:** open-telemetry#9896

**Testing:** Added unit tests

**Documentation:** Added to builder README

---------

Co-authored-by: Pablo Baeyens <[email protected]>
  • Loading branch information
Kristina Pathak and mx-psi authored Apr 18, 2024
1 parent f3305aa commit e9b432d
Show file tree
Hide file tree
Showing 10 changed files with 400 additions and 48 deletions.
25 changes: 25 additions & 0 deletions .chloggen/builder-strict-versioning.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: breaking

# The name of the component, or a single word describing the area of concern, (e.g. otlpreceiver)
component: builder

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Add strict version checking when using the builder. Add the temporary flag `--skip-strict-versioning `for skipping this check.

# One or more tracking issues or pull requests related to the change
issues: [9896]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext: Strict version checking will error on mismatches between the `otelcol_version` configured and the builder version or versions in the go.mod. This check can be temporarily disabled by using the `--skip-strict-versioning` flag. This flag will be removed in a future minor version.

# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Include 'api' if there is a change to a library API.
# Default: '[user]'
change_logs: []
21 changes: 21 additions & 0 deletions cmd/builder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,24 @@ then commit the code in a git repo. A CI can sync the code and execute
ocb --skip-generate --skip-get-modules --config=config.yaml
```
to only execute the compilation step.

### Strict versioning checks

The builder checks the relevant `go.mod`
file for the following things after `go get`ing all components and calling
`go mod tidy`:

1. The `dist::otelcol_version` field in the build configuration must
match the core library version calculated by the Go toolchain,
considering all components. A mismatch could happen, for example,
when one of the components depends on a newer release of the core
collector library.
2. For each component in the build configuration, the version included
in the `gomod` module specifier must match the one calculated by
the Go toolchain, considering all components. A mismatch could
happen, for example, when the enclosing Go module uses a newer
release of the core collector library.

The `--skip-strict-versioning` flag disables these versioning checks.
This flag is available temporarily and
**will be removed in a future minor version**.
1 change: 1 addition & 0 deletions cmd/builder/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
go.uber.org/goleak v1.3.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0
golang.org/x/mod v0.17.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions cmd/builder/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions cmd/builder/internal/builder/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ var ErrInvalidGoMod = errors.New("invalid gomod specification for module")

// Config holds the builder's configuration
type Config struct {
Logger *zap.Logger
SkipGenerate bool `mapstructure:"-"`
SkipCompilation bool `mapstructure:"-"`
SkipGetModules bool `mapstructure:"-"`
LDFlags string `mapstructure:"-"`
Verbose bool `mapstructure:"-"`
Logger *zap.Logger
SkipGenerate bool `mapstructure:"-"`
SkipCompilation bool `mapstructure:"-"`
SkipGetModules bool `mapstructure:"-"`
SkipStrictVersioning bool `mapstructure:"-"`
LDFlags string `mapstructure:"-"`
Verbose bool `mapstructure:"-"`

Distribution Distribution `mapstructure:"dist"`
Exporters []Module `mapstructure:"exporters"`
Expand Down
140 changes: 119 additions & 21 deletions cmd/builder/internal/builder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,52 @@
package builder // import "go.opentelemetry.io/collector/cmd/builder/internal/builder"

import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"time"

"go.uber.org/zap"
"go.uber.org/zap/zapio"
"golang.org/x/mod/modfile"
)

var (
// ErrGoNotFound is returned when a Go binary hasn't been found
ErrGoNotFound = errors.New("go binary not found")
ErrGoNotFound = errors.New("go binary not found")
ErrDepNotFound = errors.New("dependency not found in go mod file")
ErrVersionMismatch = errors.New("mismatch in go.mod and builder configuration versions")
errGoGetFailed = errors.New("failed to go get")
errDownloadFailed = errors.New("failed to download go modules")
errCompileFailed = errors.New("failed to compile the OpenTelemetry Collector distribution")
skipStrictMsg = "Use --skip-strict-versioning to temporarily disable this check. This flag will be removed in a future minor version"
)

func runGoCommand(cfg Config, args ...string) error {
cfg.Logger.Info("Running go subcommand.", zap.Any("arguments", args))
func runGoCommand(cfg Config, args ...string) ([]byte, error) {
if cfg.Verbose {
cfg.Logger.Info("Running go subcommand.", zap.Any("arguments", args))
}

// #nosec G204 -- cfg.Distribution.Go is trusted to be a safe path and the caller is assumed to have carried out necessary input validation
cmd := exec.Command(cfg.Distribution.Go, args...)
cmd.Dir = cfg.Distribution.OutputPath

if cfg.Verbose {
writer := &zapio.Writer{Log: cfg.Logger}
defer func() { _ = writer.Close() }()
cmd.Stdout = writer
cmd.Stderr = writer
return cmd.Run()
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("go subcommand failed with args '%v': %w. Output:\n%s", args, err, out)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("go subcommand failed with args '%v': %w, error message: %s", args, err, stderr.String())
}
if cfg.Verbose && stderr.Len() != 0 {
cfg.Logger.Info("go subcommand error", zap.String("message", stderr.String()))
}

return nil
return stdout.Bytes(), nil
}

// GenerateAndCompile will generate the source files based on the given configuration, update go mod, and will compile into a binary
Expand All @@ -64,6 +74,9 @@ func Generate(cfg Config) error {
}
// create a warning message for non-aligned builder and collector base
if cfg.Distribution.OtelColVersion != defaultOtelColVersion {
if !cfg.SkipStrictVersioning {
return fmt.Errorf("builder version %q does not match build configuration version %q: %w", cfg.Distribution.OtelColVersion, defaultOtelColVersion, ErrVersionMismatch)
}
cfg.Logger.Info("You're building a distribution with non-aligned version of the builder. Compilation may fail due to API changes. Please upgrade your builder or API", zap.String("builder-version", defaultOtelColVersion))
}
// if the file does not exist, try to create it
Expand Down Expand Up @@ -114,8 +127,8 @@ func Compile(cfg Config) error {
if cfg.Distribution.BuildTags != "" {
args = append(args, "-tags", cfg.Distribution.BuildTags)
}
if err := runGoCommand(cfg, args...); err != nil {
return fmt.Errorf("failed to compile the OpenTelemetry Collector distribution: %w", err)
if _, err := runGoCommand(cfg, args...); err != nil {
return fmt.Errorf("%w: %s", errCompileFailed, err.Error())
}
cfg.Logger.Info("Compiled", zap.String("binary", fmt.Sprintf("%s/%s", cfg.Distribution.OutputPath, cfg.Distribution.Name)))

Expand All @@ -130,29 +143,74 @@ func GetModules(cfg Config) error {
}

// ambiguous import: found package cloud.google.com/go/compute/metadata in multiple modules
if err := runGoCommand(cfg, "get", "cloud.google.com/go"); err != nil {
return fmt.Errorf("failed to go get: %w", err)
if _, err := runGoCommand(cfg, "get", "cloud.google.com/go"); err != nil {
return fmt.Errorf("%w: %s", errGoGetFailed, err.Error())
}

if err := runGoCommand(cfg, "mod", "tidy", "-compat=1.21"); err != nil {
if _, err := runGoCommand(cfg, "mod", "tidy", "-compat=1.21"); err != nil {
return fmt.Errorf("failed to update go.mod: %w", err)
}

if cfg.SkipStrictVersioning {
return downloadModules(cfg)
}

// Perform strict version checking. For each component listed and the
// otelcol core dependency, check that the enclosing go module matches.
modulePath, dependencyVersions, err := cfg.readGoModFile()
if err != nil {
return err
}

corePath, coreVersion := cfg.coreModuleAndVersion()
coreDepVersion, ok := dependencyVersions[corePath]
if !ok {
return fmt.Errorf("core collector %w: '%s'. %s", ErrDepNotFound, corePath, skipStrictMsg)
}
if coreDepVersion != coreVersion {
return fmt.Errorf(
"%w: core collector version calculated by component dependencies %q does not match configured version %q. %s",
ErrVersionMismatch, coreDepVersion, coreVersion, skipStrictMsg)
}

for _, mod := range cfg.allComponents() {
module, version, _ := strings.Cut(mod.GoMod, " ")
if module == modulePath {
// No need to check the version of components that are part of the
// module we're building from.
continue
}

moduleDepVersion, ok := dependencyVersions[module]
if !ok {
return fmt.Errorf("component %w: '%s'. %s", ErrDepNotFound, module, skipStrictMsg)
}
if moduleDepVersion != version {
return fmt.Errorf(
"%w: component %q version calculated by dependencies %q does not match configured version %q. %s",
ErrVersionMismatch, module, moduleDepVersion, version, skipStrictMsg)
}
}

return downloadModules(cfg)
}

func downloadModules(cfg Config) error {
cfg.Logger.Info("Getting go modules")
// basic retry if error from go mod command (in case of transient network error). This could be improved
// retry 3 times with 5 second spacing interval
retries := 3
failReason := "unknown"
for i := 1; i <= retries; i++ {
if err := runGoCommand(cfg, "mod", "download"); err != nil {
if _, err := runGoCommand(cfg, "mod", "download"); err != nil {
failReason = err.Error()
cfg.Logger.Info("Failed modules download", zap.String("retry", fmt.Sprintf("%d/%d", i, retries)))
time.Sleep(5 * time.Second)
continue
}
return nil
}
return fmt.Errorf("failed to download go modules: %s", failReason)
return fmt.Errorf("%w: %s", errDownloadFailed, failReason)
}

func processAndWrite(cfg Config, tmpl *template.Template, outFile string, tmplParams any) error {
Expand All @@ -164,3 +222,43 @@ func processAndWrite(cfg Config, tmpl *template.Template, outFile string, tmplPa
defer out.Close()
return tmpl.Execute(out, tmplParams)
}

func (c *Config) coreModuleAndVersion() (string, string) {
module := "go.opentelemetry.io/collector"
if c.Distribution.RequireOtelColModule {
module += "/otelcol"
}
return module, "v" + c.Distribution.OtelColVersion
}

func (c *Config) allComponents() []Module {
// TODO: Use slices.Concat when we drop support for Go 1.21
return append(c.Exporters,
append(c.Receivers,
append(c.Processors,
append(c.Extensions,
c.Connectors...)...)...)...)
}

func (c *Config) readGoModFile() (string, map[string]string, error) {
var modPath string
stdout, err := runGoCommand(*c, "mod", "edit", "-print")
if err != nil {
return modPath, nil, err
}
parsedFile, err := modfile.Parse("go.mod", stdout, nil)
if err != nil {
return modPath, nil, err
}
if parsedFile != nil && parsedFile.Module != nil {
modPath = parsedFile.Module.Mod.Path
}
dependencies := map[string]string{}
for _, req := range parsedFile.Require {
if req == nil {
continue
}
dependencies[req.Mod.Path] = req.Mod.Version
}
return modPath, dependencies, nil
}
Loading

0 comments on commit e9b432d

Please sign in to comment.