Skip to content

Add spec for shared folder and link files #888

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 31 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cc4e351
Add spec for shared folder and link files
marc-gr Apr 1, 2025
aaa3efd
Add changelog entry
marc-gr Apr 1, 2025
7605ac8
Fix description
marc-gr Apr 1, 2025
1c5bd73
Move links logic to spec
marc-gr Apr 2, 2025
6c51d57
Take into account root for copy
marc-gr Apr 2, 2025
e2d4cc3
Write to root
marc-gr Apr 2, 2025
45c0583
Add required back where needed and simplfify test
marc-gr Apr 2, 2025
e930d16
Add comments
marc-gr Apr 2, 2025
9cce7ff
Update go mod
marc-gr Apr 2, 2025
e893c88
Add go version 1.24.2
marc-gr Apr 2, 2025
3819365
Revert go version update
marc-gr Apr 4, 2025
3c540ea
Simplify link files validation
marc-gr Apr 4, 2025
6b5c07a
Fix comment
marc-gr Apr 4, 2025
f70449a
Fix included path building
marc-gr Apr 4, 2025
b9ec960
Fix patterns
marc-gr Apr 4, 2025
c7ced63
revert go.dum
marc-gr Apr 4, 2025
9d22235
Unescape unrelated patterns
marc-gr Apr 4, 2025
b214967
Add semantic anyof validation for contents
marc-gr Apr 4, 2025
a130490
Add comment
marc-gr Apr 4, 2025
60c652d
Add toslash to avoid failing on windows
marc-gr Apr 4, 2025
08c5495
Simplify initialization
marc-gr Apr 4, 2025
94ba5df
Add link to test package and reuse shared spec
marc-gr Apr 8, 2025
7f832dc
Merge remote-tracking branch 'upstream/main' into feat/includes
marc-gr Apr 8, 2025
e03cb61
Merge remote-tracking branch 'origin/feat/includes' into feat/includes
marc-gr Apr 8, 2025
9ab873d
Add allowLink option into the spec
marc-gr Apr 8, 2025
7422eec
Export NewLinkedFile
marc-gr Apr 8, 2025
835ce7e
Add fs to block links
marc-gr Apr 9, 2025
829e846
Fix lints
marc-gr Apr 9, 2025
4c330a8
Rename with_links package
marc-gr Apr 9, 2025
4a66c9d
Move changelog
marc-gr Apr 9, 2025
4f01f75
Merge remote-tracking branch 'upstream/main' into feat/includes
marc-gr Apr 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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 semantic

import (
"errors"
"io/fs"
"os"
"path/filepath"

"github.com/elastic/package-spec/v3/code/go/internal/fspath"
"github.com/elastic/package-spec/v3/code/go/pkg/specerrors"
)

type anyOfCondition struct {
path string
anyOfPatterns []string
}

func (a *anyOfCondition) validate(fsys fspath.FS) specerrors.ValidationErrors {
if len(a.anyOfPatterns) == 0 || a.path == "" {
return nil
}

var errs specerrors.ValidationErrors
if err := a.validatePath(fsys, a.path); err != nil {
errs = append(errs, specerrors.NewStructuredErrorf("path %q: %w", a.path, err))
}

dataStreams, err := listDataStreams(fsys)
if err != nil {
return specerrors.ValidationErrors{specerrors.NewStructuredError(err, specerrors.UnassignedCode)}
}

for _, dataStream := range dataStreams {
path := filepath.ToSlash(filepath.Join("data_stream", dataStream, a.path))
err := a.validatePath(fsys, path)
if err != nil {
errs = append(errs, specerrors.NewStructuredErrorf("data stream %q: %w", dataStream, err))
}
}

if len(errs) > 0 {
return errs
}
return nil
}

func (a *anyOfCondition) validatePath(fsys fspath.FS, path string) specerrors.ValidationError {
files, err := fs.ReadDir(fsys, path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return specerrors.NewStructuredError(err, specerrors.UnassignedCode)
}
return nil
}
for _, file := range files {
for _, pattern := range a.anyOfPatterns {
matched, err := filepath.Match(pattern, file.Name())
if err != nil {
return specerrors.NewStructuredError(err, specerrors.UnassignedCode)
}
if matched {
return nil
}
}
}
return specerrors.NewStructuredErrorf("no file matching any of the patterns %v found in %s", a.anyOfPatterns, path)
}

// ValidateAnyOfRequiredContents validates that at least one file matching
// any of the patterns in the given path exists in the package.
// It checks the following paths:
// - agent/input
// - agent/stream
// - fields
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we remark that this fields folder is just for the ones defined under data_stream ? Is that right?

If so, would it be better to re-phrase that item in the comment as this? WDYT @marc-gr ?

Suggested change
// - fields
// - data_stream/*/fields

IIUC the fields folder in transforms is not taken into account here. If it would be needed those links in transforms, I guess it could be added support in a follow-up. Let's continue with the current links defined in this PR , is that ok @jsoriano?

// - elasticsearch/ingest_pipeline
func ValidateAnyOfRequiredContents(fsys fspath.FS) specerrors.ValidationErrors {
conditions := []anyOfCondition{
{path: "agent/input", anyOfPatterns: []string{"*.yml.hbs", "*.yml.hbs.link"}},
{path: "agent/stream", anyOfPatterns: []string{"*.yml.hbs", "*.yml.hbs.link"}},
{path: "fields", anyOfPatterns: []string{"*.yml", "*.yml.link"}},
{path: "elasticsearch/ingest_pipeline", anyOfPatterns: []string{"*.yml", "*.json", "*.yml.link", "*.json.link"}},
}

var errs specerrors.ValidationErrors
for _, c := range conditions {
cerrs := c.validate(fsys)
if cerrs != nil {
errs = append(errs, cerrs...)
}
}
return errs
}
1 change: 1 addition & 0 deletions code/go/internal/validator/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ func (s Spec) rules(pkgType string, rootSpec spectypes.ItemSpec) validationRules
{fn: semantic.ValidateDimensionsPresent, types: []string{"integration"}, since: semver.MustParse("3.0.1")},
{fn: semantic.ValidateCapabilitiesRequired, since: semver.MustParse("2.10.0")}, // capabilities definition was added in spec version 2.10.0
{fn: semantic.ValidateRequiredVarGroups},
{fn: semantic.ValidateAnyOfRequiredContents},
}

var validationRules validationRules
Expand Down
46 changes: 46 additions & 0 deletions code/go/pkg/linkedfiles/fs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// 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 linkedfiles

import (
"fmt"
"io/fs"
"os"
"path/filepath"
)

var _ fs.FS = (*LinksFS)(nil)

// LinksFS is a filesystem that handles linked files.
// It wraps another filesystem and checks for linked files with the ".link" extension.
// If a linked file is found, it reads the link file to determine the target file
// and its checksum. If the target file is up to date, it returns the target file.
// Otherwise, it returns an error.
type LinksFS struct {
workDir string
inner fs.FS
}

// NewLinksFS creates a new LinksFS.
func NewLinksFS(workDir string, inner fs.FS) *LinksFS {
return &LinksFS{workDir: workDir, inner: inner}
}

// Open opens a file in the filesystem.
func (lfs *LinksFS) Open(name string) (fs.File, error) {
if filepath.Ext(name) != LinkExtension {
return lfs.inner.Open(name)
}
pathName := filepath.Join(lfs.workDir, name)
l, err := newLinkedFile(pathName)
if err != nil {
return nil, err
}
if !l.UpToDate {
return nil, fmt.Errorf("linked file %s is not up to date", name)
}
includedPath := filepath.Join(lfs.workDir, filepath.Dir(name), l.IncludedFilePath)
return os.Open(includedPath)
}
100 changes: 100 additions & 0 deletions code/go/pkg/linkedfiles/linkedfiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// 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 linkedfiles

import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)

// LinkExtension is the file extension for linked files.
const LinkExtension = ".link"

// A Link represents a linked file.
// It contains the path to the link file, the checksum of the linked file,
// the path to the target file, and the checksum of the included file contents.
// It also contains a boolean indicating whether the link is up to date.
type Link struct {
LinkFilePath string
LinkChecksum string

IncludedFilePath string
IncludedFileContentsChecksum string

UpToDate bool
}

func newLinkedFile(linkFilePath string) (Link, error) {
var l Link
firstLine, err := readFirstLine(linkFilePath)
if err != nil {
return Link{}, err
}
l.LinkFilePath = linkFilePath

fields := strings.Fields(firstLine)
l.IncludedFilePath = fields[0]
if len(fields) == 2 {
l.LinkChecksum = fields[1]
}

pathName := filepath.Join(filepath.Dir(linkFilePath), l.IncludedFilePath)
cs, err := getLinkedFileChecksum(pathName)
if err != nil {
return Link{}, fmt.Errorf("could not collect file %v: %w", l.IncludedFilePath, err)
}
if l.LinkChecksum == cs {
l.UpToDate = true
}
l.IncludedFileContentsChecksum = cs

return l, nil
}

func getLinkedFileChecksum(path string) (string, error) {
b, err := os.ReadFile(filepath.FromSlash(path))
if err != nil {
return "", err
}
cs, err := checksum(b)
if err != nil {
return "", err
}
return cs, nil
}

func readFirstLine(filePath string) (string, error) {
file, err := os.Open(filepath.FromSlash(filePath))
if err != nil {
return "", err
}
defer file.Close()

scanner := bufio.NewScanner(file)
if scanner.Scan() {
return scanner.Text(), nil
}

if err := scanner.Err(); err != nil {
return "", err
}

return "", fmt.Errorf("file is empty or first line is missing")
}

func checksum(b []byte) (string, error) {
hash := sha256.New()
if _, err := io.Copy(hash, bytes.NewReader(b)); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
8 changes: 0 additions & 8 deletions code/go/pkg/specerrors/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,6 @@ import (
"github.com/stretchr/testify/require"
)

func createValidationErrors(messages []string) ValidationErrors {
var allErrors ValidationErrors
for _, m := range messages {
allErrors = append(allErrors, NewStructuredErrorf(m))
}
return allErrors
}

func createValidationError(message, code string) ValidationError {
return NewStructuredError(errors.New(message), code)
}
Expand Down
5 changes: 4 additions & 1 deletion code/go/pkg/validator/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import (

"github.com/elastic/package-spec/v3/code/go/internal/packages"
"github.com/elastic/package-spec/v3/code/go/internal/validator"
"github.com/elastic/package-spec/v3/code/go/pkg/linkedfiles"
)

// ValidateFromPath validates a package located at the given path against the
// appropriate specification and returns any errors.
func ValidateFromPath(packageRootPath string) error {
return ValidateFromFS(packageRootPath, os.DirFS(packageRootPath))
// We wrap the fs.FS with a linkedfiles.LinksFS to handle linked files.
linksFS := linkedfiles.NewLinksFS(packageRootPath, os.DirFS(packageRootPath))
return ValidateFromFS(packageRootPath, linksFS)
}

// ValidateFromZip validates a package on its zip format.
Expand Down
36 changes: 36 additions & 0 deletions code/go/pkg/validator/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ func TestValidateFile(t *testing.T) {
`required var "password" in optional group is not defined`,
},
},
"with_links": {},
}

for pkgName, test := range tests {
Expand Down Expand Up @@ -813,6 +814,41 @@ func TestValidateForbiddenDataStreamName(t *testing.T) {
}
}

func TestValidateAnyOfContents(t *testing.T) {
tests := map[string][]string{
"missing_required_files": {
"item [.keep] is not allowed in folder [../../../../test/packages/missing_required_files/agent/input]",
"item [.keep] is not allowed in folder [../../../../test/packages/missing_required_files/data_stream/foo/agent/stream]",
"item [.keep] is not allowed in folder [../../../../test/packages/missing_required_files/data_stream/foo/elasticsearch/ingest_pipeline]",
"item [.keep] is not allowed in folder [../../../../test/packages/missing_required_files/data_stream/foo/fields]",
`path "agent/input": no file matching any of the patterns [*.yml.hbs *.yml.hbs.link] found in agent/input`,
`data stream "foo": no file matching any of the patterns [*.yml.hbs *.yml.hbs.link] found in data_stream/foo/agent/stream`,
`data stream "foo": no file matching any of the patterns [*.yml *.yml.link] found in data_stream/foo/fields`,
`path "elasticsearch/ingest_pipeline": no file matching any of the patterns [*.yml *.json *.yml.link *.json.link] found in elasticsearch/ingest_pipeline`,
`data stream "foo": no file matching any of the patterns [*.yml *.json *.yml.link *.json.link] found in data_stream/foo/elasticsearch/ingest_pipeline`,
},
}

for pkgName, expectedErrorMessages := range tests {
t.Run(pkgName, func(t *testing.T) {
err := ValidateFromPath(path.Join("..", "..", "..", "..", "test", "packages", pkgName))
if len(expectedErrorMessages) == 0 {
assert.NoError(t, err)
return
}
assert.Error(t, err)

errs, ok := err.(specerrors.ValidationErrors)
require.True(t, ok)
assert.Len(t, errs, len(expectedErrorMessages))

for _, foundError := range errs {
require.Contains(t, expectedErrorMessages, foundError.Error())
}
})
}
}

func requireErrorMessage(t *testing.T, pkgName string, invalidItemsPerFolder map[string][]string, expectedErrorMessage string) {
pkgRootPath := filepath.Join("..", "..", "..", "..", "test", "packages", pkgName)

Expand Down
6 changes: 6 additions & 0 deletions spec/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
- description: Allow security_rule assets in content package.
type: enhancement
link: https://github.com/elastic/package-spec/pull/885
- description: Add support for _dev/shared folder.
type: enhancement
link: https://github.com/elastic/package-spec/pull/888
- description: Add support for *.link files in agent, pipelines, and fields folders.
type: enhancement
link: https://github.com/elastic/package-spec/pull/888
Comment on lines +18 to +23
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved to a new version block for 3.3.6-next.

- version: 3.3.4
changes:
- description: Remove slo assets when spec versions are less than 3.4.0.
Expand Down
2 changes: 2 additions & 0 deletions spec/input/_dev/shared/spec.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
spec:
additionalContents: true
4 changes: 4 additions & 0 deletions spec/input/_dev/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ spec:
name: test
required: false
$ref: "./test/spec.yml"
- description: Folder containing shared files.
type: folder
name: shared
$ref: "./shared/spec.yml"
2 changes: 2 additions & 0 deletions spec/integration/_dev/shared/spec.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
spec:
additionalContents: true
5 changes: 5 additions & 0 deletions spec/integration/_dev/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ spec:
name: test
required: false
$ref: "./test/spec.yml"
- description: Folder containing shared files.
type: folder
name: shared
required: false
$ref: "./shared/spec.yml"
6 changes: 5 additions & 1 deletion spec/integration/agent/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ spec:
type: file
sizeLimit: 2MB
pattern: '^.+.yml.hbs$'
required: true
required: false
- description: Link to a config template file for inputs defined in the policy_templates section of the top level manifest
type: file
pattern: '^.+\.yml\.hbs\.link$'
required: false
8 changes: 6 additions & 2 deletions spec/integration/data_stream/agent/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ spec:
required: true
additionalContents: false
contents:
- description: Folder containing agent stream definitions
- description: Config template file for inputs defined in the policy_templates section of the top level manifest
type: file
sizeLimit: 2MB
pattern: '^.+.yml.hbs$'
required: true
required: false
- description: Link to a config template file for inputs defined in the policy_templates section of the top level manifest
type: file
pattern: '^.+\.yml\.hbs\.link$'
required: false
Loading