internal/fields/dependency_manager.go (307 lines of code) (raw):

// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one // or more contributor license agreements. Licensed under the Elastic License; // you may not use this file except in compliance with the Elastic License. package fields import ( "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "github.com/elastic/elastic-package/internal/common" "github.com/elastic/elastic-package/internal/configuration/locations" "github.com/elastic/elastic-package/internal/logger" "github.com/elastic/elastic-package/internal/packages/buildmanifest" ) const ( ecsSchemaName = "ecs" gitReferencePrefix = "git@" localFilePrefix = "file://" ecsSchemaFile = "ecs_nested.yml" ecsSchemaURL = "https://raw.githubusercontent.com/elastic/ecs/%s/generated/ecs/%s" ) // DependencyManager is responsible for resolving external field dependencies. type DependencyManager struct { schema map[string][]FieldDefinition } // CreateFieldDependencyManager function creates a new instance of the DependencyManager. func CreateFieldDependencyManager(deps buildmanifest.Dependencies) (*DependencyManager, error) { schema, err := buildFieldsSchema(deps) if err != nil { return nil, fmt.Errorf("can't build fields schema: %w", err) } return &DependencyManager{ schema: schema, }, nil } func buildFieldsSchema(deps buildmanifest.Dependencies) (map[string][]FieldDefinition, error) { schema := map[string][]FieldDefinition{} ecsSchema, err := loadECSFieldsSchema(deps.ECS) if err != nil { return nil, fmt.Errorf("can't load fields: %w", err) } schema[ecsSchemaName] = ecsSchema return schema, nil } func loadECSFieldsSchema(dep buildmanifest.ECSDependency) ([]FieldDefinition, error) { if dep.Reference == "" { logger.Debugf("ECS dependency isn't defined") return nil, nil } content, err := readECSFieldsSchemaFile(dep) if err != nil { return nil, fmt.Errorf("error reading ECS fields schema file: %w", err) } return parseECSFieldsSchema(content) } func readECSFieldsSchemaFile(dep buildmanifest.ECSDependency) ([]byte, error) { if strings.HasPrefix(dep.Reference, localFilePrefix) { path := strings.TrimPrefix(dep.Reference, localFilePrefix) return os.ReadFile(path) } gitReference, err := asGitReference(dep.Reference) if err != nil { return nil, fmt.Errorf("can't process the value as Git reference: %w", err) } loc, err := locations.NewLocationManager() if err != nil { return nil, fmt.Errorf("error fetching profile path: %w", err) } cachedSchemaPath := filepath.Join(loc.CacheDir(locations.FieldsCacheName), ecsSchemaName, gitReference, ecsSchemaFile) content, err := os.ReadFile(cachedSchemaPath) if errors.Is(err, os.ErrNotExist) { logger.Debugf("Pulling ECS dependency using reference: %s", dep.Reference) url := fmt.Sprintf(ecsSchemaURL, gitReference, ecsSchemaFile) logger.Debugf("Schema URL: %s", url) resp, err := http.Get(url) if err != nil { return nil, fmt.Errorf("can't download the online schema (URL: %s): %w", url, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("unsatisfied ECS dependency, reference defined in build manifest doesn't exist (HTTP StatusNotFound, URL: %s)", url) } else if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode) } content, err = io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("can't read schema content (URL: %s): %w", url, err) } logger.Debugf("Downloaded %d bytes", len(content)) cachedSchemaDir := filepath.Dir(cachedSchemaPath) err = os.MkdirAll(cachedSchemaDir, 0755) if err != nil { return nil, fmt.Errorf("can't create cache directories for schema (path: %s): %w", cachedSchemaDir, err) } logger.Debugf("Cache downloaded schema: %s", cachedSchemaPath) err = os.WriteFile(cachedSchemaPath, content, 0644) if err != nil { return nil, fmt.Errorf("can't write cached schema (path: %s): %w", cachedSchemaPath, err) } } else if err != nil { return nil, fmt.Errorf("can't read cached schema (path: %s): %w", cachedSchemaPath, err) } return content, nil } func parseECSFieldsSchema(content []byte) ([]FieldDefinition, error) { var fields FieldDefinitions err := yaml.Unmarshal(content, &fields) if err != nil { return nil, fmt.Errorf("unmarshalling field body failed: %w", err) } // Force to set External as "ecs" in all these fields to be able to distinguish // which fields come from ECS and which ones are loaded from the package directory fields = setExternalAsECS(fields) return fields, nil } func setExternalAsECS(fields []FieldDefinition) []FieldDefinition { for i := 0; i < len(fields); i++ { f := &fields[i] f.External = "ecs" if len(f.MultiFields) > 0 { for j := 0; j < len(f.MultiFields); j++ { mf := &f.MultiFields[j] mf.External = "ecs" } } if len(f.Fields) > 0 { f.Fields = setExternalAsECS(f.Fields) } } return fields } func asGitReference(reference string) (string, error) { if !strings.HasPrefix(reference, gitReferencePrefix) { return "", errors.New(`invalid Git reference ("git@" prefix expected)`) } return reference[len(gitReferencePrefix):], nil } // InjectFieldsOptions allow to configure fields injection. type InjectFieldsOptions struct { // KeepExternal can be set to true to avoid deleting the `external` parameter // of a field when resolving it. This helps keeping behaviours that depended // in previous versions on lazy resolution of external fields. KeepExternal bool // SkipEmptyFields can be set to true to skip empty groups when injecting fields. SkipEmptyFields bool // DisallowReusableECSFieldsAtTopLevel can be set to true to disallow importing reusable // ECS fields at the top level, when they cannot be reused there. DisallowReusableECSFieldsAtTopLevel bool // IncludeValidationSettings can be set to enable the injection of settings of imported // fields that are only used for validation of documents, but are not needed on built packages. IncludeValidationSettings bool root string } // InjectFields function replaces external field references with target definitions. func (dm *DependencyManager) InjectFields(defs []common.MapStr) ([]common.MapStr, bool, error) { return dm.injectFieldsWithOptions(defs, InjectFieldsOptions{}) } // InjectFieldsWithOptions function replaces external field references with target definitions. // It can be configured with options. func (dm *DependencyManager) InjectFieldsWithOptions(defs []common.MapStr, options InjectFieldsOptions) ([]common.MapStr, bool, error) { return dm.injectFieldsWithOptions(defs, options) } func (dm *DependencyManager) injectFieldsWithOptions(defs []common.MapStr, options InjectFieldsOptions) ([]common.MapStr, bool, error) { var updated []common.MapStr var changed bool for _, def := range defs { fieldPath := buildFieldPath(options.root, def) external, _ := def.GetValue("external") if external != nil { imported, err := dm.importField(external.(string), fieldPath) if err != nil { return nil, false, fmt.Errorf("can't import field: %w", err) } if imported.disallowAtTopLevel && options.DisallowReusableECSFieldsAtTopLevel { return nil, false, fmt.Errorf("field %s cannot be reused at top level", fieldPath) } transformed := transformImportedField(imported, options) // Allow overrides of everything, except the imported type, for consistency. transformed.DeepUpdate(def) if !options.KeepExternal { transformed.Delete("external") } // Set the type back to the one imported, unless it is one of the allowed overrides. if ttype, _ := transformed["type"].(string); !allowedTypeOverride(imported.Type, ttype) { transformed["type"] = imported.Type } def = transformed changed = true } else { fields, _ := def.GetValue("fields") if fields != nil { fieldsMs, err := common.ToMapStrSlice(fields) if err != nil { return nil, false, fmt.Errorf("can't convert fields: %w", err) } childrenOptions := options childrenOptions.root = fieldPath updatedFields, fieldsChanged, err := dm.injectFieldsWithOptions(fieldsMs, childrenOptions) if err != nil { return nil, false, err } if fieldsChanged { changed = true } def.Put("fields", updatedFields) } } if options.SkipEmptyFields && skipField(def) { changed = true continue } updated = append(updated, def) } return updated, changed, nil } // skipField decides if a field should be skipped and not injected in the built fields. func skipField(def common.MapStr) bool { t, _ := def.GetValue("type") if t == "group" { // Keep empty external groups for backwards compatibility in docs generation. external, _ := def.GetValue("external") if external != nil { return false } fields, _ := def.GetValue("fields") switch fields := fields.(type) { case nil: return true case []interface{}: return len(fields) == 0 case []common.MapStr: return len(fields) == 0 } } return false } func allowedTypeOverride(fromType, toType string) bool { allowed := []struct { from string to string }{ // Support the case of setting the value already in the mappings. {"keyword", "constant_keyword"}, // Support objects in ECS where the developer must decide if using // a group or nested object. {"object", "group"}, {"object", "nested"}, } for _, a := range allowed { if a.from == fromType && a.to == toType { return true } } return false } // importField method resolves dependency on a single external field using available schemas. func (dm *DependencyManager) importField(schemaName, fieldPath string) (FieldDefinition, error) { if dm == nil { return FieldDefinition{}, fmt.Errorf(`importing external field "%s": external fields not allowed because dependencies file "_dev/build/build.yml" is missing`, fieldPath) } schema, ok := dm.schema[schemaName] if !ok { return FieldDefinition{}, fmt.Errorf(`schema "%s" is not defined as package depedency`, schemaName) } imported := FindElementDefinition(fieldPath, schema) if imported == nil { return FieldDefinition{}, fmt.Errorf("field definition not found in schema (name: %s)", fieldPath) } return *imported, nil } // ImportAllFields method resolves all fields available in the default ECS schema. func (dm *DependencyManager) ImportAllFields(schemaName string) ([]FieldDefinition, error) { if dm == nil { return []FieldDefinition{}, fmt.Errorf(`importing all external fields: external fields not allowed because dependencies file "_dev/build/build.yml" is missing`) } schema, ok := dm.schema[schemaName] if !ok { return []FieldDefinition{}, fmt.Errorf(`schema "%s" is not defined as package depedency`, schemaName) } return schema, nil } func buildFieldPath(root string, field common.MapStr) string { path := root if root != "" { path += "." } fieldName, _ := field.GetValue("name") path = path + fieldName.(string) return path } func transformImportedField(fd FieldDefinition, options InjectFieldsOptions) common.MapStr { m := common.MapStr{ "name": fd.Name, "type": fd.Type, } if fd.ObjectType != "" { m["object_type"] = fd.ObjectType } // Multi-fields don't have descriptions. if fd.Description != "" { m["description"] = fd.Description } if fd.Pattern != "" { m["pattern"] = fd.Pattern } if fd.Index != nil { m["index"] = *fd.Index } if fd.DocValues != nil { m["doc_values"] = *fd.DocValues } if len(fd.MultiFields) > 0 { var t []common.MapStr for _, f := range fd.MultiFields { i := transformImportedField(f, options) t = append(t, i) } m.Put("multi_fields", t) } if options.IncludeValidationSettings { if len(fd.Normalize) > 0 { m["normalize"] = fd.Normalize } if len(fd.AllowedValues) > 0 { m["allowed_values"] = fd.AllowedValues } if len(fd.ExpectedValues) > 0 { m["expected_values"] = fd.ExpectedValues } } return m }