Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,21 @@ diki report generate diff \
difference1.json difference2.json
```

### Config Merge

Diki can merge two configuration files, a base (default) config and a custom config, into a single config.
This is helpful when you want to reuse the same rule options across different diki runs.
Only `ruleOptions` are merged, everything else (provider args, metadata, output) comes from the custom config.

If either config skips a rule, the merged result will always skip it.

```bash
diki config merge \
--base=base.yaml \
--custom=custom.yaml \
--output=merged.yaml
```

### Unit Tests

You can manually run the tests via `make test`.
Expand Down
91 changes: 91 additions & 0 deletions cmd/diki/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
logf "sigs.k8s.io/controller-runtime/pkg/log"

"github.com/gardener/diki/pkg/config"
"github.com/gardener/diki/pkg/config/merge"
"github.com/gardener/diki/pkg/metadata"
"github.com/gardener/diki/pkg/provider"
"github.com/gardener/diki/pkg/report"
Expand Down Expand Up @@ -170,6 +171,37 @@ e.g. to check compliance of your hyperscaler accounts.`,

showCmd.AddCommand(showProviderCmd)

mergeRegistryFuncs := []provider.MergeRegistryFunc{}
for _, providerOption := range providerOptions {
if providerOption.MergeRegistryFunc != nil {
mergeRegistryFuncs = append(mergeRegistryFuncs, providerOption.MergeRegistryFunc)
}
}

configCmd := &cobra.Command{
Use: "config",
Short: "Config is the root command for configuration operations.",
Long: "Config is the root command for configuration operations.",
RunE: func(_ *cobra.Command, _ []string) error {
return errors.New("config subcommand not selected")
},
}

rootCmd.AddCommand(configCmd)

var configMergeOpts configMergeOptions
configMergeCmd := &cobra.Command{
Use: "merge",
Short: "Merge a base config with a custom config.",
Long: "Merge combines rule options from a base (default) config with a custom config. The custom config is primary; only ruleOptions are merged.",
RunE: func(_ *cobra.Command, _ []string) error {
return configMergeCmd(configMergeOpts, mergeRegistryFuncs, logger)
},
}

addConfigMergeFlags(configMergeCmd, &configMergeOpts)
configCmd.AddCommand(configMergeCmd)

return rootCmd
}

Expand Down Expand Up @@ -557,6 +589,65 @@ type diffOptions struct {
title string
}

type configMergeOptions struct {
basePath string
customPath string
outputPath string
}

func addConfigMergeFlags(cmd *cobra.Command, opts *configMergeOptions) {
cmd.PersistentFlags().StringVar(&opts.basePath, "base", "", "Path to the base (default) configuration file.")
cmd.PersistentFlags().StringVar(&opts.customPath, "custom", "", "Path to the custom configuration file.")
cmd.PersistentFlags().StringVar(&opts.outputPath, "output", "", "Output path for the merged configuration. If not set, output is written to stdout.")
_ = cmd.MarkPersistentFlagRequired("base")
_ = cmd.MarkPersistentFlagRequired("custom")
}

func configMergeCmd(opts configMergeOptions, registryFuncs []provider.MergeRegistryFunc, logger *slog.Logger) error {

baseConfig, err := readConfig(opts.basePath)
if err != nil {
return fmt.Errorf("failed to read base config: %w", err)
}

customConfig, err := readConfig(opts.customPath)
if err != nil {
return fmt.Errorf("failed to read custom config: %w", err)
}

registry := merge.NewRegistry()
for _, fn := range registryFuncs {
fn(registry)
}

merged, err := merge.MergeConfigs(baseConfig, customConfig, registry)
if err != nil {
return fmt.Errorf("failed to merge configs: %w", err)
}

data, err := yaml.Marshal(merged)
if err != nil {
return fmt.Errorf("failed to marshal merged config: %w", err)
}

var writer io.Writer = os.Stdout
if len(opts.outputPath) > 0 {
file, err := os.OpenFile(opts.outputPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
logger.Error(err.Error())
}
}()
writer = file
}

_, err = writer.Write(data)
return err
}

func readConfig(filePath string) (*config.DikiConfig, error) {
data, err := os.ReadFile(filepath.Clean(filePath))
if err != nil {
Expand Down
19 changes: 15 additions & 4 deletions cmd/diki/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,36 @@ import (
controllerruntime "sigs.k8s.io/controller-runtime"

"github.com/gardener/diki/cmd/diki/app"
"github.com/gardener/diki/pkg/config/merge"
"github.com/gardener/diki/pkg/provider"
"github.com/gardener/diki/pkg/provider/builder"
"github.com/gardener/diki/pkg/provider/garden"
gardenmerge "github.com/gardener/diki/pkg/provider/garden/ruleset/securityhardenedshoot"
"github.com/gardener/diki/pkg/provider/gardener"
gardenermerge "github.com/gardener/diki/pkg/provider/gardener/ruleset/disak8sstig"
"github.com/gardener/diki/pkg/provider/managedk8s"
managedk8sdisa "github.com/gardener/diki/pkg/provider/managedk8s/ruleset/disak8sstig"
"github.com/gardener/diki/pkg/provider/managedk8s/ruleset/securityhardenedk8s"
"github.com/gardener/diki/pkg/provider/virtualgarden"
virtualgardenmerge "github.com/gardener/diki/pkg/provider/virtualgarden/ruleset/disak8sstig"
)

func main() {
cmd := app.NewDikiCommand(
map[string]provider.ProviderOption{
garden.ProviderID: {ProviderFromConfigFunc: builder.GardenProviderFromConfig, MetadataFunc: builder.GardenProviderMetadata},
gardener.ProviderID: {ProviderFromConfigFunc: builder.GardenerProviderFromConfig, MetadataFunc: builder.GardenerProviderMetadata},
managedk8s.ProviderID: {ProviderFromConfigFunc: builder.ManagedK8SProviderFromConfig, MetadataFunc: builder.ManagedK8SProviderMetadata, DefaultDikiConfigFunc: managedk8s.ManagedK8sDefaultDikiConfigFunc},
virtualgarden.ProviderID: {ProviderFromConfigFunc: builder.VirtualGardenProviderFromConfig, MetadataFunc: builder.VirtualGardenProviderMetadata},
garden.ProviderID: {ProviderFromConfigFunc: builder.GardenProviderFromConfig, MetadataFunc: builder.GardenProviderMetadata, MergeRegistryFunc: gardenmerge.RegisterMergeFuncs},
gardener.ProviderID: {ProviderFromConfigFunc: builder.GardenerProviderFromConfig, MetadataFunc: builder.GardenerProviderMetadata, MergeRegistryFunc: gardenermerge.RegisterMergeFuncs},
managedk8s.ProviderID: {ProviderFromConfigFunc: builder.ManagedK8SProviderFromConfig, MetadataFunc: builder.ManagedK8SProviderMetadata, DefaultDikiConfigFunc: managedk8s.ManagedK8sDefaultDikiConfigFunc, MergeRegistryFunc: managedk8sMergeRegistryFunc},
virtualgarden.ProviderID: {ProviderFromConfigFunc: builder.VirtualGardenProviderFromConfig, MetadataFunc: builder.VirtualGardenProviderMetadata, MergeRegistryFunc: virtualgardenmerge.RegisterMergeFuncs},
},
)

if err := cmd.ExecuteContext(controllerruntime.SetupSignalHandler()); err != nil {
log.Fatal(err)
}
}

func managedk8sMergeRegistryFunc(r *merge.Registry) {
managedk8sdisa.RegisterMergeFuncs(r)
securityhardenedk8s.RegisterMergeFuncs(r)
}
179 changes: 179 additions & 0 deletions pkg/config/merge/merge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
//
// SPDX-License-Identifier: Apache-2.0

package merge

import (
"github.com/gardener/diki/pkg/config"
)

// MergeConfigs merges two DikiConfigs. The custom config is the primary config — its structure,
// providers, metadata, output, and provider/ruleset args are preserved as-is. Only ruleOptions
// within matching rulesets (matched by providerID + rulesetID + version) are merged.
//
// Merge rules for ruleOptions:
// - Rule in custom only: kept as-is
// - Rule in base only: appended to the output
// - Rule in both + MergeableOption: Merge() is called (base.Merge(custom))
// - Rule in both + not MergeableOption: custom args win
// - Skip always wins: if either config skips a rule, the merged result skips it
func MergeConfigs(base, custom *config.DikiConfig, registry *Registry) (*config.DikiConfig, error) {
if base == nil {
return custom, nil
}
if custom == nil {
return custom, nil
}

baseProviders := indexProviders(base.Providers)

for pi := range custom.Providers {
customProvider := &custom.Providers[pi]
baseProvider, found := baseProviders[customProvider.ID]
if !found {
continue
}

baseRulesets := indexRulesets(baseProvider.Rulesets)

for ri := range customProvider.Rulesets {
customRuleset := &customProvider.Rulesets[ri]
rulesetKey := rulesetIndexKey{ID: customRuleset.ID, Version: customRuleset.Version}
baseRuleset, found := baseRulesets[rulesetKey]
if !found {
continue
}

merged, err := mergeRuleOptions(
baseRuleset.RuleOptions,
customRuleset.RuleOptions,
customProvider.ID,
customRuleset.ID,
customRuleset.Version,
registry,
)
if err != nil {
return nil, err
}
customRuleset.RuleOptions = merged
}
}

return custom, nil
}

func mergeRuleOptions(
baseOpts, customOpts []config.RuleOptionsConfig,
providerID, rulesetID, version string,
registry *Registry,
) ([]config.RuleOptionsConfig, error) {
baseByRuleID := make(map[string]config.RuleOptionsConfig, len(baseOpts))
for _, opt := range baseOpts {
baseByRuleID[opt.RuleID] = opt
}

customRuleIDs := make(map[string]struct{}, len(customOpts))
merged := make([]config.RuleOptionsConfig, 0, len(customOpts)+len(baseOpts))

for _, customOpt := range customOpts {
customRuleIDs[customOpt.RuleID] = struct{}{}

baseOpt, inBase := baseByRuleID[customOpt.RuleID]
if !inBase {
merged = append(merged, customOpt)
continue
}

mergedOpt, err := mergeSingleRuleOption(baseOpt, customOpt, providerID, rulesetID, version, registry)
if err != nil {
return nil, err
}
merged = append(merged, mergedOpt)
}

for _, baseOpt := range baseOpts {
if _, inCustom := customRuleIDs[baseOpt.RuleID]; !inCustom {
merged = append(merged, baseOpt)
}
}

return merged, nil
}

func mergeSingleRuleOption(
baseOpt, customOpt config.RuleOptionsConfig,
providerID, rulesetID, version string,
registry *Registry,
) (config.RuleOptionsConfig, error) {
result := config.RuleOptionsConfig{
RuleID: customOpt.RuleID,
}

result.Skip = mergeSkip(baseOpt.Skip, customOpt.Skip)

if baseOpt.Args == nil && customOpt.Args == nil {
return result, nil
}
if baseOpt.Args == nil {
result.Args = customOpt.Args
return result, nil
}
if customOpt.Args == nil {
result.Args = baseOpt.Args
return result, nil
}

key := RegistryKey{
ProviderID: providerID,
RulesetID: rulesetID,
Version: version,
RuleID: customOpt.RuleID,
}

mergeFn := registry.Get(key)
if mergeFn == nil {
result.Args = customOpt.Args
return result, nil
}

mergedArgs, err := mergeFn(baseOpt.Args, customOpt.Args)
if err != nil {
return config.RuleOptionsConfig{}, err
}
result.Args = mergedArgs

return result, nil
}

func mergeSkip(baseSkip, customSkip *config.RuleOptionSkipConfig) *config.RuleOptionSkipConfig {
if customSkip != nil && customSkip.Enabled {
return customSkip
}
if baseSkip != nil && baseSkip.Enabled {
return baseSkip
}
return nil
}

type rulesetIndexKey struct {
ID string
Version string
}

func indexProviders(providers []config.ProviderConfig) map[string]*config.ProviderConfig {
m := make(map[string]*config.ProviderConfig, len(providers))
for i := range providers {
m[providers[i].ID] = &providers[i]
}
return m
}

func indexRulesets(rulesets []config.RulesetConfig) map[rulesetIndexKey]*config.RulesetConfig {
m := make(map[rulesetIndexKey]*config.RulesetConfig, len(rulesets))
for i := range rulesets {
key := rulesetIndexKey{ID: rulesets[i].ID, Version: rulesets[i].Version}
m[key] = &rulesets[i]
}
return m
}
17 changes: 17 additions & 0 deletions pkg/config/merge/merge_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
//
// SPDX-License-Identifier: Apache-2.0

package merge_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestMerge(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Config Merge Test Suite")
}
Loading