Skip to content
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

feat: yaml map injection #568

Merged
merged 9 commits into from
Sep 3, 2024
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ require (
github.com/sergi/go-diff v1.3.1
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0
k8s.io/cli-runtime v0.31.0
k8s.io/client-go v0.31.0
k8s.io/kubectl v0.31.0
sigs.k8s.io/e2e-framework v0.4.0
sigs.k8s.io/kustomize/kyaml v0.17.1
sigs.k8s.io/yaml v1.4.0
)

Expand Down Expand Up @@ -114,6 +116,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.20.2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
Expand Down Expand Up @@ -170,6 +173,5 @@ require (
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/kustomize/api v0.17.2 // indirect
sigs.k8s.io/kustomize/kustomize/v5 v5.4.2 // indirect
sigs.k8s.io/kustomize/kyaml v0.17.1 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
)
109 changes: 109 additions & 0 deletions src/internal/inject/inject.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package inject

import (
"fmt"
"strings"

"sigs.k8s.io/kustomize/kyaml/yaml"
)

// InjectMapData injects the subset map into a target map at the path
func InjectMapData(target, subset map[string]interface{}, path string) (map[string]interface{}, error) {
pathSlice := splitPath(path)
// Convert the target and subset maps to yaml nodes
targetNode, err := yaml.FromMap(target)
if err != nil {
return nil, fmt.Errorf("failed to create target node from map: %v", err)
}

subsetNode, err := yaml.FromMap(subset)
if err != nil {
return nil, fmt.Errorf("failed to create subset node from map: %v", err)
}

// Get the subset node from target
targetSubsetNode, err := targetNode.Pipe(yaml.LookupCreate(yaml.MappingNode, pathSlice...))
if err != nil {
return nil, fmt.Errorf("error identifying subset node: %v", err)
}

// Alternate merge based on custom merge function
err = mergeYAMLNodes(targetSubsetNode, subsetNode)
if err != nil {
return nil, fmt.Errorf("error merging subset into target: %v", err)
}

if pathSlice[0] == "" {
targetNode = targetSubsetNode
} else {
if err := targetNode.PipeE(yaml.Lookup(pathSlice[:len(pathSlice)-1]...), yaml.SetField(pathSlice[len(pathSlice)-1], targetSubsetNode)); err != nil {
return nil, fmt.Errorf("error setting merged node back into target: %v", err)
}
}

// Write targetNode into map[string]interface{}
targetMap, err := targetNode.Map()
if err != nil {
return nil, fmt.Errorf("failed to convert target node to map: %v", err)
}

return targetMap, nil
}

// splitPath splits a path by '.' into a path array
// TODO: This could be a more complicated path: is there a lib function to do this and possibly handle things like [] or escaped '.'
func splitPath(path string) []string {
// strip leading '.' if present
if len(path) > 0 && path[0] == '.' {
path = path[1:]
}
return strings.Split(path, ".")
}

// mergeYAMLNodes recursively merges the subset node into the target node
// Note - this is an alternate to kyaml merge2 function which doesn't append lists, it replaces them
func mergeYAMLNodes(target, subset *yaml.RNode) error {
switch subset.YNode().Kind {
case yaml.MappingNode:
subsetFields, err := subset.Fields()
if err != nil {
return err
}
for _, field := range subsetFields {
subsetFieldNode, err := subset.Pipe(yaml.Lookup(field))
if err != nil {
return err
}
targetFieldNode, err := target.Pipe(yaml.Lookup(field))
if err != nil {
return err
}

if targetFieldNode == nil {
// Field doesn't exist in target, so set it
err = target.PipeE(yaml.SetField(field, subsetFieldNode))
if err != nil {
return err
}
} else {
// Field exists, merge it recursively
err = mergeYAMLNodes(targetFieldNode, subsetFieldNode)
if err != nil {
return err
}
}
}
case yaml.SequenceNode:
subsetItems, err := subset.Elements()
if err != nil {
return err
}
for _, item := range subsetItems {
target.YNode().Content = append(target.YNode().Content, item.YNode())
}
default:
// Simple replacement for scalar and other nodes
target.YNode().Value = subset.YNode().Value
}
return nil
}
120 changes: 120 additions & 0 deletions src/internal/inject/inject_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package inject_test

import (
"testing"

"github.com/defenseunicorns/lula/src/internal/inject"
"github.com/stretchr/testify/assert"
goyaml "gopkg.in/yaml.v3"
)

// TestInjectMapData tests the InjectMapData function
func TestInjectMapData(t *testing.T) {
tests := []struct {
name string
path string
target []byte
subset []byte
expected []byte
}{
{
name: "test-merge-subset-with-list",
path: "metadata",
target: []byte(`
name: target
metadata:
some-data: target-data
only-target-field: data
some-submap:
only-target-field: target-data
sub-data: this-should-be-overwritten
some-list:
- item1
`),
subset: []byte(`
some-data: subset-data
some-submap:
sub-data: my-submap-data
more-data: some-more-data
some-list:
- item2
- item3
`),
expected: []byte(`
name: target
metadata:
some-data: subset-data
only-target-field: data
some-submap:
only-target-field: target-data
sub-data: my-submap-data
more-data: some-more-data
some-list:
- item1
- item2
- item3
`),
},
{
name: "test-merge-at-root",
path: "",
target: []byte(`
name: target
some-information: some-data
some-map:
test-key: test-value
`),
subset: []byte(`
more-information: more-data
some-map:
test-key: subset-value
`),
expected: []byte(`
name: target
more-information: more-data
some-information: some-data
some-map:
test-key: subset-value
`),
},
{
name: "test-merge-at-non-existant-path",
path: "metadata.test",
target: []byte(`
name: target
some-information: some-data
`),
subset: []byte(`
name: some-name
more-metdata: here
`),
expected: []byte(`
name: target
some-information: some-data
metadata:
test:
name: some-name
more-metdata: here
`),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := inject.InjectMapData(convertBytesToMap(t, tt.target), convertBytesToMap(t, tt.subset), tt.path)
if err != nil {
t.Errorf("InjectMapData() error = %v", err)
}
assert.Equal(t, result, convertBytesToMap(t, tt.expected), "The maps should be equal")
})
}
}

// convertBytesToMap converts a byte slice to a map[string]interface{}
func convertBytesToMap(t *testing.T, data []byte) map[string]interface{} {
var dataMap map[string]interface{}
if err := goyaml.Unmarshal(data, &dataMap); err != nil {
t.Errorf("yaml.Unmarshal failed: %v", err)
}
return dataMap
}
61 changes: 61 additions & 0 deletions src/pkg/common/oscal/complete-schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/defenseunicorns/go-oscal/src/pkg/files"
oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2"
"github.com/defenseunicorns/lula/src/internal/inject"
"github.com/defenseunicorns/lula/src/pkg/message"
yamlV3 "gopkg.in/yaml.v3"
"sigs.k8s.io/yaml"
Expand Down Expand Up @@ -182,3 +183,63 @@ func GetOscalModel(model *oscalTypes_1_1_2.OscalModels) (modelType string, err e
}

}

// InjectIntoOSCALModel takes a model target and a map[string]interface{} of values to inject into the model
func InjectIntoOSCALModel(target *oscalTypes_1_1_2.OscalModels, values map[string]interface{}, path string) (*oscalTypes_1_1_2.OscalModels, error) {
// If the target is nil, return an error
if target == nil {
return nil, fmt.Errorf("target model is nil")
}

// Convert target to a map
modelMap, err := convertOscalModelToMap(*target)
if err != nil {
return nil, err
}

// Inject the values into the map at the path
newModelMap, err := inject.InjectMapData(modelMap, values, path)
if err != nil {
return nil, err
}

// Convert the new model map back to an OSCAL model
newModel, err := convertMapToOscalModel(newModelMap)
if err != nil {
return nil, err
}

return newModel, nil
}

// convertOscalModelToMap converts an OSCAL model to a map[string]interface{}
func convertOscalModelToMap(model oscalTypes_1_1_2.OscalModels) (map[string]interface{}, error) {
var modelMap map[string]interface{}
modelBytes, err := json.Marshal(model)
if err != nil {
return nil, err
}

err = json.Unmarshal(modelBytes, &modelMap)
if err != nil {
return nil, err
}

return modelMap, nil
}

// convertMapToOscalModel converts a map[string]interface{} to an OSCAL model
func convertMapToOscalModel(modelMap map[string]interface{}) (*oscalTypes_1_1_2.OscalModels, error) {
var model oscalTypes_1_1_2.OscalModels
modelBytes, err := json.Marshal(modelMap)
if err != nil {
return nil, err
}

err = json.Unmarshal(modelBytes, &model)
if err != nil {
return nil, err
}

return &model, nil
}
Loading