Skip to content

[cozystack-api] Implement Kubernetes-like defaulting - #1432

Merged
Andrei Kvapil (kvaps) merged 1 commit into
mainfrom
api-defauling
Sep 18, 2025
Merged

Andrei Kvapil (kvaps) merged 1 commit into
mainfrom
api-defauling

Conversation

@kvaps

@kvaps Andrei Kvapil (kvaps) commented Sep 17, 2025 •

Copy link
Copy Markdown
Member

Signed-off-by: Andrei Kvapil [email protected]

What this PR does

Release note

[]

Summary by CodeRabbit

  • New Features

    • Application specs now get recursive, Kubernetes-like defaulting: missing fields in nested objects and arrays are auto-populated safely without mutating shared defaults.
    • No changes to public APIs; existing manifests remain compatible while gaining broader defaulting.
  • Tests

    • Added unit tests validating defaulting behavior, per-item defaults, and non-creation of absent keys.

@coderabbitai

coderabbitai Bot commented Sep 17, 2025 •

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Replaced OpenAPI v1 -> internal conversion path for schema handling and removed schemadefault usage; added a Kubernetes-style recursive defaulting implementation (deep-clone helpers and applySpecDefaults) and accompanying tests to apply Structural schema defaults to Application.Spec without mutating schema defaults.

Changes

Cohort / File(s) Summary
REST schema parsing & wiring
pkg/registry/apps/application/rest.go
Removed direct internal JSONSchemaProps unmarshal and schemadefault wiring; added v1 -> internal JSONSchemaProps conversion via a runtime Scheme and converted path to build Structural; removed previous Default-based mutation calls.
Defaulting implementation
pkg/registry/apps/application/rest_defaulting.go
Added Kubernetes-style defaulting: applySpecDefaults, defaultLikeKubernetes, applyDefaults, and deep-clone helpers (clone, cloneMap, cloneSlice) to recursively apply Structural schema defaults (objects, arrays, AdditionalProperties) and rewrite Application.Spec.Raw.
Tests for defaulting
pkg/registry/apps/application/rest_defaulting_test.go
Added Ginkgo/Gomega tests validating defaulting behavior against a constructed apischema.Structural schema, covering per-field defaults, arrays, AdditionalProperties, and non-creation of absent parent keys.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client
  participant REST as REST Handler
  participant Schema as Schema Loader (v1 -> internal)
  participant Default as defaultLikeKubernetes
  participant Clone as clone helpers

  Client->>REST: Create/Update Application (with Spec.Raw)
  REST->>Schema: Load OpenAPI v1 schema -> convert to internal JSONSchemaProps
  Schema-->>REST: Structural schema
  REST->>Default: applySpecDefaults(spec.Raw, Structural)
  Default->>Clone: deep-clone default values when applying
  Default->>Default: recurse applyDefaults over objects/arrays/AdditionalProperties
  Default-->>REST: Spec.Raw updated (JSON) or error
  REST-->>Client: Response (created/updated or error)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

In my warren of structs I quietly tread,
I copy small defaults and tuck them in bed.
Arrays nudge in line, objects get care,
Cloned carrots placed gently — nothing to share.
Hop! The spec sleeps sound with defaults everywhere.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "[cozystack-api] Implement Kubernetes-like defaulting" is concise and directly describes the primary change in the diff (adding Kubernetes-style defaulting logic and tests), so it accurately summarizes the main intent and is clear to reviewers scanning history.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch api-defauling

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary of Changes

Hello Andrei Kvapil (@kvaps), I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a custom defaulting mechanism for application specifications within the cozystack-api. The primary goal is to implement Kubernetes-like defaulting logic without directly relying on the Kubernetes API extensions server's internal defaulting package. This change provides more control over how default values are applied, ensuring consistency and robustness for application configurations.

Highlights

  • Custom Defaulting Implementation: The pull request replaces the direct usage of Kubernetes' internal schemadefault.Default function with a custom, in-house implementation for applying default values to application specifications.
  • New Defaulting Logic: A new set of functions, including defaultLikeKubernetes, applyDefaults, clone, cloneMap, and cloneSlice, have been introduced to replicate Kubernetes-like defaulting behavior, handling objects, arrays, and primitive types recursively.
  • Dependency Reduction: The import of k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting has been removed, indicating a move towards a more self-contained defaulting mechanism within cozystack-api.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request replaces the usage of an internal Kubernetes defaulting package with a custom implementation that mimics Kubernetes' defaulting behavior. The new implementation is mostly correct, but I've found a significant bug in how additionalProperties are handled, which could lead to incorrect behavior. I've also suggested an improvement for robustness in the top-level defaulting function. Please see my detailed comments.

Comment thread pkg/registry/apps/application/rest.go Outdated
Comment thread pkg/registry/apps/application/rest.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
pkg/registry/apps/application/rest.go (4)

1233-1245: Guard root type to avoid silently coercing non-object specs.

If a misconfigured schema sets a non-object root, we currently coerce to {}. Prefer a fast-fail.

Apply this diff:

 func defaultLikeKubernetes(root *map[string]any, s *structuralschema.Structural) error {
-	v := any(*root)
+	if s == nil {
+		return nil
+	}
+	if s.Generic.Type != "object" {
+		return fmt.Errorf("expected spec schema type=object, got %q", s.Generic.Type)
+	}
+	v := any(*root)
 	nv, err := applyDefaults(v, s, true)
 	if err != nil {
 		return err
 	}
 	obj, _ := nv.(map[string]any)
 	if obj == nil {
 		obj = map[string]any{}
 	}
 	*root = obj
 	return nil
 }

1249-1266: Don’t override explicit nulls for objects.

K8s-style defaulting applies to absent fields, not ones explicitly set to null. Today null becomes {} or an object default.

Apply this diff:

 	case "object":
 		mv, isMap := v.(map[string]any)
-		if !isMap || v == nil {
-			if s.Generic.Default.Object != nil && !top {
-				if dm, ok := s.Generic.Default.Object.(map[string]any); ok {
-					mv = cloneMap(dm)
-				}
-			}
-			if mv == nil {
-				mv = map[string]any{}
-			}
-		} else {
+		if v == nil {
+			// honor explicit null (no defaulting)
+			return nil, nil
+		}
+		if !isMap {
+			if s.Generic.Default.Object != nil && !top {
+				if dm, ok := s.Generic.Default.Object.(map[string]any); ok {
+					mv = cloneMap(dm)
+				}
+			}
+			if mv == nil {
+				mv = map[string]any{}
+			}
+		} else {
 			// do not merge object-level default into existing object
 		}

1294-1316: Don’t override explicit nulls for arrays.

Null arrays get defaulted/emptied; that diverges from “absent-only” defaulting.

Apply this diff:

 	case "array":
 		sl, isSlice := v.([]any)
-		if !isSlice || v == nil {
+		if v == nil {
+			// honor explicit null (no defaulting)
+			return nil, nil
+		}
+		if !isSlice {
 			if s.Generic.Default.Object != nil {
 				if ds, ok := s.Generic.Default.Object.([]any); ok {
 					sl = cloneSlice(ds)
 				}
 			}
 			if sl == nil {
 				sl = []any{}
 			}
 		}

1317-1323: Don’t override explicit nulls for scalars.

Present-but-null fields should not be defaulted.

Apply this diff:

 	default:
-		if v == nil && s.Generic.Default.Object != nil {
-			return clone(s.Generic.Default.Object), nil
-		}
-		return v, nil
+		if v == nil {
+			// honor explicit null (no defaulting)
+			return nil, nil
+		}
+		return v, nil
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7118232 and 9828982.

📒 Files selected for processing (1)
  • pkg/registry/apps/application/rest.go (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build
🔇 Additional comments (3)
pkg/registry/apps/application/rest.go (3)

1222-1224: Good switch to schema-driven defaulting; watch for behavior change on GET.

Defaults now error-bubble on Get/Watch/Convert paths. That’s fine; just confirm this is intentional since a bad schema/default will now 404/skip items or fail a single GET.


1282-1292: Map defaults vs x-kubernetes-map-type.

We default AdditionalProperties for all map entries. If schemas use x-kubernetes-map-type=atomic, defaulting those subfields might be undesirable. Verify this matches your intended semantics.


1325-1350: Deep-clone helpers look solid.

Prevents mutating schema defaults; recursion handles nested maps/slices correctly.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
pkg/registry/apps/application/rest_defaulting.go (2)

116-128: Good fix: skip known properties when processing AdditionalProperties

Prevents incorrectly applying AP schema to declared properties. This addresses the earlier review note.


53-68: Root-type assertion added — good defensive check

Returning an error when the root schema unexpectedly resolves to non-object avoids silent data loss. This aligns with a prior review suggestion.

🧹 Nitpick comments (6)
pkg/registry/apps/application/rest.go (2)

92-100: Accept YAML schemas (not just JSON) when unmarshalling OpenAPI

Config schemas are often YAML. Switch to sigs.k8s.io/yaml to handle both YAML and JSON transparently.

@@
-		var v1js apiextv1.JSONSchemaProps
-		if err := json.Unmarshal([]byte(raw), &v1js); err != nil {
+		var v1js apiextv1.JSONSchemaProps
+		if err := yaml.Unmarshal([]byte(raw), &v1js); err != nil {
 			klog.Errorf("Failed to unmarshal v1 OpenAPI schema: %v", err)
 		} else {
 			scheme := runtime.NewScheme()
-			_ = internalapiext.AddToScheme(scheme)
-			_ = apiextv1.AddToScheme(scheme)
+			if err := internalapiext.AddToScheme(scheme); err != nil {
+				klog.Errorf("Failed to register internal apiext scheme: %v", err)
+			}
+			if err := apiextv1.AddToScheme(scheme); err != nil {
+				klog.Errorf("Failed to register v1 apiext scheme: %v", err)
+			}

Add import:

@@
 	"k8s.io/klog/v2"
+	"sigs.k8s.io/yaml"

104-108: Validate Structural schema after construction

Validate to catch subtle schema issues early; keep defaulting predictable.

-			} else {
-				specSchema = s
-			}
+			} else {
+				// Optional: validate structural schema
+				if verrs := structuralschema.ValidateStructural(s); len(verrs) > 0 {
+					klog.Errorf("Structural schema validation errors: %v", verrs)
+				}
+				specSchema = s
+			}

Please confirm the exact signature of ValidateStructural for your k8s dependency version and adjust logging accordingly.

pkg/registry/apps/application/rest_defaulting.go (3)

101-114: Preserve explicit nulls to better match Kubernetes semantics

If a field is present with null, defaults generally should not override it. Today, a present-null scalar gets defaulted via the recursive call. Consider skipping defaulting when the key exists but the value is nil.

-			if cur, ok := mv[name]; ok {
-				cv, err := applyDefaults(cur, &ps, false)
+			if cur, ok := mv[name]; ok {
+				// Preserve explicit nulls: do not apply defaults over a user-provided null
+				if cur == nil {
+					continue
+				}
+				cv, err := applyDefaults(cur, &ps, false)
 				if err != nil {
 					return nil, err
 				}
 				mv[name] = cv
 			}

Please add tests to lock this behavior (e.g., roles: null remains null and does not become ["ingress-nginx"]).


118-127: Also preserve explicit nulls for AdditionalProperties entries

Same reasoning for map entries governed by AdditionalProperties.

-			for k, cur := range mv {
+			for k, cur := range mv {
 				if _, isKnown := s.Properties[k]; isKnown {
 					continue
 				}
+				if cur == nil {
+					continue
+				}
 				cv, err := applyDefaults(cur, ap, false)
 				if err != nil {
 					return nil, err
 				}
 				mv[k] = cv
 			}

28-51: Note on JSON number types after round‑trip

Unmarshalling into map[string]any parses numbers as float64. Re‑marshalling is fine for JSON, but callers must not rely on Go int types when inspecting Spec maps post‑defaulting. If this could surprise downstream code, consider documenting or normalizing numeric types where critical.

pkg/registry/apps/application/rest_defaulting_test.go (1)

46-57: Add tests for explicit null handling and deep-copy safety

To pin intended semantics and guard regressions:

  • Field present with null is not defaulted (e.g., roles: null stays null).
  • AdditionalProperties entry present with null is not defaulted.
  • Defaults are deep‑cloned (mutating the result doesn’t mutate subsequent default applications).

Example additions (append to the suite):

@@
 	It("does not create new map keys from parent object default", func() {
@@
 	})
+
+	It("preserves explicit null values (no default over null)", func() {
+		spec := map[string]any{
+			"nodeGroups": map[string]any{
+				"md0": map[string]any{
+					"minReplicas": 3,
+					"roles":       nil, // explicit null
+				},
+			},
+		}
+		err := defaultLikeKubernetes(&spec, rootSchema)
+		Expect(err).NotTo(HaveOccurred())
+		ng := spec["nodeGroups"].(map[string]any)["md0"].(map[string]any)
+		Expect(ng).To(HaveKeyWithValue("minReplicas", BeNumerically("==", 3)))
+		Expect(ng).To(HaveKey("roles"))
+		Expect(ng["roles"]).To(BeNil())
+	})
+
+	It("does not default AP entry when value is explicit null", func() {
+		spec := map[string]any{
+			"nodeGroups": map[string]any{
+				"mdX": nil,
+			},
+		}
+		err := defaultLikeKubernetes(&spec, rootSchema)
+		Expect(err).NotTo(HaveOccurred())
+		Expect(spec["nodeGroups"].(map[string]any)).To(HaveKeyWithValue("mdX", BeNil()))
+	})
+
+	It("deep-clones defaults (mutations don’t bleed across calls)", func() {
+		spec1 := map[string]any{
+			"nodeGroups": map[string]any{
+				"md0": map[string]any{},
+			},
+		}
+		spec2 := map[string]any{
+			"nodeGroups": map[string]any{
+				"md0": map[string]any{},
+			},
+		}
+		Expect(defaultLikeKubernetes(&spec1, rootSchema)).To(Succeed())
+		ng1 := spec1["nodeGroups"].(map[string]any)["md0"].(map[string]any)
+		ng1["roles"].([]any)[0] = "mutated"
+		Expect(defaultLikeKubernetes(&spec2, rootSchema)).To(Succeed())
+		ng2 := spec2["nodeGroups"].(map[string]any)["md0"].(map[string]any)
+		Expect(ng2["roles"]).To(ConsistOf("ingress-nginx"))
+	})
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9828982 and edb3e92.

📒 Files selected for processing (3)
  • pkg/registry/apps/application/rest.go (1 hunks)
  • pkg/registry/apps/application/rest_defaulting.go (1 hunks)
  • pkg/registry/apps/application/rest_defaulting_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
pkg/registry/apps/application/rest_defaulting.go (1)
pkg/registry/apps/application/rest.go (1)
  • REST (78-86)
pkg/registry/apps/application/rest.go (1)
pkg/apis/apps/v1alpha1/types.go (1)
  • Application (56-64)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build
🔇 Additional comments (1)
pkg/registry/apps/application/rest.go (1)

951-953: Defaulting is applied on read only — confirm product intent

applySpecDefaults runs when converting HelmRelease -> Application. That means defaults are not persisted to HelmRelease.Values on create/update. If “Kubernetes-like defaulting” should behave as admission-time defaulting (persisted), consider invoking defaulting on write paths or confirm that read-time only is intentional for this virtual resource.

@kvaps
Andrei Kvapil (kvaps) merged commit 9d2fe26 into main Sep 18, 2025
20 checks passed
@kvaps
Andrei Kvapil (kvaps) deleted the api-defauling branch September 18, 2025 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant