diff --git a/Makefile b/Makefile index b5d4a6178..0f7a041ca 100644 --- a/Makefile +++ b/Makefile @@ -138,6 +138,9 @@ vet: setup/ginkgo ## Run go vet against code. test: manifests generate fmt vet envtest ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use ${ENVTEST_K8S_VERSION} --bin-dir $(LOCALBIN) -p path)" ginkgo --junit-report=unit_test.xml --output-dir=`pwd` -vv --trace --keep-going --timeout=3h --cover --covermode=count --coverprofile=coverage.out ./pkg/splunk/common ./pkg/splunk/enterprise ./pkg/splunk/client ./pkg/splunk/util ./internal/controller ./pkg/splunk/splkcontroller +.PHONY: spec-config-mapping +spec-config-mapping: ## Generate CRD spec-to-Splunk-config mapping JSON. + go run ./tools/spec-config-mapping -o api/v4/spec-config-mapping.json ##@ Build diff --git a/api/v4/common_types.go b/api/v4/common_types.go index 5bba9c0cd..ac11cb8d8 100644 --- a/api/v4/common_types.go +++ b/api/v4/common_types.go @@ -257,16 +257,16 @@ type StorageClassSpec struct { // SmartStoreSpec defines Splunk indexes and remote storage volume configuration type SmartStoreSpec struct { // List of remote storage volumes - VolList []VolumeSpec `json:"volumes,omitempty"` + VolList []VolumeSpec `json:"volumes,omitempty" splunkconf:"indexes.conf,volume:"` // List of Splunk indexes - IndexList []IndexSpec `json:"indexes,omitempty"` + IndexList []IndexSpec `json:"indexes,omitempty" splunkconf:"indexes.conf,"` // Default configuration for indexes - Defaults IndexConfDefaultsSpec `json:"defaults,omitempty"` + Defaults IndexConfDefaultsSpec `json:"defaults,omitempty" splunkconf:"indexes.conf,default"` // Defines Cache manager settings - CacheManagerConf CacheManagerSpec `json:"cacheManager,omitempty"` + CacheManagerConf CacheManagerSpec `json:"cacheManager,omitempty" splunkconf:"server.conf,cachemanager"` } // CacheManagerSpec defines cachemanager specific configuration @@ -274,19 +274,19 @@ type CacheManagerSpec struct { IndexAndCacheManagerCommonSpec `json:",inline"` // Eviction policy to use - EvictionPolicy string `json:"evictionPolicy,omitempty"` + EvictionPolicy string `json:"evictionPolicy,omitempty" splunkconf:"eviction_policy"` // Max cache size per partition - MaxCacheSizeMB uint `json:"maxCacheSize,omitempty"` + MaxCacheSizeMB uint `json:"maxCacheSize,omitempty" splunkconf:"max_cache_size"` // Additional size beyond 'minFreeSize' before eviction kicks in - EvictionPaddingSizeMB uint `json:"evictionPadding,omitempty"` + EvictionPaddingSizeMB uint `json:"evictionPadding,omitempty" splunkconf:"eviction_padding"` // Maximum number of buckets that can be downloaded from remote storage in parallel - MaxConcurrentDownloads uint `json:"maxConcurrentDownloads,omitempty"` + MaxConcurrentDownloads uint `json:"maxConcurrentDownloads,omitempty" splunkconf:"max_concurrent_downloads"` // Maximum number of buckets that can be uploaded to remote storage in parallel - MaxConcurrentUploads uint `json:"maxConcurrentUploads,omitempty"` + MaxConcurrentUploads uint `json:"maxConcurrentUploads,omitempty" splunkconf:"max_concurrent_uploads"` } // IndexConfDefaultsSpec defines Splunk indexes.conf global/defaults @@ -300,22 +300,22 @@ type VolumeSpec struct { Name string `json:"name"` // Remote volume URI - Endpoint string `json:"endpoint"` + Endpoint string `json:"endpoint" splunkconf:"remote.s3.endpoint"` // Remote volume path - Path string `json:"path"` + Path string `json:"path" splunkconf:"path"` // Secret object name - SecretRef string `json:"secretRef"` + SecretRef string `json:"secretRef" splunkconf:"remote.s3.access_key;remote.s3.secret_key"` // Remote Storage type. Supported values: s3, blob, gcs. s3 works with aws or minio providers, whereas blob works with azure provider, gcs works for gcp. - Type string `json:"storageType"` + Type string `json:"storageType" splunkconf:"storageType"` // App Package Remote Store provider. Supported values: aws, minio, azure, gcp. Provider string `json:"provider"` // Region of the remote storage volume where apps reside. Used for aws, if provided. Not used for minio and azure. - Region string `json:"region"` + Region string `json:"region" splunkconf:"remote.s3.auth_region"` } // VolumeAndTypeSpec used to add any custom varaibles for volume implementation @@ -329,7 +329,7 @@ type IndexSpec struct { Name string `json:"name"` // Index location relative to the remote volume path - RemotePath string `json:"remotePath,omitempty"` + RemotePath string `json:"remotePath,omitempty" splunkconf:"remotePath"` IndexAndCacheManagerCommonSpec `json:",inline"` @@ -340,22 +340,22 @@ type IndexSpec struct { type IndexAndGlobalCommonSpec struct { // Remote Volume name - VolName string `json:"volumeName,omitempty"` + VolName string `json:"volumeName,omitempty" splunkconf:"remotePath"` // MaxGlobalDataSizeMB defines the maximum amount of space for warm and cold buckets of an index - MaxGlobalDataSizeMB uint `json:"maxGlobalDataSizeMB,omitempty"` + MaxGlobalDataSizeMB uint `json:"maxGlobalDataSizeMB,omitempty" splunkconf:"maxGlobalDataSizeMB"` // MaxGlobalDataSizeMB defines the maximum amount of cumulative space for warm and cold buckets of an index - MaxGlobalRawDataSizeMB uint `json:"maxGlobalRawDataSizeMB,omitempty"` + MaxGlobalRawDataSizeMB uint `json:"maxGlobalRawDataSizeMB,omitempty" splunkconf:"maxGlobalRawDataSizeMB"` } // IndexAndCacheManagerCommonSpec defines configurations that can be configured at index level or at server level type IndexAndCacheManagerCommonSpec struct { // Time period relative to the bucket's age, during which the bucket is protected from cache eviction - HotlistRecencySecs uint `json:"hotlistRecencySecs,omitempty"` + HotlistRecencySecs uint `json:"hotlistRecencySecs,omitempty" splunkconf:"hotlist_recency_secs"` // Time period relative to the bucket's age, during which the bloom filter file is protected from cache eviction - HotlistBloomFilterRecencyHours uint `json:"hotlistBloomFilterRecencyHours,omitempty"` + HotlistBloomFilterRecencyHours uint `json:"hotlistBloomFilterRecencyHours,omitempty" splunkconf:"hotlist_bloom_filter_recency_hours"` } // AppSourceDefaultSpec defines config common for defaults and App Sources @@ -381,7 +381,7 @@ type PremiumAppsProps struct { // Enterpreise Security App defaults // +optional - EsDefaults EsDefaults `json:"esDefaults,omitempty"` + EsDefaults EsDefaults `json:"esDefaults,omitempty" splunkconf:"web.conf,settings"` } // EsDefaults captures defaults for the Enterprise Security App @@ -397,7 +397,7 @@ type EsDefaults struct { // ignore: Ignores whether SSL is enabled or disabled. // // +optional - SslEnablement string `json:"sslEnablement,omitempty"` + SslEnablement string `json:"sslEnablement,omitempty" splunkconf:"enableSplunkWebSSL"` } // AppSourceSpec defines list of App package (*.spl, *.tgz) locations on remote volumes diff --git a/api/v4/spec-config-mapping.json b/api/v4/spec-config-mapping.json new file mode 100644 index 000000000..746094406 --- /dev/null +++ b/api/v4/spec-config-mapping.json @@ -0,0 +1,395 @@ +{ + "version": "1.0.0", + "generatedAt": "2026-02-18T19:38:20Z", + "operatorVersion": "3.0.0", + "apiVersion": "enterprise.splunk.com/v4", + "crds": { + "ClusterManager": { + "specFields": { + "appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + }, + "appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + }, + "smartstore.cacheManager.evictionPadding": { + "jsonPath": "spec.smartstore.cacheManager.evictionPadding", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "eviction_padding" + }, + "smartstore.cacheManager.evictionPolicy": { + "jsonPath": "spec.smartstore.cacheManager.evictionPolicy", + "goType": "string", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "eviction_policy" + }, + "smartstore.cacheManager.hotlistBloomFilterRecencyHours": { + "jsonPath": "spec.smartstore.cacheManager.hotlistBloomFilterRecencyHours", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "hotlist_bloom_filter_recency_hours" + }, + "smartstore.cacheManager.hotlistRecencySecs": { + "jsonPath": "spec.smartstore.cacheManager.hotlistRecencySecs", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "hotlist_recency_secs" + }, + "smartstore.cacheManager.maxCacheSize": { + "jsonPath": "spec.smartstore.cacheManager.maxCacheSize", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "max_cache_size" + }, + "smartstore.cacheManager.maxConcurrentDownloads": { + "jsonPath": "spec.smartstore.cacheManager.maxConcurrentDownloads", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "max_concurrent_downloads" + }, + "smartstore.cacheManager.maxConcurrentUploads": { + "jsonPath": "spec.smartstore.cacheManager.maxConcurrentUploads", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "max_concurrent_uploads" + }, + "smartstore.defaults.maxGlobalDataSizeMB": { + "jsonPath": "spec.smartstore.defaults.maxGlobalDataSizeMB", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "default", + "key": "maxGlobalDataSizeMB" + }, + "smartstore.defaults.maxGlobalRawDataSizeMB": { + "jsonPath": "spec.smartstore.defaults.maxGlobalRawDataSizeMB", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "default", + "key": "maxGlobalRawDataSizeMB" + }, + "smartstore.defaults.volumeName": { + "jsonPath": "spec.smartstore.defaults.volumeName", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "default", + "key": "remotePath" + }, + "smartstore.indexes.hotlistBloomFilterRecencyHours": { + "jsonPath": "spec.smartstore.indexes.hotlistBloomFilterRecencyHours", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "", + "key": "hotlist_bloom_filter_recency_hours" + }, + "smartstore.indexes.hotlistRecencySecs": { + "jsonPath": "spec.smartstore.indexes.hotlistRecencySecs", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "", + "key": "hotlist_recency_secs" + }, + "smartstore.indexes.maxGlobalDataSizeMB": { + "jsonPath": "spec.smartstore.indexes.maxGlobalDataSizeMB", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "", + "key": "maxGlobalDataSizeMB" + }, + "smartstore.indexes.maxGlobalRawDataSizeMB": { + "jsonPath": "spec.smartstore.indexes.maxGlobalRawDataSizeMB", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "", + "key": "maxGlobalRawDataSizeMB" + }, + "smartstore.indexes.remotePath": { + "jsonPath": "spec.smartstore.indexes.remotePath", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "", + "key": "remotePath" + }, + "smartstore.indexes.volumeName": { + "jsonPath": "spec.smartstore.indexes.volumeName", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "", + "key": "remotePath" + }, + "smartstore.volumes.endpoint": { + "jsonPath": "spec.smartstore.volumes.endpoint", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "remote.s3.endpoint" + }, + "smartstore.volumes.path": { + "jsonPath": "spec.smartstore.volumes.path", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "path" + }, + "smartstore.volumes.region": { + "jsonPath": "spec.smartstore.volumes.region", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "remote.s3.auth_region" + }, + "smartstore.volumes.secretRef": { + "jsonPath": "spec.smartstore.volumes.secretRef", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "remote.s3.access_key;remote.s3.secret_key" + }, + "smartstore.volumes.storageType": { + "jsonPath": "spec.smartstore.volumes.storageType", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "storageType" + } + } + }, + "IndexerCluster": { + "specFields": {} + }, + "LicenseManager": { + "specFields": { + "appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + }, + "appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + } + } + }, + "MonitoringConsole": { + "specFields": { + "appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + }, + "appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + } + } + }, + "SearchHeadCluster": { + "specFields": { + "appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + }, + "appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + } + } + }, + "Standalone": { + "specFields": { + "appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.appSources.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + }, + "appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement": { + "jsonPath": "spec.appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement", + "goType": "string", + "confFile": "web.conf", + "stanza": "settings", + "key": "enableSplunkWebSSL" + }, + "smartstore.cacheManager.evictionPadding": { + "jsonPath": "spec.smartstore.cacheManager.evictionPadding", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "eviction_padding" + }, + "smartstore.cacheManager.evictionPolicy": { + "jsonPath": "spec.smartstore.cacheManager.evictionPolicy", + "goType": "string", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "eviction_policy" + }, + "smartstore.cacheManager.hotlistBloomFilterRecencyHours": { + "jsonPath": "spec.smartstore.cacheManager.hotlistBloomFilterRecencyHours", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "hotlist_bloom_filter_recency_hours" + }, + "smartstore.cacheManager.hotlistRecencySecs": { + "jsonPath": "spec.smartstore.cacheManager.hotlistRecencySecs", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "hotlist_recency_secs" + }, + "smartstore.cacheManager.maxCacheSize": { + "jsonPath": "spec.smartstore.cacheManager.maxCacheSize", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "max_cache_size" + }, + "smartstore.cacheManager.maxConcurrentDownloads": { + "jsonPath": "spec.smartstore.cacheManager.maxConcurrentDownloads", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "max_concurrent_downloads" + }, + "smartstore.cacheManager.maxConcurrentUploads": { + "jsonPath": "spec.smartstore.cacheManager.maxConcurrentUploads", + "goType": "uint", + "confFile": "server.conf", + "stanza": "cachemanager", + "key": "max_concurrent_uploads" + }, + "smartstore.defaults.maxGlobalDataSizeMB": { + "jsonPath": "spec.smartstore.defaults.maxGlobalDataSizeMB", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "default", + "key": "maxGlobalDataSizeMB" + }, + "smartstore.defaults.maxGlobalRawDataSizeMB": { + "jsonPath": "spec.smartstore.defaults.maxGlobalRawDataSizeMB", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "default", + "key": "maxGlobalRawDataSizeMB" + }, + "smartstore.defaults.volumeName": { + "jsonPath": "spec.smartstore.defaults.volumeName", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "default", + "key": "remotePath" + }, + "smartstore.indexes.hotlistBloomFilterRecencyHours": { + "jsonPath": "spec.smartstore.indexes.hotlistBloomFilterRecencyHours", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "", + "key": "hotlist_bloom_filter_recency_hours" + }, + "smartstore.indexes.hotlistRecencySecs": { + "jsonPath": "spec.smartstore.indexes.hotlistRecencySecs", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "", + "key": "hotlist_recency_secs" + }, + "smartstore.indexes.maxGlobalDataSizeMB": { + "jsonPath": "spec.smartstore.indexes.maxGlobalDataSizeMB", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "", + "key": "maxGlobalDataSizeMB" + }, + "smartstore.indexes.maxGlobalRawDataSizeMB": { + "jsonPath": "spec.smartstore.indexes.maxGlobalRawDataSizeMB", + "goType": "uint", + "confFile": "indexes.conf", + "stanza": "", + "key": "maxGlobalRawDataSizeMB" + }, + "smartstore.indexes.remotePath": { + "jsonPath": "spec.smartstore.indexes.remotePath", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "", + "key": "remotePath" + }, + "smartstore.indexes.volumeName": { + "jsonPath": "spec.smartstore.indexes.volumeName", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "", + "key": "remotePath" + }, + "smartstore.volumes.endpoint": { + "jsonPath": "spec.smartstore.volumes.endpoint", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "remote.s3.endpoint" + }, + "smartstore.volumes.path": { + "jsonPath": "spec.smartstore.volumes.path", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "path" + }, + "smartstore.volumes.region": { + "jsonPath": "spec.smartstore.volumes.region", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "remote.s3.auth_region" + }, + "smartstore.volumes.secretRef": { + "jsonPath": "spec.smartstore.volumes.secretRef", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "remote.s3.access_key;remote.s3.secret_key" + }, + "smartstore.volumes.storageType": { + "jsonPath": "spec.smartstore.volumes.storageType", + "goType": "string", + "confFile": "indexes.conf", + "stanza": "volume:", + "key": "storageType" + } + } + } + } +} diff --git a/tools/spec-config-mapping/main.go b/tools/spec-config-mapping/main.go new file mode 100644 index 000000000..4c6453f22 --- /dev/null +++ b/tools/spec-config-mapping/main.go @@ -0,0 +1,229 @@ +// Copyright (c) 2018-2022 Splunk Inc. All rights reserved. + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "reflect" + "strings" + "time" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// ── JSON output types ───────────────────────────────────────────────── + +// Manifest is the top-level output structure. +type Manifest struct { + Version string `json:"version"` + GeneratedAt string `json:"generatedAt"` + OperatorVersion string `json:"operatorVersion"` + APIVersion string `json:"apiVersion"` + CRDs map[string]CRDMapping `json:"crds"` +} + +// CRDMapping holds the field mappings for one CRD kind. +type CRDMapping struct { + SpecFields map[string]FieldMapping `json:"specFields"` +} + +// FieldMapping is the per-field entry in the JSON output. +type FieldMapping struct { + JSONPath string `json:"jsonPath"` + GoType string `json:"goType"` + ConfFile string `json:"confFile"` + Stanza string `json:"stanza"` + Key string `json:"key"` +} + +// ── CRD registry ────────────────────────────────────────────────────── + +type crdEntry struct { + kind string + specType reflect.Type +} + +func crdRegistry() []crdEntry { + return []crdEntry{ + {"Standalone", reflect.TypeOf(enterpriseApi.StandaloneSpec{})}, + {"ClusterManager", reflect.TypeOf(enterpriseApi.ClusterManagerSpec{})}, + {"IndexerCluster", reflect.TypeOf(enterpriseApi.IndexerClusterSpec{})}, + {"SearchHeadCluster", reflect.TypeOf(enterpriseApi.SearchHeadClusterSpec{})}, + {"LicenseManager", reflect.TypeOf(enterpriseApi.LicenseManagerSpec{})}, + {"MonitoringConsole", reflect.TypeOf(enterpriseApi.MonitoringConsoleSpec{})}, + } +} + +// ── confContext tracks inherited confFile and stanza from parent tags ── + +type confContext struct { + confFile string + stanza string +} + +// ── Reflection walker ───────────────────────────────────────────────── + +// WalkConfTags recursively walks a struct type and collects fields that +// have a `splunkconf` struct tag. Parent struct/slice fields with a +// 2-part tag ("confFile,stanza") set the context for child leaf fields +// with a 1-part tag ("key"). +func WalkConfTags(t reflect.Type, prefix string, ctx confContext) map[string]FieldMapping { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + + result := make(map[string]FieldMapping) + + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + + // Determine JSON name + jsonTag := sf.Tag.Get("json") + if jsonTag == "-" { + continue + } + jsonName := strings.Split(jsonTag, ",")[0] + + // Handle embedded (anonymous) structs: flatten, keep parent prefix and context + if sf.Anonymous { + for k, v := range WalkConfTags(sf.Type, prefix, ctx) { + result[k] = v + } + continue + } + + if jsonName == "" { + jsonName = sf.Name + } + + fullPath := jsonName + if prefix != "" { + fullPath = prefix + "." + jsonName + } + + // Check for splunkconf tag + splunkTag := sf.Tag.Get("splunkconf") + + ft := sf.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + + switch ft.Kind() { + case reflect.Struct: + if isLeafType(ft) { + // Opaque k8s type — skip + continue + } + // If this struct field has a 2-part splunkconf tag, it sets context for children + childCtx := ctx + if parts := strings.SplitN(splunkTag, ",", 2); len(parts) == 2 { + childCtx = confContext{confFile: parts[0], stanza: parts[1]} + } + for k, v := range WalkConfTags(ft, fullPath, childCtx) { + result[k] = v + } + + case reflect.Slice: + elemType := ft.Elem() + if elemType.Kind() == reflect.Ptr { + elemType = elemType.Elem() + } + if elemType.Kind() == reflect.Struct && !isLeafType(elemType) { + childCtx := ctx + if parts := strings.SplitN(splunkTag, ",", 2); len(parts) == 2 { + childCtx = confContext{confFile: parts[0], stanza: parts[1]} + } + for k, v := range WalkConfTags(elemType, fullPath, childCtx) { + result[k] = v + } + } + + default: + // Leaf field — only include if it has a splunkconf key tag + if splunkTag == "" { + continue + } + if ctx.confFile == "" || ctx.stanza == "" { + continue + } + result[fullPath] = FieldMapping{ + JSONPath: "spec." + fullPath, + GoType: ft.String(), + ConfFile: ctx.confFile, + Stanza: ctx.stanza, + Key: splunkTag, + } + } + } + return result +} + +// isLeafType returns true for complex types we don't want to recurse into. +func isLeafType(t reflect.Type) bool { + return strings.HasPrefix(t.PkgPath(), "k8s.io/") +} + +// ── Main ────────────────────────────────────────────────────────────── + +func main() { + outPath := flag.String("o", "spec-config-mapping.json", "Output JSON file path") + flag.Parse() + + registry := crdRegistry() + manifest := Manifest{ + Version: "1.0.0", + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + OperatorVersion: "3.0.0", + APIVersion: enterpriseApi.APIVersion, + CRDs: make(map[string]CRDMapping), + } + + for _, crd := range registry { + fields := WalkConfTags(crd.specType, "", confContext{}) + manifest.CRDs[crd.kind] = CRDMapping{SpecFields: fields} + } + + // Write output + f, err := os.Create(*outPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to create %s: %v\n", *outPath, err) + os.Exit(1) + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(manifest); err != nil { + f.Close() + fmt.Fprintf(os.Stderr, "error: failed to write JSON: %v\n", err) + os.Exit(1) + } + f.Close() + + // Summary + totalFields := 0 + for _, crd := range manifest.CRDs { + totalFields += len(crd.SpecFields) + } + fmt.Fprintf(os.Stderr, "Generated %s: %d CRDs, %d total field mappings\n", + *outPath, len(manifest.CRDs), totalFields) +} diff --git a/tools/spec-config-mapping/mappings_test.go b/tools/spec-config-mapping/mappings_test.go new file mode 100644 index 000000000..4842103f8 --- /dev/null +++ b/tools/spec-config-mapping/mappings_test.go @@ -0,0 +1,175 @@ +// Copyright (c) 2018-2022 Splunk Inc. All rights reserved. + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" +) + +// TestStandaloneSmartStoreFields verifies SmartStore fields are discovered +// with correct conf coordinates for Standalone. +func TestStandaloneSmartStoreFields(t *testing.T) { + registry := crdRegistry() + + for _, crd := range registry { + if crd.kind != "Standalone" { + continue + } + fields := WalkConfTags(crd.specType, "", confContext{}) + + expected := map[string]FieldMapping{ + "smartstore.cacheManager.evictionPolicy": { + ConfFile: "server.conf", Stanza: "cachemanager", Key: "eviction_policy", + }, + "smartstore.cacheManager.maxCacheSize": { + ConfFile: "server.conf", Stanza: "cachemanager", Key: "max_cache_size", + }, + "smartstore.cacheManager.hotlistRecencySecs": { + ConfFile: "server.conf", Stanza: "cachemanager", Key: "hotlist_recency_secs", + }, + "smartstore.indexes.remotePath": { + ConfFile: "indexes.conf", Stanza: "", Key: "remotePath", + }, + "smartstore.indexes.maxGlobalDataSizeMB": { + ConfFile: "indexes.conf", Stanza: "", Key: "maxGlobalDataSizeMB", + }, + "smartstore.volumes.endpoint": { + ConfFile: "indexes.conf", Stanza: "volume:", Key: "remote.s3.endpoint", + }, + "smartstore.defaults.volumeName": { + ConfFile: "indexes.conf", Stanza: "default", Key: "remotePath", + }, + } + + for path, want := range expected { + got, ok := fields[path] + if !ok { + t.Errorf("expected field %q not found in Standalone", path) + continue + } + if got.ConfFile != want.ConfFile { + t.Errorf("%s: confFile = %q, want %q", path, got.ConfFile, want.ConfFile) + } + if got.Stanza != want.Stanza { + t.Errorf("%s: stanza = %q, want %q", path, got.Stanza, want.Stanza) + } + if got.Key != want.Key { + t.Errorf("%s: key = %q, want %q", path, got.Key, want.Key) + } + } + } +} + +// TestContextInheritance verifies that shared embedded types inherit the +// correct context from different parents (e.g., hotlistRecencySecs maps +// to server.conf under cacheManager but indexes.conf under indexes). +func TestContextInheritance(t *testing.T) { + registry := crdRegistry() + + for _, crd := range registry { + if crd.kind != "Standalone" { + continue + } + fields := WalkConfTags(crd.specType, "", confContext{}) + + cmField := fields["smartstore.cacheManager.hotlistRecencySecs"] + idxField := fields["smartstore.indexes.hotlistRecencySecs"] + + if cmField.ConfFile != "server.conf" { + t.Errorf("cacheManager.hotlistRecencySecs: confFile = %q, want server.conf", cmField.ConfFile) + } + if idxField.ConfFile != "indexes.conf" { + t.Errorf("indexes.hotlistRecencySecs: confFile = %q, want indexes.conf", idxField.ConfFile) + } + if cmField.Stanza != "cachemanager" { + t.Errorf("cacheManager.hotlistRecencySecs: stanza = %q, want cachemanager", cmField.Stanza) + } + if idxField.Stanza != "" { + t.Errorf("indexes.hotlistRecencySecs: stanza = %q, want ", idxField.Stanza) + } + if cmField.Key != idxField.Key { + t.Errorf("hotlistRecencySecs key mismatch: %q vs %q", cmField.Key, idxField.Key) + } + } +} + +// TestSmartStoreOnlyInCorrectCRDs verifies SmartStore fields only appear +// in Standalone and ClusterManager. +func TestSmartStoreOnlyInCorrectCRDs(t *testing.T) { + registry := crdRegistry() + ssField := "smartstore.cacheManager.evictionPolicy" + + for _, crd := range registry { + fields := WalkConfTags(crd.specType, "", confContext{}) + _, found := fields[ssField] + + switch crd.kind { + case "Standalone", "ClusterManager": + if !found { + t.Errorf("%s should have SmartStore fields but %q not found", crd.kind, ssField) + } + default: + if found { + t.Errorf("%s should NOT have SmartStore fields but %q was found", crd.kind, ssField) + } + } + } +} + +// TestNoUntaggedFieldsLeakThrough verifies that fields without a splunkconf +// tag do not appear in the output. +func TestNoUntaggedFieldsLeakThrough(t *testing.T) { + registry := crdRegistry() + + untaggedPaths := []string{ + "image", + "replicas", + "licenseUrl", + "affinity", + "resources", + } + + for _, crd := range registry { + fields := WalkConfTags(crd.specType, "", confContext{}) + for _, path := range untaggedPaths { + if _, found := fields[path]; found { + t.Errorf("%s: untagged field %q should not appear in output", crd.kind, path) + } + } + } +} + +// TestEsDefaultsMapping verifies the web.conf mapping from the app framework. +func TestEsDefaultsMapping(t *testing.T) { + registry := crdRegistry() + + for _, crd := range registry { + if crd.kind != "Standalone" { + continue + } + fields := WalkConfTags(crd.specType, "", confContext{}) + + path := "appRepo.defaults.premiumAppsProps.esDefaults.sslEnablement" + got, ok := fields[path] + if !ok { + t.Fatalf("expected field %q not found", path) + } + if got.ConfFile != "web.conf" || got.Stanza != "settings" || got.Key != "enableSplunkWebSSL" { + t.Errorf("%s: got %s/%s/%s, want web.conf/settings/enableSplunkWebSSL", + path, got.ConfFile, got.Stanza, got.Key) + } + } +}