refactor a bit

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
This commit is contained in:
Andrei Kvapil 2025-11-25 16:24:08 +01:00
parent c69756de51
commit e046206d2b
No known key found for this signature in database
GPG key ID: 931CF7FEACEAF765
9 changed files with 394 additions and 313 deletions

View file

@ -24,6 +24,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"github.com/cozystack/cozystack/pkg/cozylib"
)
// +kubebuilder:rbac:groups=cozystack.io,resources=cozystackresourcedefinitions,verbs=get;list;watch
@ -358,7 +360,7 @@ func (r *CozystackResourceDefinitionReconciler) updateHelmReleaseChart(ctx conte
var err error
if crd.Spec.Release.Values != nil {
logger.V(4).Info("Merging values from CRD", "name", hr.Name, "namespace", hr.Namespace, "crd", crd.Name)
mergedValues, err = r.mergeHelmReleaseValues(crd.Spec.Release.Values, hrCopy.Spec.Values)
mergedValues, err = cozylib.MergeValuesWithCRDPriority(crd.Spec.Release.Values, hrCopy.Spec.Values)
if err != nil {
logger.Error(err, "failed to merge values", "name", hr.Name, "namespace", hr.Namespace)
return fmt.Errorf("failed to merge values: %w", err)
@ -368,12 +370,15 @@ func (r *CozystackResourceDefinitionReconciler) updateHelmReleaseChart(ctx conte
mergedValues = hrCopy.Spec.Values
}
// Always inject namespace labels (top-level _namespace field)
// Always inject namespace annotations (top-level _namespace field)
// This matches the behavior in cozystack-api and NamespaceHelmReconciler
mergedValues, err = r.injectNamespaceLabelsIntoValues(ctx, mergedValues, hrCopy.Namespace)
if err != nil {
logger.Error(err, "failed to inject namespace labels", "name", hr.Name, "namespace", hr.Namespace)
// Continue even if namespace labels injection fails
namespace := &corev1.Namespace{}
if err := r.Get(ctx, client.ObjectKey{Name: hrCopy.Namespace}, namespace); err == nil {
mergedValues, err = cozylib.InjectNamespaceAnnotationsIntoValues(mergedValues, namespace)
if err != nil {
logger.Error(err, "failed to inject namespace annotations", "name", hr.Name, "namespace", hr.Namespace)
// Continue even if namespace annotations injection fails
}
}
// Always update values to ensure _cozystack and _namespace are applied
@ -495,47 +500,3 @@ func valuesEqual(a, b *apiextensionsv1.JSON) bool {
return string(a.Raw) == string(b.Raw)
}
// injectNamespaceLabelsIntoValues injects namespace.cozystack.io/* labels into _namespace (top-level)
// This matches the behavior in cozystack-api and NamespaceHelmReconciler
func (r *CozystackResourceDefinitionReconciler) injectNamespaceLabelsIntoValues(ctx context.Context, values *apiextensionsv1.JSON, namespaceName string) (*apiextensionsv1.JSON, error) {
// Get namespace to extract namespace.cozystack.io/* labels
namespace := &corev1.Namespace{}
if err := r.Get(ctx, client.ObjectKey{Name: namespaceName}, namespace); err != nil {
// If namespace not found, return values as-is
return values, nil
}
// Extract namespace.cozystack.io/* labels
namespaceLabels := extractNamespaceLabelsFromNamespace(namespace)
if len(namespaceLabels) == 0 {
// No namespace labels, return values as-is
return values, nil
}
// Parse values
var valuesMap map[string]interface{}
if values != nil && len(values.Raw) > 0 {
if err := json.Unmarshal(values.Raw, &valuesMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
}
} else {
valuesMap = make(map[string]interface{})
}
// Convert namespaceLabels from map[string]string to map[string]interface{}
namespaceLabelsMap := make(map[string]interface{})
for k, v := range namespaceLabels {
namespaceLabelsMap[k] = v
}
// Namespace labels completely overwrite existing _namespace field (top-level)
valuesMap["_namespace"] = namespaceLabelsMap
// Marshal back to JSON
mergedJSON, err := json.Marshal(valuesMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal values with namespace labels: %w", err)
}
return &apiextensionsv1.JSON{Raw: mergedJSON}, nil
}

View file

@ -20,7 +20,6 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
corev1 "k8s.io/api/core/v1"
@ -29,6 +28,8 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/cozystack/cozystack/pkg/cozylib"
)
// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch
@ -49,8 +50,8 @@ func (r *NamespaceHelmReconciler) Reconcile(ctx context.Context, req ctrl.Reques
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Extract namespace.cozystack.io/* labels
namespaceLabels := extractNamespaceLabelsFromNamespace(namespace)
// Extract namespace.cozystack.io/* annotations
namespaceLabels := cozylib.ExtractNamespaceAnnotations(namespace)
if len(namespaceLabels) == 0 {
// No namespace labels to process, skip
return ctrl.Result{}, nil
@ -83,25 +84,6 @@ func (r *NamespaceHelmReconciler) Reconcile(ctx context.Context, req ctrl.Reques
return ctrl.Result{}, nil
}
// extractNamespaceLabelsFromNamespace extracts namespace.cozystack.io/* labels from namespace
func extractNamespaceLabelsFromNamespace(ns *corev1.Namespace) map[string]string {
namespaceLabels := make(map[string]string)
prefix := "namespace.cozystack.io/"
if ns.Labels == nil {
return namespaceLabels
}
for key, value := range ns.Labels {
if strings.HasPrefix(key, prefix) {
// Remove prefix and add to namespace labels
namespaceKey := strings.TrimPrefix(key, prefix)
namespaceLabels[namespaceKey] = value
}
}
return namespaceLabels
}
// updateHelmReleaseWithNamespaceLabels updates HelmRelease values with namespace labels
func (r *NamespaceHelmReconciler) updateHelmReleaseWithNamespaceLabels(ctx context.Context, hr *helmv2.HelmRelease, namespaceLabels map[string]string) error {

View file

@ -2,4 +2,4 @@ cozystackOperator:
image: ghcr.io/cozystack/cozystack/cozystack-operator:latest@sha256:ede9a0a6b7b1ad137ef1f75c1e954115da8ebdab110e47d920ae01ac62be93ec
disableTelemetry: false
cozystackVersion: "latest"
packagesDigest: sha256:e0b444176a041831f79fab0264e643171c16f8519b52854c27b1fad4b4abbf5e
packagesDigest: sha256:da9d726066e8ee884210f01df4d59a29560ecda596cc01fba340c0f21ae36d7a

View file

@ -1,5 +1,5 @@
cozystackAPI:
image: ghcr.io/cozystack/cozystack/cozystack-api:latest@sha256:51cbc43b73e2f2608ae451c3c14b316f6df8508e2a53a6a4d5b577f3c698b5b3
image: ghcr.io/cozystack/cozystack/cozystack-api:latest@sha256:fa43ce97a77d4f2e4d877a1b525dabc91ec4d8a8b865c55d9d470992e9027405
localK8sAPIEndpoint:
enabled: true
replicas: 2

View file

@ -1,4 +1,4 @@
cozystackController:
image: ghcr.io/cozystack/cozystack/cozystack-controller:latest@sha256:44679c58a9a8d5431dba9b4b512ec97cb252750fd30914c5afa4addf00f50479
image: ghcr.io/cozystack/cozystack/cozystack-controller:latest@sha256:196c769a58423f108ffca1760805f022155c63accab38c6a5bd736c6008b8b54
debug: false
cozystackAPIKind: "DaemonSet"

96
pkg/cozylib/namespace.go Normal file
View file

@ -0,0 +1,96 @@
/*
Copyright 2025 The Cozystack Authors.
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 cozylib
import (
"encoding/json"
"fmt"
"strings"
corev1 "k8s.io/api/core/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
)
const (
// NamespaceAnnotationPrefix is the prefix for namespace annotations that should be copied to _namespace values
NamespaceAnnotationPrefix = "namespace.cozystack.io/"
)
// ExtractNamespaceAnnotations extracts namespace.cozystack.io/* annotations from namespace
// and returns them as a map with the prefix removed.
// For example, "namespace.cozystack.io/host" becomes "host" in the returned map.
func ExtractNamespaceAnnotations(ns *corev1.Namespace) map[string]string {
result := make(map[string]string)
prefix := NamespaceAnnotationPrefix
if ns.Annotations == nil {
return result
}
for key, value := range ns.Annotations {
if strings.HasPrefix(key, prefix) {
// Remove prefix and add to result
namespaceKey := strings.TrimPrefix(key, prefix)
result[namespaceKey] = value
}
}
return result
}
// InjectNamespaceAnnotationsIntoValues injects namespace.cozystack.io/* annotations into _namespace (top-level) in values.
// This function extracts annotations from the namespace and adds them to the _namespace field in the values JSON.
// If namespace is nil or has no matching annotations, values are returned as-is.
func InjectNamespaceAnnotationsIntoValues(values *apiextensionsv1.JSON, ns *corev1.Namespace) (*apiextensionsv1.JSON, error) {
if ns == nil {
return values, nil
}
// Extract namespace.cozystack.io/* annotations
namespaceLabels := ExtractNamespaceAnnotations(ns)
if len(namespaceLabels) == 0 {
// No namespace annotations, return values as-is
return values, nil
}
// Parse values
var valuesMap map[string]interface{}
if values != nil && len(values.Raw) > 0 {
if err := json.Unmarshal(values.Raw, &valuesMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
}
} else {
valuesMap = make(map[string]interface{})
}
// Convert namespaceLabels from map[string]string to map[string]interface{}
namespaceLabelsMap := make(map[string]interface{})
for k, v := range namespaceLabels {
namespaceLabelsMap[k] = v
}
// Namespace annotations completely overwrite existing _namespace field (top-level)
valuesMap["_namespace"] = namespaceLabelsMap
// Marshal back to JSON
mergedJSON, err := json.Marshal(valuesMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal values with namespace annotations: %w", err)
}
return &apiextensionsv1.JSON{Raw: mergedJSON}, nil
}

266
pkg/cozylib/values.go Normal file
View file

@ -0,0 +1,266 @@
/*
Copyright 2025 The Cozystack Authors.
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 cozylib
import (
"encoding/json"
"fmt"
"strings"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
)
// DeepMergeMaps performs a deep merge of two maps.
// Values from override map take precedence, but nested maps are merged recursively.
func DeepMergeMaps(base, override map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Copy base map
for k, v := range base {
result[k] = v
}
// Merge override map
for k, v := range override {
if baseVal, exists := result[k]; exists {
// If both are maps, recursively merge
if baseMap, ok := baseVal.(map[string]interface{}); ok {
if overrideMap, ok := v.(map[string]interface{}); ok {
result[k] = DeepMergeMaps(baseMap, overrideMap)
continue
}
}
}
// Override takes precedence for non-map values or new keys
result[k] = v
}
return result
}
// MergeValues merges two JSON values with deep merge.
// baseValues are merged first, then overrideValues (overrideValues take precedence).
func MergeValues(baseValues, overrideValues *apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) {
var baseMap, overrideMap map[string]interface{}
if baseValues != nil && len(baseValues.Raw) > 0 {
if err := json.Unmarshal(baseValues.Raw, &baseMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal base values: %w", err)
}
} else {
baseMap = make(map[string]interface{})
}
if overrideValues != nil && len(overrideValues.Raw) > 0 {
if err := json.Unmarshal(overrideValues.Raw, &overrideMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal override values: %w", err)
}
} else {
overrideMap = make(map[string]interface{})
}
// Deep merge: baseValues first, then overrideValues (overrideValues override)
merged := DeepMergeMaps(baseMap, overrideMap)
mergedJSON, err := json.Marshal(merged)
if err != nil {
return nil, fmt.Errorf("failed to marshal merged values: %w", err)
}
return &apiextensionsv1.JSON{Raw: mergedJSON}, nil
}
// MergeValuesWithCRDPriority merges CRD values with existing values.
// Existing values have priority (user values override defaults), but _cozystack and _namespace
// from CRD completely overwrite existing values.
func MergeValuesWithCRDPriority(crdValues, existingValues *apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) {
// If CRD has no values, preserve existing
if crdValues == nil || len(crdValues.Raw) == 0 {
return existingValues, nil
}
// If existing has no values, use CRD values
if existingValues == nil || len(existingValues.Raw) == 0 {
return crdValues, nil
}
var crdMap, existingMap map[string]interface{}
// Parse CRD values (defaults)
if err := json.Unmarshal(crdValues.Raw, &crdMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal CRD values: %w", err)
}
// Parse existing HelmRelease values
if err := json.Unmarshal(existingValues.Raw, &existingMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal existing values: %w", err)
}
// Start with existing values as base (user values take priority)
// Then merge CRD values on top, but _cozystack and _namespace from CRD completely overwrite
merged := DeepMergeMaps(existingMap, crdMap)
// Explicitly handle "_cozystack" field: CRD values completely overwrite existing
// This ensures _cozystack field from CRD is always used, even if user modified it
if crdCozystack, exists := crdMap["_cozystack"]; exists {
merged["_cozystack"] = crdCozystack
}
// Explicitly handle "_namespace" field: CRD values completely overwrite existing
// This ensures _namespace field from CRD is always used, even if user modified it
if crdNamespace, exists := crdMap["_namespace"]; exists {
merged["_namespace"] = crdNamespace
}
mergedJSON, err := json.Marshal(merged)
if err != nil {
return nil, fmt.Errorf("failed to marshal merged values: %w", err)
}
return &apiextensionsv1.JSON{Raw: mergedJSON}, nil
}
// RemoveUnderscoreFields recursively removes all fields starting with "_" from values.
// This is used to hide internal fields from API responses.
func RemoveUnderscoreFields(values *apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) {
if values == nil || len(values.Raw) == 0 {
return values, nil
}
var valuesMap map[string]interface{}
if err := json.Unmarshal(values.Raw, &valuesMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
}
removeUnderscoreFieldsRecursive(valuesMap)
// Always return at least an empty JSON object, never nil
if len(valuesMap) == 0 {
return &apiextensionsv1.JSON{Raw: []byte("{}")}, nil
}
cleanedJSON, err := json.Marshal(valuesMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal cleaned values: %w", err)
}
return &apiextensionsv1.JSON{Raw: cleanedJSON}, nil
}
// removeUnderscoreFieldsRecursive recursively removes all fields starting with "_" from a map
func removeUnderscoreFieldsRecursive(m map[string]interface{}) {
if m == nil {
return
}
// Collect keys to delete (we can't delete while iterating)
keysToDelete := make([]string, 0)
for k, v := range m {
if strings.HasPrefix(k, "_") {
keysToDelete = append(keysToDelete, k)
} else if nestedMap, ok := v.(map[string]interface{}); ok {
// Recursively process nested maps
removeUnderscoreFieldsRecursive(nestedMap)
} else if nestedArray, ok := v.([]interface{}); ok {
// Process arrays that might contain maps
for _, item := range nestedArray {
if itemMap, ok := item.(map[string]interface{}); ok {
removeUnderscoreFieldsRecursive(itemMap)
}
}
}
}
// Delete collected keys
for _, k := range keysToDelete {
delete(m, k)
}
}
// RemoveUnderscoreFieldsFromMap recursively removes all fields starting with "_" from a map.
// This is a variant that works directly with map[string]any (used in defaulting).
func RemoveUnderscoreFieldsFromMap(m map[string]any) {
if m == nil {
return
}
// Collect keys to delete (we can't delete while iterating)
keysToDelete := make([]string, 0)
for k, v := range m {
if strings.HasPrefix(k, "_") {
keysToDelete = append(keysToDelete, k)
} else if nestedMap, ok := v.(map[string]any); ok {
// Recursively process nested maps
RemoveUnderscoreFieldsFromMap(nestedMap)
} else if nestedArray, ok := v.([]any); ok {
// Process arrays that might contain maps
for _, item := range nestedArray {
if itemMap, ok := item.(map[string]any); ok {
RemoveUnderscoreFieldsFromMap(itemMap)
}
}
}
}
// Delete collected keys
for _, k := range keysToDelete {
delete(m, k)
}
}
// CheckUnderscoreFields checks if any field starting with "_" exists in user values and returns an error if it does.
// This prevents users from setting internal fields.
func CheckUnderscoreFields(values *apiextensionsv1.JSON) error {
if values == nil || len(values.Raw) == 0 {
return nil
}
var valuesMap map[string]interface{}
if err := json.Unmarshal(values.Raw, &valuesMap); err != nil {
return fmt.Errorf("failed to unmarshal values: %w", err)
}
if hasUnderscoreFields(valuesMap) {
return fmt.Errorf("fields starting with '_' are not allowed in user values")
}
return nil
}
// hasUnderscoreFields recursively checks if any field starting with "_" exists in a map
func hasUnderscoreFields(m map[string]interface{}) bool {
if m == nil {
return false
}
for k, v := range m {
if strings.HasPrefix(k, "_") {
return true
}
if nestedMap, ok := v.(map[string]interface{}); ok {
if hasUnderscoreFields(nestedMap) {
return true
}
} else if nestedArray, ok := v.([]interface{}); ok {
for _, item := range nestedArray {
if itemMap, ok := item.(map[string]interface{}); ok {
if hasUnderscoreFields(itemMap) {
return true
}
}
}
}
}
return false
}

View file

@ -44,6 +44,7 @@ import (
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
"github.com/cozystack/cozystack/pkg/config"
"github.com/cozystack/cozystack/pkg/cozylib"
internalapiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@ -1052,182 +1053,6 @@ func (r *REST) getCozystackResourceDefinition(ctx context.Context) (*cozyv1alpha
return nil, fmt.Errorf("CozystackResourceDefinition not found for kind %s", r.kindName)
}
// extractNamespaceLabels extracts namespace.cozystack.io/* labels from namespace and converts them to cozystack.namespace values
func extractNamespaceLabels(ns *corev1.Namespace) map[string]interface{} {
namespaceValues := make(map[string]interface{})
prefix := "namespace.cozystack.io/"
if ns.Labels == nil {
return namespaceValues
}
for key, value := range ns.Labels {
if strings.HasPrefix(key, prefix) {
// Remove prefix and add to namespace values
namespaceKey := strings.TrimPrefix(key, prefix)
namespaceValues[namespaceKey] = value
}
}
return namespaceValues
}
// mergeValues merges two JSON values, with userValues taking precedence
func mergeValues(defaultValues, userValues *apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) {
var defaultMap, userMap map[string]interface{}
if defaultValues != nil && len(defaultValues.Raw) > 0 {
if err := json.Unmarshal(defaultValues.Raw, &defaultMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal default values: %w", err)
}
} else {
defaultMap = make(map[string]interface{})
}
if userValues != nil && len(userValues.Raw) > 0 {
if err := json.Unmarshal(userValues.Raw, &userMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal user values: %w", err)
}
} else {
userMap = make(map[string]interface{})
}
// Deep merge: defaultValues first, then userValues (userValues override)
merged := deepMergeMaps(defaultMap, userMap)
mergedJSON, err := json.Marshal(merged)
if err != nil {
return nil, fmt.Errorf("failed to marshal merged values: %w", err)
}
return &apiextensionsv1.JSON{Raw: mergedJSON}, nil
}
// deepMergeMaps performs a deep merge of two maps
func deepMergeMaps(base, override map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Copy base map
for k, v := range base {
result[k] = v
}
// Merge override map
for k, v := range override {
if baseVal, exists := result[k]; exists {
// If both are maps, recursively merge
if baseMap, ok := baseVal.(map[string]interface{}); ok {
if overrideMap, ok := v.(map[string]interface{}); ok {
result[k] = deepMergeMaps(baseMap, overrideMap)
continue
}
}
}
// Override takes precedence
result[k] = v
}
return result
}
// removeUnderscoreFields recursively removes all fields starting with "_" from values
func removeUnderscoreFields(values *apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) {
if values == nil || len(values.Raw) == 0 {
return values, nil
}
var valuesMap map[string]interface{}
if err := json.Unmarshal(values.Raw, &valuesMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
}
// Recursively remove all fields starting with "_"
removeUnderscoreFieldsRecursive(valuesMap)
// If map is empty, return empty JSON object instead of nil to ensure proper serialization
if len(valuesMap) == 0 {
return &apiextensionsv1.JSON{Raw: []byte("{}")}, nil
}
cleanedJSON, err := json.Marshal(valuesMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal cleaned values: %w", err)
}
return &apiextensionsv1.JSON{Raw: cleanedJSON}, nil
}
// removeUnderscoreFieldsRecursive recursively removes all fields starting with "_" from a map
func removeUnderscoreFieldsRecursive(m map[string]interface{}) {
if m == nil {
return
}
// Collect keys to delete (we can't delete while iterating)
keysToDelete := make([]string, 0)
for k, v := range m {
if strings.HasPrefix(k, "_") {
keysToDelete = append(keysToDelete, k)
} else if nestedMap, ok := v.(map[string]interface{}); ok {
// Recursively process nested maps
removeUnderscoreFieldsRecursive(nestedMap)
} else if nestedArray, ok := v.([]interface{}); ok {
// Process arrays that might contain maps
for _, item := range nestedArray {
if itemMap, ok := item.(map[string]interface{}); ok {
removeUnderscoreFieldsRecursive(itemMap)
}
}
}
}
// Delete collected keys
for _, k := range keysToDelete {
delete(m, k)
}
}
// checkUnderscoreFields checks if any field starting with "_" exists in user values and returns an error if it does
func checkUnderscoreFields(values *apiextensionsv1.JSON) error {
if values == nil || len(values.Raw) == 0 {
return nil
}
var valuesMap map[string]interface{}
if err := json.Unmarshal(values.Raw, &valuesMap); err != nil {
return fmt.Errorf("failed to unmarshal values: %w", err)
}
// Check for any field starting with "_"
if found := findUnderscoreFields(valuesMap); found != "" {
return fmt.Errorf("field %s is not allowed in user-specified values (fields starting with '_' are reserved)", found)
}
return nil
}
// findUnderscoreFields recursively finds the first field starting with "_" and returns its key path
func findUnderscoreFields(m map[string]interface{}) string {
for k, v := range m {
if strings.HasPrefix(k, "_") {
return k
}
if nestedMap, ok := v.(map[string]interface{}); ok {
if found := findUnderscoreFields(nestedMap); found != "" {
return k + "." + found
}
} else if nestedArray, ok := v.([]interface{}); ok {
for i, item := range nestedArray {
if itemMap, ok := item.(map[string]interface{}); ok {
if found := findUnderscoreFields(itemMap); found != "" {
return fmt.Sprintf("%s[%d].%s", k, i, found)
}
}
}
}
}
return ""
}
// ConvertHelmReleaseToApplication converts a HelmRelease to an Application
func (r *REST) ConvertHelmReleaseToApplication(hr *helmv2.HelmRelease) (appsv1alpha1.Application, error) {
klog.V(6).Infof("Converting HelmRelease to Application for resource %s", hr.GetName())
@ -1246,7 +1071,7 @@ func (r *REST) ConvertHelmReleaseToApplication(hr *helmv2.HelmRelease) (appsv1al
// Remove all fields starting with "_" after applying defaults to ensure they're not shown to user
// This must be done after applySpecDefaults because defaults might add them back
if app.Spec != nil && len(app.Spec.Raw) > 0 {
cleanedValues, err := removeUnderscoreFields(app.Spec)
cleanedValues, err := cozylib.RemoveUnderscoreFields(app.Spec)
if err != nil {
return app, fmt.Errorf("failed to remove underscore fields from values: %w", err)
}
@ -1288,7 +1113,7 @@ func (r *REST) ConvertApplicationToHelmRelease(app *appsv1alpha1.Application) (*
// convertHelmReleaseToApplication implements the actual conversion logic
func (r *REST) convertHelmReleaseToApplication(hr *helmv2.HelmRelease) (appsv1alpha1.Application, error) {
// Remove all fields starting with "_" from values before setting spec to ensure they never appear in spec
cleanedValues, err := removeUnderscoreFields(hr.Spec.Values)
cleanedValues, err := cozylib.RemoveUnderscoreFields(hr.Spec.Values)
if err != nil {
// If removal fails, use original values (shouldn't happen, but be safe)
cleanedValues = hr.Spec.Values
@ -1342,7 +1167,7 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (*
ctx := context.Background()
// Check if user specified any field starting with "_" in values
if err := checkUnderscoreFields(app.Spec); err != nil {
if err := cozylib.CheckUnderscoreFields(app.Spec); err != nil {
return nil, err
}
@ -1357,7 +1182,7 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (*
// Start with default values from CRD (if any)
var mergedValues *apiextensionsv1.JSON
if crd != nil && crd.Spec.Release.Values != nil {
mergedValues, err = mergeValues(crd.Spec.Release.Values, app.Spec)
mergedValues, err = cozylib.MergeValues(crd.Spec.Release.Values, app.Spec)
if err != nil {
return nil, fmt.Errorf("failed to merge default values with user values: %w", err)
}
@ -1374,35 +1199,12 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (*
namespace = nil
}
// Extract namespace labels and add to namespace (top-level)
// Extract namespace annotations and add to _namespace (top-level)
if namespace != nil {
namespaceLabels := extractNamespaceLabels(namespace)
if len(namespaceLabels) > 0 {
// Parse merged values to add namespace labels
var valuesMap map[string]interface{}
if mergedValues != nil && len(mergedValues.Raw) > 0 {
if err := json.Unmarshal(mergedValues.Raw, &valuesMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal merged values: %w", err)
}
} else {
valuesMap = make(map[string]interface{})
}
// Convert namespaceLabels to map[string]interface{}
namespaceLabelsMap := make(map[string]interface{})
for k, v := range namespaceLabels {
namespaceLabelsMap[k] = v
}
// Namespace labels completely overwrite existing _namespace field (top-level)
valuesMap["_namespace"] = namespaceLabelsMap
// Marshal back to JSON
mergedJSON, err := json.Marshal(valuesMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal values with namespace labels: %w", err)
}
mergedValues = &apiextensionsv1.JSON{Raw: mergedJSON}
var err error
mergedValues, err = cozylib.InjectNamespaceAnnotationsIntoValues(mergedValues, namespace)
if err != nil {
return nil, fmt.Errorf("failed to inject namespace annotations: %w", err)
}
}

View file

@ -24,6 +24,8 @@ import (
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
"github.com/cozystack/cozystack/pkg/cozylib"
)
// applySpecDefaults applies default values to the Application spec based on the schema
@ -41,7 +43,7 @@ func (r *REST) applySpecDefaults(app *appsv1alpha1.Application) error {
m = map[string]any{}
}
// Remove all fields starting with "_" BEFORE applying defaults to prevent them from being processed
removeUnderscoreFieldsFromMap(m)
cozylib.RemoveUnderscoreFieldsFromMap(m)
if err := defaultLikeKubernetes(&m, r.specSchema); err != nil {
return err
@ -49,7 +51,7 @@ func (r *REST) applySpecDefaults(app *appsv1alpha1.Application) error {
// Remove all fields starting with "_" AFTER applying defaults to ensure they're never in the output
// This is a safety measure in case defaults added them back
removeUnderscoreFieldsFromMap(m)
cozylib.RemoveUnderscoreFieldsFromMap(m)
// Always return at least an empty JSON object, never nil
if len(m) == 0 {
@ -65,34 +67,6 @@ func (r *REST) applySpecDefaults(app *appsv1alpha1.Application) error {
return nil
}
// removeUnderscoreFieldsFromMap recursively removes all fields starting with "_" from a map
func removeUnderscoreFieldsFromMap(m map[string]any) {
if m == nil {
return
}
// Collect keys to delete (we can't delete while iterating)
keysToDelete := make([]string, 0)
for k, v := range m {
if strings.HasPrefix(k, "_") {
keysToDelete = append(keysToDelete, k)
} else if nestedMap, ok := v.(map[string]any); ok {
// Recursively process nested maps
removeUnderscoreFieldsFromMap(nestedMap)
} else if nestedArray, ok := v.([]any); ok {
// Process arrays that might contain maps
for _, item := range nestedArray {
if itemMap, ok := item.(map[string]any); ok {
removeUnderscoreFieldsFromMap(itemMap)
}
}
}
}
// Delete collected keys
for _, k := range keysToDelete {
delete(m, k)
}
}
func defaultLikeKubernetes(root *map[string]any, s *structuralschema.Structural) error {
v := any(*root)