From e046206d2b297866ce3b4aeb06da270b4cf4bebb Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Tue, 25 Nov 2025 16:24:08 +0100 Subject: [PATCH] refactor a bit Signed-off-by: Andrei Kvapil --- .../cozystackresource_controller.go | 61 +--- .../controller/namespace_helm_reconciler.go | 26 +- packages/core/installer/values.yaml | 2 +- packages/system/cozystack-api/values.yaml | 2 +- .../system/cozystack-controller/values.yaml | 2 +- pkg/cozylib/namespace.go | 96 +++++++ pkg/cozylib/values.go | 266 ++++++++++++++++++ pkg/registry/apps/application/rest.go | 218 +------------- .../apps/application/rest_defaulting.go | 34 +-- 9 files changed, 394 insertions(+), 313 deletions(-) create mode 100644 pkg/cozylib/namespace.go create mode 100644 pkg/cozylib/values.go diff --git a/internal/controller/cozystackresource_controller.go b/internal/controller/cozystackresource_controller.go index 86c83270..f7013c87 100644 --- a/internal/controller/cozystackresource_controller.go +++ b/internal/controller/cozystackresource_controller.go @@ -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 -} diff --git a/internal/controller/namespace_helm_reconciler.go b/internal/controller/namespace_helm_reconciler.go index 8b30ecff..57719ddd 100644 --- a/internal/controller/namespace_helm_reconciler.go +++ b/internal/controller/namespace_helm_reconciler.go @@ -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 { diff --git a/packages/core/installer/values.yaml b/packages/core/installer/values.yaml index 53b51940..45585e76 100644 --- a/packages/core/installer/values.yaml +++ b/packages/core/installer/values.yaml @@ -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 diff --git a/packages/system/cozystack-api/values.yaml b/packages/system/cozystack-api/values.yaml index f5bbe1e8..8ffc37f5 100644 --- a/packages/system/cozystack-api/values.yaml +++ b/packages/system/cozystack-api/values.yaml @@ -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 diff --git a/packages/system/cozystack-controller/values.yaml b/packages/system/cozystack-controller/values.yaml index c9044c04..f6d5cdbd 100644 --- a/packages/system/cozystack-controller/values.yaml +++ b/packages/system/cozystack-controller/values.yaml @@ -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" diff --git a/pkg/cozylib/namespace.go b/pkg/cozylib/namespace.go new file mode 100644 index 00000000..ea87f535 --- /dev/null +++ b/pkg/cozylib/namespace.go @@ -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 +} diff --git a/pkg/cozylib/values.go b/pkg/cozylib/values.go new file mode 100644 index 00000000..29a47d1e --- /dev/null +++ b/pkg/cozylib/values.go @@ -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 +} diff --git a/pkg/registry/apps/application/rest.go b/pkg/registry/apps/application/rest.go index 5ed64566..0081265d 100644 --- a/pkg/registry/apps/application/rest.go +++ b/pkg/registry/apps/application/rest.go @@ -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) } } diff --git a/pkg/registry/apps/application/rest_defaulting.go b/pkg/registry/apps/application/rest_defaulting.go index 82e48ab7..dc0b31c0 100644 --- a/pkg/registry/apps/application/rest_defaulting.go +++ b/pkg/registry/apps/application/rest_defaulting.go @@ -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)