Skip to content

Simple includes implementation #2483

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 42 commits into from
Jul 9, 2025
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
04fb45d
Simple includes implementation
marc-gr Mar 18, 2025
f778462
Second iteration
marc-gr Mar 18, 2025
f24f948
Use link files instead of includes file
marc-gr Mar 19, 2025
52132f5
Collect included pipelines in tests and benchmarks
marc-gr Mar 20, 2025
fc8f054
Create complete path if not exist
marc-gr Mar 25, 2025
268e23e
Add test package
marc-gr Mar 25, 2025
4b6dcb0
Add links commands
marc-gr Mar 25, 2025
0fb950c
Improve links commands
marc-gr Mar 26, 2025
d4f4c36
List also if not in package
marc-gr Mar 26, 2025
f044059
Reorganize code
marc-gr Mar 27, 2025
e9196c0
Merge remote-tracking branch 'upstream/main' into feat/includes
marc-gr Mar 27, 2025
44a1d1a
go mod tidy
marc-gr Mar 27, 2025
afc4b26
Only read entire file when copying
marc-gr Mar 28, 2025
53eead0
Add unit tests
marc-gr Mar 31, 2025
c2cdf7b
Always copy file on build
marc-gr Mar 31, 2025
eb8b05c
remove unused function
marc-gr Mar 31, 2025
0fdd2df
Use package in spec
marc-gr Apr 2, 2025
a4322e5
Use package spec
marc-gr Apr 2, 2025
663ec29
Update links usage
marc-gr Apr 10, 2025
c10bdee
Merge remote-tracking branch 'upstream/main' into feat/includes
marc-gr Apr 10, 2025
6c94355
replace package-spec
marc-gr Apr 10, 2025
ed5a641
Update readme
marc-gr Apr 10, 2025
3438ac4
Merge remote-tracking branch 'upstream/main' into feat/includes
marc-gr Jun 23, 2025
f30f574
Remove go.mod replace
marc-gr Jun 23, 2025
6526118
Secure linked files: fix path traversal vulnerabilities and improve t…
marc-gr Jun 26, 2025
1e50f26
Improve linkedfiles API with convenience functions and structured res…
marc-gr Jun 26, 2025
efd9c9f
fix test cleanup
marc-gr Jun 26, 2025
3c56a23
Use spec 3.4.0 for test package
marc-gr Jun 26, 2025
ebf7c56
Fix shared folder placement
marc-gr Jun 26, 2025
1880bcd
Close root usage on cleanup
marc-gr Jun 26, 2025
e0e8754
close root
marc-gr Jun 26, 2025
b450f33
handle paths better for linksfs
marc-gr Jun 27, 2025
83478ac
Enhance LinksFS security and path handling
marc-gr Jun 27, 2025
c091ed0
lint
marc-gr Jun 27, 2025
0d70806
Merge remote-tracking branch 'upstream/main' into feat/includes
marc-gr Jun 27, 2025
1fb6aa5
Merge remote-tracking branch 'upstream/main' into feat/includes
marc-gr Jun 30, 2025
12a0bc1
Restore LinksFS support lost in upstream merge
marc-gr Jun 30, 2025
bd5b9b8
Use LinksFS as the source for operations
marc-gr Jul 8, 2025
7668010
Merge remote-tracking branch 'upstream/main' into feat/includes
marc-gr Jul 8, 2025
d4d7634
make linksfs work with relative paths from root and wd
marc-gr Jul 8, 2025
2ab1703
check
marc-gr Jul 8, 2025
70e6bc2
remove err declaration
marc-gr Jul 9, 2025
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,30 @@ Use this command to install the package in Kibana.

The command uses Kibana API to install the package in Kibana. The package must be exposed via the Package Registry or built locally in zip format so they can be installed using --zip parameter. Zip packages can be installed directly in Kibana >= 8.7.0. More details in this [HOWTO guide](https://github.com/elastic/elastic-package/blob/main/docs/howto/install_package.md).

### `elastic-package links`

_Context: global_

Use this command to manage linked files in the repository.

### `elastic-package links check`

_Context: global_

Use this command to check if linked files references inside the current directory are up to date.

### `elastic-package links list`

_Context: global_

Use this command to list all packages that have linked file references that include the current directory.

### `elastic-package links update`

_Context: global_

Use this command to update all linked files references inside the current directory.

### `elastic-package lint`

_Context: package_
Expand Down
146 changes: 146 additions & 0 deletions cmd/links.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// 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 cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"

"github.com/elastic/elastic-package/internal/cobraext"
"github.com/elastic/elastic-package/internal/files"
)

const (
linksLongDescription = `Use this command to manage linked files in the repository.`
linksCheckLongDescription = `Use this command to check if linked files references inside the current directory are up to date.`
linksUpdateLongDescription = `Use this command to update all linked files references inside the current directory.`
linksListLongDescription = `Use this command to list all packages that have linked file references that include the current directory.`
)

func setupLinksCommand() *cobraext.Command {
cmd := &cobra.Command{
Use: "links",
Short: "Manage linked files",
Long: linksLongDescription,
RunE: func(parent *cobra.Command, args []string) error {
return cobraext.ComposeCommandsParentContext(parent, args, parent.Commands()...)
},
}

cmd.AddCommand(getLinksCheckCommand())
cmd.AddCommand(getLinksUpdateCommand())
cmd.AddCommand(getLinksListCommand())

return cobraext.NewCommand(cmd, cobraext.ContextGlobal)
}

func getLinksCheckCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "check",
Short: "Check for linked files changes",
Long: linksCheckLongDescription,
Args: cobra.NoArgs,
RunE: linksCheckCommandAction,
}
return cmd
}

func linksCheckCommandAction(cmd *cobra.Command, args []string) error {
cmd.Printf("Check for linked files changes\n")
pwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("reading current working directory failed: %w", err)
}

linkedFiles, err := files.AreLinkedFilesUpToDate(pwd)
if err != nil {
return fmt.Errorf("checking linked files are up-to-date failed: %w", err)
}
for _, f := range linkedFiles {
if !f.UpToDate {
cmd.Printf("%s is outdated.\n", filepath.Join(f.WorkDir, f.LinkFilePath))
}
}
if len(linkedFiles) > 0 {
return fmt.Errorf("linked files are outdated")
}
return nil
}

func getLinksUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update linked files checksums if needed.",
Long: linksUpdateLongDescription,
Args: cobra.NoArgs,
RunE: linksUpdateCommandAction,
}
return cmd
}

func linksUpdateCommandAction(cmd *cobra.Command, args []string) error {
cmd.Printf("Update linked files checksums if needed.\n")
pwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("reading current working directory failed: %w", err)
}

updatedLinks, err := files.UpdateLinkedFilesChecksums(pwd)
if err != nil {
return fmt.Errorf("updating linked files checksums failed: %w", err)
}

for _, l := range updatedLinks {
cmd.Printf("%s was updated.\n", l.LinkFilePath)
}

return nil
}

func getLinksListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List packages linking files from this path",
Long: linksListLongDescription,
Args: cobra.NoArgs,
RunE: linksListCommandAction,
}
cmd.Flags().BoolP(cobraext.PackagesFlagName, "", false, cobraext.PackagesFlagDescription)
return cmd
}

func linksListCommandAction(cmd *cobra.Command, args []string) error {
onlyPackages, err := cmd.Flags().GetBool(cobraext.PackagesFlagName)
if err != nil {
return cobraext.FlagParsingError(err, cobraext.PackagesFlagName)
}

pwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("reading current working directory failed: %w", err)
}

byPackage, err := files.LinkedFilesByPackageFrom(pwd)
if err != nil {
return fmt.Errorf("listing linked packages failed: %w", err)
}

for i := range byPackage {
for p, links := range byPackage[i] {
if onlyPackages {
cmd.Println(p)
continue
}
for _, l := range links {
cmd.Println(l)
}
}
}

return nil
}
1 change: 0 additions & 1 deletion cmd/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ func setupLintCommand() *cobraext.Command {

func lintCommandAction(cmd *cobra.Command, args []string) error {
cmd.Println("Lint the package")

readmeFiles, err := docs.AreReadmesUpToDate()
if err != nil {
for _, f := range readmeFiles {
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var commands = []*cobraext.Command{
setupExportCommand(),
setupFormatCommand(),
setupInstallCommand(),
setupLinksCommand(),
setupLintCommand(),
setupProfilesCommand(),
setupReportsCommand(),
Expand Down
34 changes: 34 additions & 0 deletions docs/howto/links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# HOWTO: Use links to reuse common files.

## Introduction

Many packages have files that are equal between them. This is more common in pipelines,
input configurations, and field definitions.

In order to help developers, there is the ability to define links, so a file that might be reused needs to only be defined once, and can be reused from any other packages.


# Links

Currently, there are some specific places where links can be defined:

- `elasticsearch/ingest_pipeline`
- `data_stream/**/elasticsearch/ingest_pipeline`
- `agent/input`
- `data_stream/**/agent/stream`
- `data_stream/**/fields`

A link consists of a file with a `.link` extension that contains a path, relative to its location, to the file that it will be replaced with. It also consists of a checksum to validate the linked file is up to date with the package expectations.

`data_stream/foo/elasticsearch/ingest_pipeline/default.yml.link`

```
../../../../../testpackage/data_stream/test/elasticsearch/ingest_pipeline/default.yml f7c5f0c03aca8ef68c379a62447bdafbf0dcf32b1ff2de143fd6878ee01a91ad
```

This will use the contents of the linked file during validation, tests, and building of the package, so functionally nothing changes from the package point of view.

## The `_dev/shared` folder

As a convenience, shared files can be placed under `_dev/shared` if they are going to be
reused from several places. They can even be added outside of any package, in any place in the repository.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module github.com/elastic/elastic-package

go 1.24.2

replace github.com/elastic/package-spec/v3 => github.com/elastic/package-spec/v3 v3.0.0-20250409140721-851b65d4339d

require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/Masterminds/semver/v3 v3.3.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ github.com/elastic/gojsonschema v1.2.1 h1:cUMbgsz0wyEB4x7xf3zUEvUVDl6WCz2RKcQPul
github.com/elastic/gojsonschema v1.2.1/go.mod h1:biw5eBS2Z4T02wjATMRSfecfjCmwaDPvuaqf844gLrg=
github.com/elastic/kbncontent v0.1.4 h1:GoUkJkqkn2H6iJTnOHcxEqYVVYyjvcebLQVaSR1aSvU=
github.com/elastic/kbncontent v0.1.4/go.mod h1:kOPREITK9gSJsiw/WKe7QWSO+PRiZMyEFQCw+CMLAHI=
github.com/elastic/package-spec/v3 v3.3.5 h1:D0AXRiTNcF8Ue8gLIafF/BLOk7V2yqSFVUy/p0fwArM=
github.com/elastic/package-spec/v3 v3.3.5/go.mod h1:+q7JpjqBFnNVMmh9VAVfZdOxQ3EmdCD+KM8Cg6VhKgg=
github.com/elastic/package-spec/v3 v3.0.0-20250409140721-851b65d4339d h1:jg8qN/0ZAxbo65coqJUFx01OC2PMkWc+6kaf9labTkc=
github.com/elastic/package-spec/v3 v3.0.0-20250409140721-851b65d4339d/go.mod h1:+q7JpjqBFnNVMmh9VAVfZdOxQ3EmdCD+KM8Cg6VhKgg=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
Expand Down
9 changes: 9 additions & 0 deletions internal/builder/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ func BuildPackage(options BuildOptions) (string, error) {
return "", fmt.Errorf("adding dynamic mappings: %w", err)
}

logger.Debug("Include linked files")
links, err := files.IncludeLinkedFiles(options.PackageRoot, destinationDir)
if err != nil {
return "", fmt.Errorf("including linked files failed: %w", err)
}
for _, l := range links {
logger.Debugf("Linked file included (path: %s)", l.TargetFilePath(destinationDir))
}

if options.CreateZip {
return buildZippedPackage(options, destinationDir)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/cobraext/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ const (
GenerateTestResultFlagName = "generate"
GenerateTestResultFlagDescription = "generate test result file"

PackagesFlagName = "packages"
PackagesFlagDescription = "whether to return packages names or complete paths for the linked files found"

ProfileFlagName = "profile"
ProfileFlagDescription = "select a profile to use for the stack configuration. Can also be set with %s"

Expand Down
19 changes: 12 additions & 7 deletions internal/elasticsearch/ingest/datastream.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ import (
"gopkg.in/yaml.v3"

"github.com/elastic/elastic-package/internal/elasticsearch"
"github.com/elastic/elastic-package/internal/files"
"github.com/elastic/elastic-package/internal/packages"
)

var (
ingestPipelineTag = regexp.MustCompile(`{{\s*IngestPipeline.+}}`)
defaultPipelineJSON = "default.json"
defaultPipelineYML = "default.yml"
ingestPipelineTag = regexp.MustCompile(`{{\s*IngestPipeline.+}}`)
defaultPipelineJSON = "default.json"
defaultPipelineJSONLink = "default.json"
defaultPipelineYML = "default.yml.link"
defaultPipelineYMLLink = "default.yml.link"
)

type Rule struct {
Expand Down Expand Up @@ -71,17 +74,18 @@ func loadIngestPipelineFiles(dataStreamPath string, nonce int64) ([]Pipeline, er
elasticsearchPath := filepath.Join(dataStreamPath, "elasticsearch", "ingest_pipeline")

var pipelineFiles []string
for _, pattern := range []string{"*.json", "*.yml"} {
for _, pattern := range []string{"*.json", "*.yml", "*.link"} {
files, err := filepath.Glob(filepath.Join(elasticsearchPath, pattern))
if err != nil {
return nil, fmt.Errorf("listing '%s' in '%s': %w", pattern, elasticsearchPath, err)
}
pipelineFiles = append(pipelineFiles, files...)
}

linksFS := files.NewLinksFS(elasticsearchPath)
var pipelines []Pipeline
for _, path := range pipelineFiles {
c, err := os.ReadFile(path)
c, err := linksFS.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading ingest pipeline failed (path: %s): %w", path, err)
}
Expand All @@ -108,7 +112,7 @@ func loadIngestPipelineFiles(dataStreamPath string, nonce int64) ([]Pipeline, er
pipelines = append(pipelines, Pipeline{
Path: path,
Name: getPipelineNameWithNonce(name[:strings.Index(name, ".")], nonce),
Format: filepath.Ext(path)[1:],
Format: filepath.Ext(strings.TrimSuffix(path, ".link"))[1:],
Content: cWithRerouteProcessors,
ContentOriginal: c,
})
Expand All @@ -119,7 +123,8 @@ func loadIngestPipelineFiles(dataStreamPath string, nonce int64) ([]Pipeline, er
func addRerouteProcessors(pipeline []byte, dataStreamPath, path string) ([]byte, error) {
// Only attach routing_rules.yml reroute processors after the default pipeline
filename := filepath.Base(path)
if filename != defaultPipelineJSON && filename != defaultPipelineYML {
if filename != defaultPipelineJSON && filename != defaultPipelineYML &&
filename != defaultPipelineJSONLink && filename != defaultPipelineYMLLink {
return pipeline, nil
}

Expand Down
2 changes: 1 addition & 1 deletion internal/files/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

var (
defaultFoldersToSkip = []string{"_dev", "build", ".git"}
defaultFileGlobsToSkip = []string{".DS_Store", ".*.swp"}
defaultFileGlobsToSkip = []string{".DS_Store", ".*.swp", "*.link"}
)

// CopyAll method copies files from the source to the destination skipping empty directories.
Expand Down
Loading