Skip to content

Commit 4d739c2

Browse files
committed
Add semantic validator for policy template datastream categories
1 parent 5ed1f43 commit 4d739c2

16 files changed

Lines changed: 474 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
package semantic
6+
7+
import (
8+
"io/fs"
9+
"path"
10+
"sort"
11+
12+
"gopkg.in/yaml.v3"
13+
14+
"github.com/elastic/package-spec/v3/code/go/internal/fspath"
15+
"github.com/elastic/package-spec/v3/code/go/pkg/specerrors"
16+
)
17+
18+
type policyTemplateWithCategories struct {
19+
Name string `yaml:"name"`
20+
DataStreams []string `yaml:"data_streams"`
21+
Categories []string `yaml:"categories"`
22+
}
23+
24+
type packageManifestWithCategories struct {
25+
Type string `yaml:"type"`
26+
PolicyTemplates []policyTemplateWithCategories `yaml:"policy_templates"`
27+
}
28+
29+
type dataStreamManifestWithCategories struct {
30+
Categories []string `yaml:"categories"`
31+
}
32+
33+
// ValidatePolicyTemplateDatastreamCategories validates that when a policy template
34+
// entry in the package manifest.yml defines categories, those categories match the
35+
// categories defined in the manifest.yml of each referenced data stream.
36+
// Data stream manifests without a categories field are skipped.
37+
func ValidatePolicyTemplateDatastreamCategories(fsys fspath.FS) specerrors.ValidationErrors {
38+
var errs specerrors.ValidationErrors
39+
40+
manifestPath := "manifest.yml"
41+
data, err := fs.ReadFile(fsys, manifestPath)
42+
if err != nil {
43+
return specerrors.ValidationErrors{
44+
specerrors.NewStructuredErrorf("file \"%s\" is invalid: %w", fsys.Path(manifestPath), errFailedToReadManifest)}
45+
}
46+
47+
var manifest packageManifestWithCategories
48+
if err := yaml.Unmarshal(data, &manifest); err != nil {
49+
return specerrors.ValidationErrors{
50+
specerrors.NewStructuredErrorf("file \"%s\" is invalid: %w", fsys.Path(manifestPath), errFailedToParseManifest)}
51+
}
52+
53+
// only validate integration type packages
54+
if manifest.Type != packageTypeIntegration {
55+
return nil
56+
}
57+
58+
dsCategories, err := readDataStreamManifestCategories(fsys)
59+
if err != nil {
60+
return specerrors.ValidationErrors{
61+
specerrors.NewStructuredErrorf("file \"%s\" is invalid: %w", fsys.Path(manifestPath), err)}
62+
}
63+
64+
for _, pt := range manifest.PolicyTemplates {
65+
// skip policy templates that don't define both categories and data_streams
66+
if len(pt.Categories) == 0 || len(pt.DataStreams) == 0 {
67+
continue
68+
}
69+
70+
for _, dsName := range pt.DataStreams {
71+
dsCats, hasCats := dsCategories[dsName]
72+
if !hasCats {
73+
// data stream manifest has no categories field — nothing to validate
74+
continue
75+
}
76+
77+
if !categoriesEqual(pt.Categories, dsCats) {
78+
dsManifestPath := path.Join("data_stream", dsName, "manifest.yml")
79+
errs = append(errs, specerrors.NewStructuredErrorf(
80+
"file \"%s\" is invalid: policy template \"%s\" categories %v do not match data stream \"%s\" manifest categories %v (defined in \"%s\")",
81+
fsys.Path(manifestPath),
82+
pt.Name,
83+
pt.Categories,
84+
dsName,
85+
dsCats,
86+
fsys.Path(dsManifestPath),
87+
))
88+
}
89+
}
90+
}
91+
92+
return errs
93+
}
94+
95+
// readDataStreamManifestCategories reads the categories field from every
96+
// data_stream/*/manifest.yml and returns a map of data stream name to its categories.
97+
// Data streams without a categories field are omitted from the map.
98+
func readDataStreamManifestCategories(fsys fspath.FS) (map[string][]string, error) {
99+
result := make(map[string][]string)
100+
101+
manifests, err := fs.Glob(fsys, "data_stream/*/manifest.yml")
102+
if err != nil {
103+
return nil, err
104+
}
105+
106+
for _, file := range manifests {
107+
data, err := fs.ReadFile(fsys, file)
108+
if err != nil {
109+
return nil, err
110+
}
111+
112+
var m dataStreamManifestWithCategories
113+
if err := yaml.Unmarshal(data, &m); err != nil {
114+
return nil, err
115+
}
116+
117+
if len(m.Categories) == 0 {
118+
continue
119+
}
120+
121+
// path is data_stream/{name}/manifest.yml — extract the name component
122+
dsName := path.Base(path.Dir(file))
123+
result[dsName] = m.Categories
124+
}
125+
126+
return result, nil
127+
}
128+
129+
// categoriesEqual returns true if both slices contain exactly the same set of
130+
// categories, regardless of order.
131+
func categoriesEqual(a, b []string) bool {
132+
if len(a) != len(b) {
133+
return false
134+
}
135+
aCopy := make([]string, len(a))
136+
bCopy := make([]string, len(b))
137+
copy(aCopy, a)
138+
copy(bCopy, b)
139+
sort.Strings(aCopy)
140+
sort.Strings(bCopy)
141+
for i := range aCopy {
142+
if aCopy[i] != bCopy[i] {
143+
return false
144+
}
145+
}
146+
return true
147+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
package semantic
6+
7+
import (
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/elastic/package-spec/v3/code/go/internal/fspath"
16+
)
17+
18+
func writeManifest(t *testing.T, dir, content string) {
19+
t.Helper()
20+
err := os.WriteFile(filepath.Join(dir, "manifest.yml"), []byte(content), 0o644)
21+
require.NoError(t, err)
22+
}
23+
24+
func writeDataStreamManifest(t *testing.T, dir, dsName, content string) {
25+
t.Helper()
26+
dsDir := filepath.Join(dir, "data_stream", dsName)
27+
err := os.MkdirAll(dsDir, 0o755)
28+
require.NoError(t, err)
29+
err = os.WriteFile(filepath.Join(dsDir, "manifest.yml"), []byte(content), 0o644)
30+
require.NoError(t, err)
31+
}
32+
33+
func TestValidatePolicyTemplateDatastreamCategories_Match(t *testing.T) {
34+
d := t.TempDir()
35+
36+
writeManifest(t, d, `
37+
type: integration
38+
policy_templates:
39+
- name: mytemplate
40+
data_streams:
41+
- mylogs
42+
categories:
43+
- observability
44+
- network
45+
`)
46+
writeDataStreamManifest(t, d, "mylogs", `
47+
title: My Logs
48+
categories:
49+
- network
50+
- observability
51+
`)
52+
53+
errs := ValidatePolicyTemplateDatastreamCategories(fspath.DirFS(d))
54+
assert.Empty(t, errs)
55+
}
56+
57+
func TestValidatePolicyTemplateDatastreamCategories_Mismatch(t *testing.T) {
58+
d := t.TempDir()
59+
60+
writeManifest(t, d, `
61+
type: integration
62+
policy_templates:
63+
- name: mytemplate
64+
data_streams:
65+
- mylogs
66+
categories:
67+
- observability
68+
`)
69+
writeDataStreamManifest(t, d, "mylogs", `
70+
title: My Logs
71+
categories:
72+
- observability
73+
- security
74+
`)
75+
76+
errs := ValidatePolicyTemplateDatastreamCategories(fspath.DirFS(d))
77+
assert.Len(t, errs, 1)
78+
assert.Contains(t, errs[0].Error(), `policy template "mytemplate"`)
79+
assert.Contains(t, errs[0].Error(), `"mylogs"`)
80+
}
81+
82+
func TestValidatePolicyTemplateDatastreamCategories_NoDataStreamCategories(t *testing.T) {
83+
// Data stream manifest without categories field → should pass (nothing to validate)
84+
d := t.TempDir()
85+
86+
writeManifest(t, d, `
87+
type: integration
88+
policy_templates:
89+
- name: mytemplate
90+
data_streams:
91+
- mylogs
92+
categories:
93+
- observability
94+
`)
95+
writeDataStreamManifest(t, d, "mylogs", `
96+
title: My Logs
97+
type: logs
98+
streams:
99+
- input: logfile
100+
`)
101+
102+
errs := ValidatePolicyTemplateDatastreamCategories(fspath.DirFS(d))
103+
assert.Empty(t, errs)
104+
}
105+
106+
func TestValidatePolicyTemplateDatastreamCategories_NoPolicyTemplateCategories(t *testing.T) {
107+
// Policy template without categories field → should pass (nothing to validate)
108+
d := t.TempDir()
109+
110+
writeManifest(t, d, `
111+
type: integration
112+
policy_templates:
113+
- name: mytemplate
114+
data_streams:
115+
- mylogs
116+
`)
117+
writeDataStreamManifest(t, d, "mylogs", `
118+
title: My Logs
119+
categories:
120+
- observability
121+
`)
122+
123+
errs := ValidatePolicyTemplateDatastreamCategories(fspath.DirFS(d))
124+
assert.Empty(t, errs)
125+
}
126+
127+
func TestValidatePolicyTemplateDatastreamCategories_MultipleDataStreams(t *testing.T) {
128+
// Multiple data streams — one matches, one doesn't
129+
d := t.TempDir()
130+
131+
writeManifest(t, d, `
132+
type: integration
133+
policy_templates:
134+
- name: mytemplate
135+
data_streams:
136+
- logs_ok
137+
- logs_bad
138+
categories:
139+
- observability
140+
`)
141+
writeDataStreamManifest(t, d, "logs_ok", `
142+
title: OK Logs
143+
categories:
144+
- observability
145+
`)
146+
writeDataStreamManifest(t, d, "logs_bad", `
147+
title: Bad Logs
148+
categories:
149+
- security
150+
`)
151+
152+
errs := ValidatePolicyTemplateDatastreamCategories(fspath.DirFS(d))
153+
assert.Len(t, errs, 1)
154+
assert.Contains(t, errs[0].Error(), `"logs_bad"`)
155+
}
156+
157+
func TestValidatePolicyTemplateDatastreamCategories_MultiplePolicyTemplates(t *testing.T) {
158+
// Two policy templates — each has one mismatch
159+
d := t.TempDir()
160+
161+
writeManifest(t, d, `
162+
type: integration
163+
policy_templates:
164+
- name: template_a
165+
data_streams:
166+
- ds_a
167+
categories:
168+
- observability
169+
- name: template_b
170+
data_streams:
171+
- ds_b
172+
categories:
173+
- network
174+
`)
175+
writeDataStreamManifest(t, d, "ds_a", `
176+
title: DS A
177+
categories:
178+
- security
179+
`)
180+
writeDataStreamManifest(t, d, "ds_b", `
181+
title: DS B
182+
categories:
183+
- network
184+
`)
185+
186+
errs := ValidatePolicyTemplateDatastreamCategories(fspath.DirFS(d))
187+
assert.Len(t, errs, 1)
188+
assert.Contains(t, errs[0].Error(), `"template_a"`)
189+
assert.Contains(t, errs[0].Error(), `"ds_a"`)
190+
}
191+
192+
func TestValidatePolicyTemplateDatastreamCategories_NonIntegrationPackage(t *testing.T) {
193+
// Non-integration packages are skipped
194+
d := t.TempDir()
195+
196+
writeManifest(t, d, `
197+
type: input
198+
policy_templates:
199+
- name: mytemplate
200+
data_streams:
201+
- mylogs
202+
categories:
203+
- observability
204+
`)
205+
writeDataStreamManifest(t, d, "mylogs", `
206+
title: My Logs
207+
categories:
208+
- security
209+
`)
210+
211+
errs := ValidatePolicyTemplateDatastreamCategories(fspath.DirFS(d))
212+
assert.Empty(t, errs)
213+
}

‎code/go/internal/validator/spec.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ func (s Spec) rules(pkgType string, rootSpec spectypes.ItemSpec) validationRules
226226
{fn: semantic.ValidateInputDynamicSignalTypes},
227227
{fn: semantic.ValidateMinimumAgentVersion},
228228
{fn: semantic.ValidateIntegrationPolicyTemplates, types: []string{"integration"}},
229+
{fn: semantic.ValidatePolicyTemplateDatastreamCategories, types: []string{"integration"}},
229230
{fn: semantic.ValidatePipelineTags, types: []string{"integration"}, since: semver.MustParse("3.6.0")},
230231
{fn: semantic.ValidateStaticHandlebarsFiles, types: []string{"integration", "input"}},
231232
{fn: semantic.ValidateKibanaTagDuplicates},

‎code/go/pkg/validator/validator_test.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func TestValidateFile(t *testing.T) {
4545
"good_alert_rule_templates": {},
4646
"good_requires": {},
4747
"good_package_reference_policy_template": {},
48+
"good_datastream_categories_match": {},
4849
"deploy_custom_agent": {},
4950
"deploy_custom_agent_multi_services": {},
5051
"deploy_docker": {},
@@ -141,6 +142,12 @@ func TestValidateFile(t *testing.T) {
141142
"field (root): Additional property release is not allowed",
142143
},
143144
},
145+
"bad_datastream_categories_mismatch": {
146+
"manifest.yml",
147+
[]string{
148+
fmt.Sprintf(`policy template "mytemplate" categories [observability] do not match data stream "mylogs" manifest categories [security] (defined in "%sbad_datastream_categories_mismatch/data_stream/mylogs/manifest.yml")`, osTestBasePath),
149+
},
150+
},
144151
"bad_custom_ilm_policy": {
145152
"data_stream/test/manifest.yml",
146153
[]string{
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- version: 0.0.1
2+
changes:
3+
- description: Initial release
4+
type: enhancement
5+
link: https://github.com/elastic/package-spec/pull/1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{fields "stream"}}

0 commit comments

Comments
 (0)