Repository: rancher/fleet
Pull request #3542 (2025-04-10 15:40:48)

[v0.12] Fix resource.PerClusterState incompatibility
Refers to #3535

Deserializing existing data with the previous format (an array instead of a map) will make the strict YAML (actually JSON) deserializer in Go to complain about data being incorrect. Adding a custom `UnmarshalJSON` function for this field should allow to process that existing data correctly, ignoring the offending field, which should be populated on the following resource reconciliation.



Patch:
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/resource_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/resource_types.go
index 6b1b92d6..baa0b5c7 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/resource_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/resource_types.go
@@ -1,5 +1,10 @@
 package v1alpha1
 
+import (
+	"bytes"
+	"encoding/json"
+)
+
 // Resource contains metadata about the resources of a bundle.
 type Resource struct {
 	// APIVersion is the API version of the resource.
@@ -57,6 +62,24 @@ type PerClusterState struct {
 	NotReady []string `json:"notReady,omitempty"`
 }
 
+type perClusterStateAlias PerClusterState
+
+// UnmarshalJSON implements the json.Unmarshaler interface, to which the yaml decoders delegate to
+// PerClusterState used to be a slice instead of a struct, which breaks backwards compatibility with existing data during upgrade
+// Simply ignoring non-object inputs solves the problem, since the following reconciliations will update the resources to the correct payload
+// To be removed for Fleet >= 0.13
+func (in *PerClusterState) UnmarshalJSON(data []byte) error {
+	if !bytes.HasPrefix(data, []byte("{")) {
+		return nil
+	}
+	var aux perClusterStateAlias
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	*in = PerClusterState(aux)
+	return nil
+}
+
 // ResourceCounts contains the number of resources in each state.
 type ResourceCounts struct {
 	// Ready is the number of ready resources.
diff --git a/pkg/git/fetch.go b/pkg/git/fetch.go
index 6273618b..60ac185e 100644
--- a/pkg/git/fetch.go
+++ b/pkg/git/fetch.go
@@ -70,7 +70,7 @@ func (f *Fetch) LatestCommit(ctx context.Context, gitrepo *v1alpha1.GitRepo, cli
 		knownHosts = kh
 	}
 
-	if f.KnownHosts != nil && !f.KnownHosts.IsStrict() {
+	if secret.Data != nil && f.KnownHosts != nil && !f.KnownHosts.IsStrict() {
 		// This prevents errors about keys being mismatch or not found when host key checks are disabled.
 		secret.Data["known_hosts"] = nil
 	}


Test patch:
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/resource_types_test.go b/pkg/apis/fleet.cattle.io/v1alpha1/resource_types_test.go
new file mode 100644
index 00000000..713a5009
--- /dev/null
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/resource_types_test.go
@@ -0,0 +1,49 @@
+package v1alpha1
+
+import (
+	"bytes"
+	"encoding/json"
+	"reflect"
+	"strings"
+	"testing"
+
+	"k8s.io/apimachinery/pkg/util/yaml"
+)
+
+const legacyResource = `
+apiVersion: perfmodel.pea.tppm.tetrapak.com/v1alpha1
+id: test
+kind: ConfigMap
+message: Modified
+name: test
+perClusterState:
+  - clusterId: fleet-default/c-12345
+    state: Modified
+`
+
+func TestDeserializeResourceLegacyFormat(t *testing.T) {
+	var res Resource
+	if err := yaml.NewYAMLToJSONDecoder(strings.NewReader(legacyResource)).Decode(&res); err != nil {
+		t.Fatal(err)
+	}
+	if got, want := res.Kind, "ConfigMap"; got != want {
+		t.Errorf("resource.kind = %v, wanted %v", got, want)
+	}
+	var emptyPerClusterState PerClusterState
+	if got, want := res.PerClusterState, emptyPerClusterState; !reflect.DeepEqual(got, want) {
+		t.Errorf("resource.perClusterState = %v, wanted %v", got, want)
+	}
+
+	// Modify the offending field and make sure it gets serialized/deserialized correctly
+	res.PerClusterState.Ready = []string{"fleet-default/c-12345"}
+	var buf bytes.Buffer
+	if err := json.NewEncoder(&buf).Encode(&res); err != nil {
+		t.Fatalf("failed to serialize modified resource: %v", err)
+	}
+	if err := yaml.NewYAMLToJSONDecoder(&buf).Decode(&res); err != nil {
+		t.Fatal(err)
+	}
+	if got, want := len(res.PerClusterState.Ready), 1; got != want {
+		t.Errorf("len(resource.perClusterState.Ready) = %v, wanted %v", got, want)
+	}
+}
diff --git a/pkg/git/fetch_test.go b/pkg/git/fetch_test.go
index 882bf015..a667a61a 100644
--- a/pkg/git/fetch_test.go
+++ b/pkg/git/fetch_test.go
@@ -164,6 +164,32 @@ var _ = Describe("git fetch's LatestCommit tests", func() {
 		Expect(commit).To(Equal("2ada7cca738877df8459b3a34839a15e5683edaa"))
 	})
 
+	It("returns the commit for the expected branch with no secret", func() {
+		gr := &fleetv1.GitRepo{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "test-gitrepo",
+				Namespace: "test-ns",
+			},
+			Spec: fleetv1.GitRepoSpec{
+				ClientSecretName: "test-secret",
+				Repo:             fakeGithub.URL,
+				Branch:           "master",
+			},
+			Status: fleetv1.GitRepoStatus{
+				Commit: "",
+			},
+		}
+		c := newTestClient()
+		f := git.Fetch{
+			KnownHosts: mockKnownHostsGetter{
+				data: "foo",
+			},
+		}
+		commit, err := f.LatestCommit(context.Background(), gr, c)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(commit).To(Equal("2ada7cca738877df8459b3a34839a15e5683edaa"))
+	})
+
 	It("returns an error when secret's type is not expected", func() {
 		secret := &corev1.Secret{
 			ObjectMeta: metav1.ObjectMeta{