packages/package.go (591 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 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package packages
import (
"errors"
"fmt"
"os"
"path"
"slices"
"strings"
"github.com/Masterminds/semver/v3"
"go.uber.org/zap"
"github.com/elastic/go-ucfg"
"github.com/elastic/go-ucfg/yaml"
"github.com/elastic/package-registry/categories"
)
const (
defaultType = "integration"
// Prefix used for all assets served for a package
packagePathPrefix = "/package"
)
var (
Categories = categories.DefaultCategories()
// Deprecated, keeping for backwards compatibility, Categories should be used instead.
CategoryTiles = categoryTitles(categories.DefaultCategories())
)
type Package struct {
BasePackage `config:",inline" json:",inline" yaml:",inline"`
FormatVersion string `config:"format_version" json:"format_version" yaml:"format_version"`
Readme *string `config:"readme,omitempty" json:"readme,omitempty" yaml:"readme,omitempty"`
License string `config:"license,omitempty" json:"license,omitempty" yaml:"license,omitempty"`
Screenshots []Image `config:"screenshots,omitempty" json:"screenshots,omitempty" yaml:"screenshots,omitempty"`
Assets []string `config:"assets,omitempty" json:"assets,omitempty" yaml:"assets,omitempty"`
PolicyTemplates []PolicyTemplate `config:"policy_templates,omitempty" json:"policy_templates,omitempty" yaml:"policy_templates,omitempty"`
DataStreams []*DataStream `config:"data_streams,omitempty" json:"data_streams,omitempty" yaml:"data_streams,omitempty"`
Vars []Variable `config:"vars" json:"vars,omitempty" yaml:"vars,omitempty"`
Elasticsearch *PackageElasticsearch `config:"elasticsearch,omitempty" json:"elasticsearch,omitempty" yaml:"elasticsearch,omitempty"`
Agent *PackageAgent `config:"agent,omitempty" json:"agent,omitempty" yaml:"agent,omitempty"`
// Local path to the package dir
BasePath string `json:"-" yaml:"-"`
versionSemVer *semver.Version
specMajorMinorSemVer *semver.Version
fsBuilder FileSystemBuilder
resolver RemoteResolver
logger *zap.Logger
}
type FileSystemBuilder func(*Package) (PackageFileSystem, error)
// BasePackage is used for the output of the package info in the /search endpoint
type BasePackage struct {
Name string `config:"name" json:"name"`
Title *string `config:"title,omitempty" json:"title,omitempty" yaml:"title,omitempty"`
Version string `config:"version" json:"version"`
Release string `config:"release,omitempty" json:"release,omitempty"`
Source *Source `config:"source,omitempty" json:"source,omitempty" yaml:"source,omitempty"`
Description string `config:"description" json:"description"`
Type string `config:"type" json:"type"`
Download string `json:"download" yaml:"download,omitempty"`
Path string `json:"path" yaml:"path,omitempty"`
Icons []Image `config:"icons,omitempty" json:"icons,omitempty" yaml:"icons,omitempty"`
PolicyTemplatesBehavior string `config:"policy_templates_behavior,omitempty" json:"policy_templates_behavior,omitempty" yaml:"policy_templates_behavior,omitempty"`
BasePolicyTemplates []BasePolicyTemplate `json:"policy_templates,omitempty"`
Conditions *Conditions `config:"conditions,omitempty" json:"conditions,omitempty" yaml:"conditions,omitempty"`
Owner *Owner `config:"owner,omitempty" json:"owner,omitempty" yaml:"owner,omitempty"`
Categories []string `config:"categories,omitempty" json:"categories,omitempty" yaml:"categories,omitempty"`
SignaturePath string `config:"signature_path,omitempty" json:"signature_path,omitempty" yaml:"signature_path,omitempty"`
Discovery *Discovery `config:"discovery,omitempty" json:"discovery,omitempty" yaml:"discovery,omitempty"`
}
// BasePolicyTemplate is used for the package policy templates in the /search endpoint
type BasePolicyTemplate struct {
Name string `config:"name" json:"name" validate:"required"`
Title string `config:"title" json:"title" validate:"required"`
Description string `config:"description" json:"description" validate:"required"`
Icons []Image `config:"icons,omitempty" json:"icons,omitempty" yaml:"icons,omitempty"`
Categories []string `config:"categories,omitempty" json:"categories,omitempty" yaml:"categories,omitempty"`
DeploymentModes *DeploymentModes `config:"deployment_modes,omitempty" json:"deployment_modes,omitempty" yaml:"deployment_modes,omitempty"`
}
type PolicyTemplate struct {
Name string `config:"name" json:"name" validate:"required"`
Title string `config:"title" json:"title" validate:"required"`
Description string `config:"description" json:"description" validate:"required"`
DataStreams []string `config:"data_streams,omitempty" json:"data_streams,omitempty" yaml:"data_streams,omitempty"`
Inputs []Input `config:"inputs" json:"inputs,omitempty" yaml:"inputs,omitempty"`
Multiple *bool `config:"multiple" json:"multiple,omitempty" yaml:"multiple,omitempty"`
Icons []Image `config:"icons,omitempty" json:"icons,omitempty" yaml:"icons,omitempty"`
Categories []string `config:"categories,omitempty" json:"categories,omitempty" yaml:"categories,omitempty"`
Screenshots []Image `config:"screenshots,omitempty" json:"screenshots,omitempty" yaml:"screenshots,omitempty"`
Readme *string `config:"readme,omitempty" json:"readme,omitempty" yaml:"readme,omitempty"`
DeploymentModes *DeploymentModes `config:"deployment_modes,omitempty" json:"deployment_modes,omitempty" yaml:"deployment_modes,omitempty"`
// For purposes of "input packages"
Type string `config:"type,omitempty" json:"type,omitempty" yaml:"type,omitempty"`
Input string `config:"input,omitempty" json:"input,omitempty" yaml:"input,omitempty"`
TemplatePath string `config:"template_path,omitempty" json:"template_path,omitempty" yaml:"template_path,omitempty"`
}
// Source contains metadata about the source of the package and its distribution.
type Source struct {
License string `config:"license,omitempty" json:"license,omitempty" yaml:"license,omitempty"`
}
type Conditions struct {
Kibana *KibanaConditions `config:"kibana,omitempty" json:"kibana,omitempty" yaml:"kibana,omitempty"`
Elastic *ElasticConditions `config:"elastic,omitempty" json:"elastic,omitempty" yaml"elastic,omitempty"`
}
// KibanaConditions defines conditions for Kibana (e.g. required version).
type KibanaConditions struct {
Version string `config:"version" json:"version" yaml:"version"`
constraint *semver.Constraints
}
// ElasticConditions defines conditions related to Elastic subscriptions or partnerships.
type ElasticConditions struct {
Subscription string `config:"subscription" json:"subscription" yaml:"subscription"`
Capabilities []string `config:"capabilities,omitempty" json:"capabilities,omitempty" yaml:"capabilities,omitempty"`
}
type Version struct {
Min string `config:"min,omitempty" json:"min,omitempty"`
Max string `config:"max,omitempty" json:"max,omitempty"`
}
type Owner struct {
Type string `config:"type,omitempty" json:"type,omitempty"`
Github string `config:"github,omitempty" json:"github,omitempty"`
}
type Image struct {
// Src is relative inside the package
Src string `config:"src" json:"src" validate:"required"`
// Path is the absolute path in the url
// TODO: remove yaml struct tag once mage ImportBeats is removed from elastic/integrations repo.
Path string `config:"path" json:"path" yaml:"path,omitempty"`
Title string `config:"title" json:"title,omitempty"`
Size string `config:"size" json:"size,omitempty"`
Type string `config:"type" json:"type,omitempty"`
}
type PackageAgent struct {
Privileges *PackageAgentPrivileges `config:"privileges,omitempty" json:"privileges,omitempty" yaml:"privileges,omitempty"`
}
type PackageAgentPrivileges struct {
Root bool `config:"root,omitempty" json:"root,omitempty" yaml:"root,omitempty"`
}
type PackageElasticsearch struct {
Privileges *PackageElasticsearchPrivileges `config:"privileges,omitempty" json:"privileges,omitempty" yaml:"privileges,omitempty"`
}
// Discovery define indications for the data this package can be useful with.
type Discovery struct {
Fields []DiscoveryField `config:"fields,omitempty" json:"fields,omitempty" yaml:"fields,omitempty"`
}
// DiscoveryField defines a field used for discovery.
type DiscoveryField struct {
Name string `config:"name" json:"name" yaml:"name"`
}
type PackageElasticsearchPrivileges struct {
Cluster []string `config:"cluster,omitempty" json:"cluster,omitempty" yaml:"cluster,omitempty"`
}
func (i Image) getPath(p *Package) string {
return path.Join(packagePathPrefix, p.Name, p.Version, i.Src)
}
type Download struct {
Path string `config:"path" json:"path" validate:"required"`
Type string `config:"type" json:"type" validate:"required"`
}
type DeploymentModes struct {
Default *DeploymentMode `config:"default,omitempty" json:"default,omitempty" yaml:"default,omitempty"`
Agentless *DeploymentMode `config:"agentless,omitempty" json:"agentless,omitempty" yaml:"agentless,omitempty"`
}
type DeploymentMode struct {
Enabled bool `config:"enabled" json:"enabled" yaml:"enabled" validate:"required"`
IsDefault *bool `config:"is_default" json:"is_default,omitempty" yaml:"is_default,omitempty"`
}
func NewDownload(p Package, t string) Download {
return Download{
Path: getDownloadPath(p, t),
Type: t,
}
}
func getDownloadPath(p Package, t string) string {
return path.Join("/epr", p.Name, p.Name+"-"+p.Version+".zip")
}
// NewPackage creates a new package instances based on the given base path.
// The path passed goes to the root of the package where the manifest.yml is.
func NewPackage(logger *zap.Logger, basePath string, fsBuilder FileSystemBuilder) (*Package, error) {
p := &Package{
BasePath: basePath,
fsBuilder: fsBuilder,
logger: logger,
}
fs, err := p.fs()
if err != nil {
return nil, err
}
defer fs.Close()
manifestBody, err := ReadAll(fs, "manifest.yml")
if err != nil {
return nil, err
}
manifest, err := yaml.NewConfig(manifestBody, ucfg.PathSep("."))
if err != nil {
return nil, err
}
err = manifest.Unpack(p, ucfg.PathSep("."))
if err != nil {
return nil, err
}
p.logger = p.logger.With(zap.String("package", p.Name), zap.String("version", p.Version))
// Default for the multiple flags is true.
trueValue := true
for i := range p.PolicyTemplates {
if p.PolicyTemplates[i].Multiple == nil {
p.PolicyTemplates[i].Multiple = &trueValue
}
// Collect basic information from policy templates and store into the /search endpoint
t := p.PolicyTemplates[i]
for k, i := range p.PolicyTemplates[i].Icons {
t.Icons[k].Path = i.getPath(p)
}
// Store paths for all screenshots under each policy template
if p.PolicyTemplates[i].Screenshots != nil {
for k, s := range p.PolicyTemplates[i].Screenshots {
p.PolicyTemplates[i].Screenshots[k].Path = s.getPath(p)
}
}
// Store policy template specific README
readmePath := path.Join("docs", p.PolicyTemplates[i].Name+".md")
readme, err := fs.Stat(readmePath)
if err != nil {
if _, ok := err.(*os.PathError); !ok {
return nil, fmt.Errorf("failed to find %s file: %s", p.PolicyTemplates[i].Name+".md", err)
}
} else if readme != nil {
if readme.IsDir() {
return nil, fmt.Errorf("%s.md is a directory", p.PolicyTemplates[i].Name)
}
readmePathShort := path.Join(packagePathPrefix, p.Name, p.Version, "docs", p.PolicyTemplates[i].Name+".md")
p.PolicyTemplates[i].Readme = &readmePathShort
}
}
p.setBasePolicyTemplates()
if p.Type == "" {
p.Type = defaultType
}
// If not license is set, basic is assumed
if p.License == "" {
// Keep compatibility with deprecated license field.
if p.Conditions != nil && p.Conditions.Elastic != nil && p.Conditions.Elastic.Subscription != "" {
p.License = p.Conditions.Elastic.Subscription
} else {
p.License = DefaultLicense
}
}
err = p.setRuntimeFields()
if err != nil {
return nil, err
}
if p.Icons != nil {
for k, i := range p.Icons {
p.Icons[k].Path = i.getPath(p)
}
}
if p.Screenshots != nil {
for k, s := range p.Screenshots {
p.Screenshots[k].Path = s.getPath(p)
}
}
if p.Release == "" {
p.Release = releaseForSemVerCompat(p.versionSemVer)
}
if !IsValidRelease(p.Release) {
return nil, fmt.Errorf("invalid release: %q", p.Release)
}
readmePath := path.Join("docs", "README.md")
// Check if readme
readme, err := fs.Stat(readmePath)
if err != nil {
return nil, fmt.Errorf("no readme file found, README.md is required: %s", err)
}
if readme != nil {
if readme.IsDir() {
return nil, fmt.Errorf("README.md is a directory")
}
readmePathShort := path.Join(packagePathPrefix, p.Name, p.Version, "docs", "README.md")
p.Readme = &readmePathShort
}
// Assign download path to be part of the output
p.Download = p.GetDownloadPath()
p.Path = p.GetUrlPath()
err = p.LoadAssets()
if err != nil {
return nil, fmt.Errorf("loading package assets failed (path '%s'): %w", p.BasePath, err)
}
err = p.LoadDataSets()
if err != nil {
return nil, fmt.Errorf("loading package data streams failed (path '%s'): %w", p.BasePath, err)
}
// Read path for package signature
p.SignaturePath, err = p.getSignaturePath()
if err != nil {
return nil, fmt.Errorf("can't process the package signature: %w", err)
}
return p, nil
}
func (p *Package) setRuntimeFields() error {
var err error
p.versionSemVer, err = semver.StrictNewVersion(p.Version)
if err != nil {
return fmt.Errorf("invalid package version: %w", err)
}
if p.Conditions != nil && p.Conditions.Kibana != nil {
p.Conditions.Kibana.constraint, err = semver.NewConstraint(p.Conditions.Kibana.Version)
if err != nil {
return fmt.Errorf("invalid Kibana versions range %s: %w", p.Conditions.Kibana.Version, err)
}
}
// Packages from proxy mode do not have "format_version" field
if p.FormatVersion == "" {
return nil
}
specSemVer, err := semver.StrictNewVersion(p.FormatVersion)
if err != nil {
return fmt.Errorf("invalid format spec version '%s': %w", p.FormatVersion, err)
}
specMajorMinorVersion := fmt.Sprintf("%d.%d.0", specSemVer.Major(), specSemVer.Minor())
p.specMajorMinorSemVer, err = semver.StrictNewVersion(specMajorMinorVersion)
if err != nil {
return fmt.Errorf("invalid format spec version '%s': %w", specMajorMinorVersion, err)
}
return nil
}
// setBasePolicyTemplates method mirrors policy_templates from Package to a corresponding property in BasePackage.
// It's required to perform that sync, because PolicyTemplates and BasePolicyTemplates have same JSON annotation
// (policy_template).
func (p *Package) setBasePolicyTemplates() {
for _, t := range p.PolicyTemplates {
baseT := BasePolicyTemplate{
Name: t.Name,
Title: t.Title,
Description: t.Description,
Categories: t.Categories,
Icons: t.Icons,
DeploymentModes: t.DeploymentModes,
}
p.BasePolicyTemplates = append(p.BasePolicyTemplates, baseT)
}
}
func (p *Package) HasCategory(category string) bool {
return hasCategory(p.Categories, category)
}
func (p *Package) HasPolicyTemplateWithCategory(category string) bool {
for _, pt := range p.PolicyTemplates {
if hasCategory(pt.Categories, category) {
return true
}
}
return false
}
func hasCategory(categories []string, category string) bool {
if slices.Contains(categories, category) {
return true
}
// Check if this category has subcategories, and the package contains any of them.
for _, subcategory := range Categories {
if subcategory.Parent == nil || subcategory.Parent.Name != category {
continue
}
if slices.Contains(categories, subcategory.Name) {
return true
}
}
return false
}
func (p *Package) HasKibanaVersion(version *semver.Version) bool {
// If the version is not specified, it is for all versions
if p.Conditions == nil || p.Conditions.Kibana == nil || p.Conditions.Kibana.constraint == nil || version == nil {
return true
}
return p.Conditions.Kibana.constraint.Check(version)
}
func (p *Package) WorksWithCapabilities(capabilities []string) bool {
if p.Conditions == nil || p.Conditions.Elastic == nil || p.Conditions.Elastic.Capabilities == nil || capabilities == nil {
return true
}
for _, requiredCapability := range p.Conditions.Elastic.Capabilities {
if !slices.Contains(capabilities, requiredCapability) {
return false
}
}
return true
}
func (p *Package) HasCompatibleSpec(specMin, specMax, kibanaVersion *semver.Version) (bool, error) {
if specMin == nil && specMax == nil {
return true, nil
}
constraints := []string{}
if specMin != nil {
constraints = append(constraints, fmt.Sprintf(">=%s", specMin.String()))
}
if specMax != nil {
constraints = append(constraints, fmt.Sprintf("<=%s", specMax.String()))
}
fullConstraint := strings.Join(constraints, ",")
constraint, err := semver.NewConstraint(fullConstraint)
if err != nil {
return false, fmt.Errorf("cannot create constraint %s: %w", fullConstraint, err)
}
return constraint.Check(p.specMajorMinorSemVer), nil
}
func (p *Package) IsNewerOrEqual(pp *Package) bool {
return !p.versionSemVer.LessThan(pp.versionSemVer)
}
func (p *Package) IsPrerelease() bool {
return isPrerelease(p.versionSemVer)
}
func isPrerelease(version *semver.Version) bool {
if version.Major() < 1 {
return true
}
return version.Prerelease() != ""
}
// LoadAssets (re)loads all the assets of the package
// Based on the time when this is called, it might be that not all assets for a package exist yet, so it is reset every time.
func (p *Package) LoadAssets() (err error) {
fs, err := p.fs()
if err != nil {
return err
}
defer fs.Close()
// Reset Assets
p.Assets = nil
// Iterates recursively through all the levels to find assets
// If we need more complex matching a library like https://github.com/bmatcuk/doublestar
// could be used but the below works and is pretty simple.
assets, err := collectAssets(fs, "*")
if err != nil {
return err
}
for _, a := range assets {
// Unfortunately these files keep sneaking in
if strings.Contains(a, ".DS_Store") {
continue
}
info, err := fs.Stat(a)
if err != nil {
return err
}
if info.IsDir() {
if strings.Contains(info.Name(), "-") {
return fmt.Errorf("directory name inside package %s contains -: %s", p.Name, a)
}
continue
}
a = path.Join(packagePathPrefix, p.GetPath(), a)
p.Assets = append(p.Assets, a)
}
return nil
}
func collectAssets(fs PackageFileSystem, pattern string) ([]string, error) {
assets, err := fs.Glob(pattern)
if err != nil {
return nil, err
}
if len(assets) != 0 {
a, err := collectAssets(fs, path.Join(pattern, "*"))
if err != nil {
return nil, err
}
return append(assets, a...), nil
}
return nil, nil
}
func (p *Package) fs() (PackageFileSystem, error) {
if p.fsBuilder == nil {
return NewVirtualPackageFileSystem()
}
return p.fsBuilder(p)
}
// Validate is called during Unpack of the manifest.
// The validation here is only related to the fields directly specified in the manifest itself.
func (p *Package) Validate() error {
if ValidationDisabled {
return nil
}
if p.FormatVersion == "" {
return fmt.Errorf("no format_version set: %v", p)
}
_, err := semver.StrictNewVersion(p.FormatVersion)
if err != nil {
return fmt.Errorf("invalid package version: %s, %s", p.FormatVersion, err)
}
p.versionSemVer, err = semver.StrictNewVersion(p.Version)
if err != nil {
return err
}
if p.Release == "" {
p.Release = releaseForSemVerCompat(p.versionSemVer)
}
if p.Title == nil || *p.Title == "" {
return fmt.Errorf("no title set for package: %s", p.Name)
}
if p.Description == "" {
return fmt.Errorf("no description set")
}
j := 0
for i, c := range p.Categories {
if _, ok := Categories[c]; !ok {
p.logger.Warn("package uses an unknown category, will be ignored",
zap.String("package", p.Name),
zap.String("version", p.Version),
zap.String("category", c))
continue
}
p.Categories[j] = p.Categories[i]
j += 1
}
p.Categories = p.Categories[:j]
fs, err := p.fs()
if err != nil {
return err
}
defer fs.Close()
for _, i := range p.Icons {
_, err := fs.Stat(i.Src)
if err != nil {
return err
}
}
for _, s := range p.Screenshots {
_, err := fs.Stat(s.Src)
if err != nil {
return err
}
}
err = p.validateVersionConsistency()
if err != nil {
return fmt.Errorf("version in manifest file is not consistent with path: %w", err)
}
return p.ValidateDataStreams()
}
func (p *Package) validateVersionConsistency() error {
versionPackage, err := semver.NewVersion(p.Version)
if err != nil {
return fmt.Errorf("invalid version defined in manifest: %w", err)
}
baseDir := path.Base(p.BasePath)
versionDir, err := semver.NewVersion(baseDir)
if err != nil {
// TODO: There should be a flag passed to the registry to accept these kind of packages
// as otherwise these could hide some errors in the structure of the package-storage
return nil // package content is not rooted in version directory
}
if !versionPackage.Equal(versionDir) {
return fmt.Errorf("inconsistent versions (path: %s, manifest: %s)", versionDir.String(), p.versionSemVer.String())
}
return nil
}
// GetDataStreamPaths returns a list with the dataStream paths inside this package
func (p *Package) GetDataStreamPaths() ([]string, error) {
fs, err := p.fs()
if err != nil {
return nil, err
}
defer fs.Close()
dataStreamBasePath := "data_stream"
// Look for a file here that a data_stream must have, some file systems as Zip files
// may not have entries for directories.
paths, err := fs.Glob(path.Join(dataStreamBasePath, "*", "manifest.yml"))
if err != nil {
return nil, err
}
for i := range paths {
if !strings.HasPrefix(paths[i], dataStreamBasePath) && !strings.HasPrefix(paths[i], "/data_stream") {
return nil, fmt.Errorf("failed to get data stream path inside package: cannot make %q relative to %q", paths[i], dataStreamBasePath)
}
relPath := strings.TrimPrefix(paths[i], dataStreamBasePath)
paths[i] = path.Dir(relPath)
}
return paths, nil
}
func (p *Package) LoadDataSets() error {
dataStreamPaths, err := p.GetDataStreamPaths()
if err != nil {
return err
}
dataStreamsBasePath := "data_stream"
for _, dataStreamPath := range dataStreamPaths {
dataStreamBasePath := path.Join(dataStreamsBasePath, dataStreamPath)
d, err := NewDataStream(dataStreamBasePath, p)
if err != nil {
return err
}
p.DataStreams = append(p.DataStreams, d)
}
return nil
}
// ValidateDataStreams loads all dataStreams and with it validates them
func (p *Package) ValidateDataStreams() error {
dataStreamPaths, err := p.GetDataStreamPaths()
if err != nil {
return err
}
dataStreamsBasePath := "data_stream"
for _, dataStreamPath := range dataStreamPaths {
dataStreamBasePath := path.Join(dataStreamsBasePath, dataStreamPath)
d, err := NewDataStream(dataStreamBasePath, p)
if err != nil {
return fmt.Errorf("building data stream failed (path: %s): %w", dataStreamBasePath, err)
}
err = d.Validate()
if err != nil {
return fmt.Errorf("validating data stream failed (path: %s): %w", dataStreamBasePath, err)
}
}
return nil
}
func (p *Package) GetPath() string {
return p.Name + "/" + p.Version
}
func (p *Package) GetDownloadPath() string {
return path.Join("/epr", p.Name, p.Name+"-"+p.Version+".zip")
}
func (p *Package) GetUrlPath() string {
return path.Join(packagePathPrefix, p.Name, p.Version)
}
func (p *Package) getSignaturePath() (string, error) {
_, err := os.Stat(p.BasePath + ".sig")
if err != nil && errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("can't stat signature file: %w", err)
}
return p.GetDownloadPath() + ".sig", nil
}
func (p *Package) SetRemoteResolver(r RemoteResolver) {
p.resolver = r
}
func (p *Package) RemoteResolver() RemoteResolver {
return p.resolver
}
func categoryTitles(categories categories.Categories) map[string]string {
titles := make(map[string]string)
for _, category := range categories {
titles[category.Name] = category.Title
}
return titles
}