internal/testrunner/runners/policy/policy.go (195 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 policy
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/pmezard/go-difflib/difflib"
"gopkg.in/yaml.v3"
"github.com/elastic/elastic-package/internal/common"
"github.com/elastic/elastic-package/internal/kibana"
)
func dumpExpectedAgentPolicy(ctx context.Context, kibanaClient *kibana.Client, testPath string, policyID string) error {
policy, err := kibanaClient.DownloadPolicy(ctx, policyID)
if err != nil {
return fmt.Errorf("failed to download policy %q: %w", policyID, err)
}
d, err := cleanPolicy(policy, policyEntryFilters)
if err != nil {
return fmt.Errorf("failed to prepare policy to store: %w", err)
}
err = os.WriteFile(expectedPathFor(testPath), d, 0644)
if err != nil {
return fmt.Errorf("failed to write policy: %w", err)
}
return nil
}
func assertExpectedAgentPolicy(ctx context.Context, kibanaClient *kibana.Client, testPath string, policyID string) error {
policy, err := kibanaClient.DownloadPolicy(ctx, policyID)
if err != nil {
return fmt.Errorf("failed to download policy %q: %w", policyID, err)
}
expectedPolicy, err := os.ReadFile(expectedPathFor(testPath))
if err != nil {
return fmt.Errorf("failed to read expected policy: %w", err)
}
diff, err := comparePolicies(expectedPolicy, policy)
if err != nil {
return fmt.Errorf("failed to compare policies: %w", err)
}
if len(diff) > 0 {
return fmt.Errorf("unexpected content in policy: %s", diff)
}
return nil
}
func comparePolicies(expected, found []byte) (string, error) {
want, err := cleanPolicy(expected, policyEntryFilters)
if err != nil {
return "", fmt.Errorf("failed to prepare expected policy: %w", err)
}
got, err := cleanPolicy(found, policyEntryFilters)
if err != nil {
return "", fmt.Errorf("failed to prepare found policy: %w", err)
}
if bytes.Equal(want, got) {
return "", nil
}
var diff bytes.Buffer
err = difflib.WriteUnifiedDiff(&diff, difflib.UnifiedDiff{
A: difflib.SplitLines(string(want)),
B: difflib.SplitLines(string(got)),
FromFile: "want",
ToFile: "got",
Context: 1,
})
if err != nil {
return "", fmt.Errorf("failed to compare policies: %w", err)
}
return diff.String(), nil
}
func expectedPathFor(testPath string) string {
ext := filepath.Ext(testPath)
return strings.TrimSuffix(testPath, ext) + ".expected"
}
type policyEntryFilter struct {
name string
elementsEntries []policyEntryFilter
memberReplace *policyEntryReplace
onlyIfEmpty bool
}
type policyEntryReplace struct {
regexp *regexp.Regexp
replace string
}
// policyEntryFilter includes a list of filters to do to the policy. These filters
// are used to remove or control fields whose content is not relevant for the package
// test.
var policyEntryFilters = []policyEntryFilter{
// IDs are not relevant.
{name: "id"},
{name: "inputs", elementsEntries: []policyEntryFilter{
{name: "id"},
{name: "package_policy_id"},
{name: "streams", elementsEntries: []policyEntryFilter{
{name: "id"},
}},
}},
{name: "secret_references", elementsEntries: []policyEntryFilter{
{name: "id"},
}},
// Avoid having to regenerate files every time the package version changes.
{name: "inputs", elementsEntries: []policyEntryFilter{
{name: "meta.package.version"},
}},
// Revision is not relevant, it is usually the same.
{name: "revision"},
{name: "inputs", elementsEntries: []policyEntryFilter{
{name: "revision"},
}},
// Outputs, agent and fleet can depend on the deployment.
{name: "agent"},
{name: "fleet"},
{name: "outputs"},
// Signatures that change from installation to installation.
{name: "agent.protection.uninstall_token_hash"},
{name: "agent.protection.signing_key"},
{name: "signed"},
// We want to check permissions, but one is stored under a random UUID, replace it.
{name: "output_permissions.default", memberReplace: &policyEntryReplace{
// Match things that look like UUIDs.
regexp: regexp.MustCompile(`^[a-z0-9]{4,}(-[a-z0-9]{4,})+$`),
replace: "uuid-for-permissions-on-related-indices",
}},
// Namespaces may not be present in older versions of the stack.
{name: "namespaces", onlyIfEmpty: true},
}
// cleanPolicy prepares a policy YAML as returned by the download API to be compared with other
// policies. This preparation is based on removing contents that are generated, or replace them
// by controlled values.
func cleanPolicy(policy []byte, entriesToClean []policyEntryFilter) ([]byte, error) {
var policyMap common.MapStr
err := yaml.Unmarshal(policy, &policyMap)
if err != nil {
return nil, fmt.Errorf("failed to decode policy: %w", err)
}
policyMap, err = cleanPolicyMap(policyMap, entriesToClean)
if err != nil {
return nil, err
}
return yaml.Marshal(policyMap)
}
func cleanPolicyMap(policyMap common.MapStr, entries []policyEntryFilter) (common.MapStr, error) {
for _, entry := range entries {
v, err := policyMap.GetValue(entry.name)
if errors.Is(err, common.ErrKeyNotFound) {
continue
}
if err != nil {
return nil, err
}
switch {
case len(entry.elementsEntries) > 0:
list, err := common.ToMapStrSlice(v)
if err != nil {
return nil, err
}
clean := make([]any, len(list))
for i := range list {
c, err := cleanPolicyMap(list[i], entry.elementsEntries)
if err != nil {
return nil, err
}
clean[i] = c
}
policyMap.Delete(entry.name)
_, err = policyMap.Put(entry.name, clean)
if err != nil {
return nil, err
}
case entry.memberReplace != nil:
m, ok := v.(common.MapStr)
if !ok {
return nil, fmt.Errorf("expected map, found %T", v)
}
for k, e := range m {
if entry.memberReplace.regexp.MatchString(k) {
delete(m, k)
m[entry.memberReplace.replace] = e
}
}
default:
if entry.onlyIfEmpty && !isEmpty(v) {
continue
}
err := policyMap.Delete(entry.name)
if errors.Is(err, common.ErrKeyNotFound) {
continue
}
if err != nil {
return nil, err
}
}
}
return policyMap, nil
}
func isEmpty(v any) bool {
switch v := v.(type) {
case nil:
return true
case []any:
return len(v) == 0
case map[string]any:
return len(v) == 0
}
return false
}