cmd/distrogen/distribution.go (340 lines of code) (raw):

// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "errors" "fmt" "io/fs" "os" "path/filepath" "reflect" "strings" "github.com/google/go-cmp/cmp" ) var ErrNoDiff = errors.New("no differences found with previous generation") type BuildContainerOption string const ( Alpine BuildContainerOption = "alpine" Ubuntu BuildContainerOption = "ubuntu" ) // DistributionSpec is the specification for a new OpenTelemetry Collector distribution. // It contains all the information that will be formatted into the default set of // templates/user provided templates. type DistributionSpec struct { Name string `yaml:"name"` Module string `yaml:"module"` DisplayName string `yaml:"display_name"` Description string `yaml:"description"` Blurb string `yaml:"blurb"` BuildContainer BuildContainerOption `yaml:"build_container"` Version string `yaml:"version"` OpenTelemetryVersion string `yaml:"opentelemetry_version"` OpenTelemetryContribVersion string `yaml:"opentelemetry_contrib_version"` OpenTelemetryStableVersion string `yaml:"opentelemetry_stable_version"` GoVersion string `yaml:"go_version"` BinaryName string `yaml:"binary_name"` BuildTags string `yaml:"build_tags"` CollectorCGO bool `yaml:"collector_cgo"` DockerRepo string `yaml:"docker_repo"` Components *DistributionComponents `yaml:"components"` Replaces ComponentReplaces `yaml:"replaces,omitempty"` CustomValues map[string]any `yaml:"custom_values,omitempty"` FeatureGates FeatureGates `yaml:"feature_gates"` } // Diff will compare two different DistributionSpecs. func (s *DistributionSpec) Diff(s2 *DistributionSpec) bool { diff := cmp.Diff(s, s2) return diff != "" } var ErrQueryValueNotFound = errors.New("not found in spec") var ErrQueryValueInvalid = errors.New("found in spec but unsupported type") // Query will get a field from a loaded spec based on the yaml // field name. func (s *DistributionSpec) Query(field string) (string, error) { v := reflect.ValueOf(s).Elem() t := v.Type() for i := 0; i < t.NumField(); i++ { structField := t.Field(i) yamlTag := structField.Tag.Get("yaml") // Handle tags like "replaces,omitempty" tagName := strings.Split(yamlTag, ",")[0] if tagName == field { fieldValue := v.Field(i) // Convert the field value to string. // This handles basic types like string, int, bool. if fieldValue.IsValid() && fieldValue.CanInterface() { return fmt.Sprintf("%v", fieldValue.Interface()), nil } return "", fmt.Errorf("field '%s': %w", field, ErrQueryValueInvalid) } } return "", fmt.Errorf("field '%s': %w", field, ErrQueryValueNotFound) } // NewDistributionSpec loads the DistributionSpec from a yaml file. func NewDistributionSpec(path string) (*DistributionSpec, error) { spec, err := yamlUnmarshalFromFile[DistributionSpec](path) if err != nil { return nil, err } // It is a rare case where the contrib version falls out of sync with // the canonical OpenTelemetry version, most of the time it is the same. if spec.OpenTelemetryContribVersion == "" { spec.OpenTelemetryContribVersion = spec.OpenTelemetryVersion } if spec.BuildContainer == "" { spec.BuildContainer = Alpine } return spec, nil } // DistributionComponents is a set of components with RegistryComponent names // that defines all the components included in this collector distribution. type DistributionComponents struct { Receivers []string `yaml:"receivers,omitempty"` Processors []string `yaml:"processors,omitempty"` Exporters []string `yaml:"exporters,omitempty"` Connectors []string `yaml:"connectors,omitempty"` Extensions []string `yaml:"extensions,omitempty"` Providers []string `yaml:"providers,omitempty"` } // DistributionGenerator contains all the facilities to generate a distribution // from a DistributionSpec. type DistributionGenerator struct { Spec *DistributionSpec GenerateDirName string GeneratePath string Registry *Registry CustomTemplatesDir fs.FS } // NewDistributionGenerator creates a DistributionGenerator. func NewDistributionGenerator(spec *DistributionSpec, registry *Registry, forceGenerate bool) (*DistributionGenerator, error) { d := DistributionGenerator{ Spec: spec, Registry: registry, } d.GenerateDirName = spec.Name if !forceGenerate { specCache, err := yamlUnmarshalFromFile[DistributionSpec](filepath.Join(d.GenerateDirName, "spec.yaml")) if err != nil { logger.Debug(fmt.Sprintf("generated spec could not be read: %v", err)) if !os.IsNotExist(err) { return nil, err } } else { if !d.Spec.Diff(specCache) { return nil, ErrNoDiff } } } tmpDir, err := os.MkdirTemp(".", d.GenerateDirName) if err != nil { return nil, err } d.GeneratePath = tmpDir return &d, nil } // Generate will generate the distribution. It will generate the distribution // in a temporary local directory, and upon there no errors in the generation // will move it into the destination path. func (d *DistributionGenerator) Generate() error { templateContext, err := NewTemplateContextFromSpec(d.Spec, d.Registry) if err != nil { return err } templates, err := GetEmbeddedTemplateSet(templateContext) if err != nil { return err } if d.CustomTemplatesDir != nil { customTemplates, err := GetTemplateSetFromDir(d.CustomTemplatesDir, templateContext) if err != nil { return err } // This merge means that any custom templates named the same as the embedded // defaults will overwrite the embedded version with the custom version. mapMerge(templates, customTemplates) } for _, tmpl := range templates { if err := tmpl.Render(d.GeneratePath); err != nil { return err } } if err := d.WriteSpec(); err != nil { return err } return nil } // WriteSpec renders the DistributionSpec in a yaml file that lives in the generated // distribution. This is a human readable way to keep track of what spec was used for // this existing generation, as well as a method of detecting whether a new generation // needs to be done at all (if no spec changes no need to generate). func (d *DistributionGenerator) WriteSpec() error { return yamlMarshalToFile(d.Spec, filepath.Join(d.GeneratePath, "spec.yaml")) } // MoveGeneratedDirToWd performs the final step of the generation, moving the generated temp // directory to the destination path. It tries to do this in a way where nothing is destroyed // until everything is confirmed to work. func (d *DistributionGenerator) MoveGeneratedDirToWd() (err error) { wd, err := os.Getwd() if err != nil { return err } generateDest := filepath.Join(wd, d.GenerateDirName) bkpPath := generateDest + "-bkp" // Check if the distribution directory exists, rename it to backup // if it does. if _, err := os.Open(generateDest); err == nil { if err := os.Rename(generateDest, bkpPath); err != nil { return err } // Delete the backup. Sets the named `err` return value // if removal of backup fails. defer func() { err = os.RemoveAll(bkpPath) }() } // Move generated directory to working directory. if err := os.Rename(d.GeneratePath, generateDest); err != nil { return err } return nil } type generatedFile struct { path string content string checked bool } var ErrDistroFolderDoesNotExist = errors.New("distribution folder not generated") var ErrDistroFolderDiff = errors.New("existing distro folder differs from generation") // Compare will deeply compare the newly generated distro against the existing one. func (d *DistributionGenerator) Compare() error { wd, err := os.Getwd() if err != nil { return fmt.Errorf("could not get working directory: %w", err) } _, err = os.Stat(d.GeneratePath) if err != nil { if os.IsNotExist(err) { panic(fmt.Sprintf("generated directory %s does not exist, if you see this it's a code error in distrogen", d.GeneratePath)) } return wrapExitCodeError( unexpectErrExitCode, fmt.Errorf("could not stat generated directory: %w", err), ) } generateDest := filepath.Join(wd, d.GenerateDirName) _, err = os.Stat(generateDest) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("%w: %s", ErrDistroFolderDoesNotExist, generateDest) } return wrapExitCodeError( unexpectErrExitCode, fmt.Errorf("could not stat existing distro directory: %w", err), ) } logger.Debug("comparing %s to %s", d.GeneratePath, generateDest) generatedContent, err := d.getGeneratedFilesInDir() if err != nil { return wrapExitCodeError( unexpectErrExitCode, fmt.Errorf("could not get generated files: %w", err), ) } existingContent, err := d.getGeneratedFilesInDir() if err != nil { return wrapExitCodeError( unexpectErrExitCode, fmt.Errorf("could not get existing files: %w", err), ) } errs := make(CollectionError) for name, existingFile := range existingContent { generatedFile, ok := generatedContent[name] if !ok { errs[name] = fmt.Errorf("existing file not found in generated distribution") continue } existingFile.checked = true generatedFile.checked = true diff := cmp.Diff(existingFile.content, generatedFile.content) if diff != "" { errs[name] = fmt.Errorf("existing file differs from generated distribution:\n%s", diff) } } for name, generatedFile := range generatedContent { if !generatedFile.checked { errs[name] = fmt.Errorf("generated file not found in existing distribution") } } if len(errs) > 0 { return fmt.Errorf("%s: %w:\n\n%v", d.Spec.Name, ErrDistroFolderDiff, errs) } return nil } func (dg *DistributionGenerator) getGeneratedFilesInDir() (map[string]*generatedFile, error) { wd, err := os.Getwd() if err != nil { return nil, fmt.Errorf("could not get working directory: %w", err) } dir := filepath.Join(wd, dg.GenerateDirName) files := map[string]*generatedFile{} err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } // Don't include .tools directory in comparison. if strings.Contains(path, "/.tools/") { return nil } if d.Name() == dg.Spec.BinaryName { return nil } content, err := os.ReadFile(path) if err != nil { return err } files[d.Name()] = &generatedFile{ path: path, content: string(content), } return nil }) return files, err } // Clean will remove the temporary directory used for generation. func (d *DistributionGenerator) Clean() { if err := os.RemoveAll(d.GeneratePath); err != nil && !os.IsNotExist(err) { logger.Error("failed to clean generated directory", "err", err) } } // TemplateContext is the context that will be passed into any default or user // provided templates. type TemplateContext struct { *DistributionSpec Receivers RegistryComponents Processors RegistryComponents Exporters RegistryComponents Extensions RegistryComponents Connectors RegistryComponents Providers RegistryComponents } // NewTemplateContextFromSpec creates a TemplateContext from a DistributionSpec and a Registry. // It is expected that this registry will be already merged with the registries provided by the // user. func NewTemplateContextFromSpec(spec *DistributionSpec, registry *Registry) (*TemplateContext, error) { context := TemplateContext{DistributionSpec: spec} otelVersion := otelComponentVersion{ core: spec.OpenTelemetryVersion, coreStable: spec.OpenTelemetryStableVersion, contrib: spec.OpenTelemetryContribVersion, } errs := make(CollectionError) var err CollectionError context.Receivers, err = registry.Receivers.LoadAllComponents(spec.Components.Receivers, otelVersion) mapMerge(errs, err) context.Processors, err = registry.Processors.LoadAllComponents(spec.Components.Processors, otelVersion) mapMerge(errs, err) context.Exporters, err = registry.Exporters.LoadAllComponents(spec.Components.Exporters, otelVersion) mapMerge(errs, err) context.Connectors, err = registry.Connectors.LoadAllComponents(spec.Components.Connectors, otelVersion) mapMerge(errs, err) context.Extensions, err = registry.Extensions.LoadAllComponents(spec.Components.Extensions, otelVersion) mapMerge(errs, err) context.Providers, err = registry.Providers.LoadAllComponents(spec.Components.Providers, otelVersion) mapMerge(errs, err) if len(errs) > 0 { return nil, errs } return &context, nil } // FeatureGates is a list of feature gate names to enable in a // collector. type FeatureGates []string // Render will render the feature gates in a comma separated list. func (fgs FeatureGates) Render() string { // This case should never come up in template rendering, // but it's here as a backup in case. if len(fgs) == 0 { return "" } gates := "" for i, fg := range fgs { gates += fg if i < len(fgs)-1 { gates += "," } } return gates } // ComponentReplace is a Go module replacement that will be // rendered into the OCB manifest. type ComponentReplace struct { From *GoModuleID `yaml:"from"` To *GoModuleID `yaml:"to"` Reason string `yaml:"reason"` } // String renders the component replace for an OCB manifest. func (r *ComponentReplace) String() string { r.From.AllowBlankTag = true r.To.AllowBlankTag = true // This is pretty awkward and it would be nice to implement yaml.Marshaler // on here instead, but this was the only nice way I could find to render // the Reason field as a comment above the replacement entry. return fmt.Sprintf("# %s\n- %s => %s", r.Reason, r.From, r.To) } // ComponentReplaces is a collection of component replacements. type ComponentReplaces []*ComponentReplace // Render renders the component replaces all at once // for an OCB manifest. func (rs ComponentReplaces) Render() string { result := "" for _, r := range rs { result += fmt.Sprintf("%s\n", r) } return result }