Repository: rancher/fleet

Issue:
Fleet 0.12 changes GitRepo.Resource.PerClusterState From List To Map
> We had a similar problem.
> 
> In our case, the gitjob container also didn't start (or crashloop).
> The logs hinted us in the Direction, that some Gitrepo resources were faulty after the update.
> 
> In the Status of some there were 
> ```
> perClusterState: 
> - {}
> ```
> 
> I've changed them all to 
> ```
> perClusterState: {}
> ```
> 
> important: to update the status, you need to use the --subresource=status flag.
> 
> After that, everything worked fine again for us.
> Might be a completely other Problem at your end, but maybe that helps. 

 _Originally posted by @BiggA94 in [#3501](https://github.com/rancher/fleet/issues/3501#issuecomment-2775801039)_

Hints from issue comments:
@aruiz14: This could be tested together with https://github.com/rancher/fleet/issues/3548, starting from 0.11, create some GitRepo resources with some non-ready resources, upgrading to 0.12, then back to 0.11.@aruiz14: # QA Template

## Solution
Use custom deserializer to allow ignoring incompatible data formats.

## Testing
### Case 1
 - Run Fleet 0.11.
 - Apply a `GitRepo` with some sample data. E.g. `https://github.com/rancher/fleet-test-data`, with path `simple`.
 - Wait for Fleet to deploy it, then modify or delete any of the deployed resources.
 - Observe how the GItRepo status is Modified/Missing, and includes `perClusterState`:
```
perClusterState:
- clusterId:
   ...
```
- Upgrade Fleet to 0.12 including this patch.
- The gitjob controller should run correctly and show no warnings about this payload.
- The GitRepo status yaml is updated to either omit this field, or include a new value (using a map instead of an array):
```
perClusterState:
  ready:
  - fleet-default/downstream-0-0
```@sbulage: ## Steps Performed
- I have followed steps from: https://github.com/rancher/fleet/issues/3535#issuecomment-2804665522
- Thanks @aruiz14 for providin above steps.
- See below video for more details.

### Video of steps performed

<details><summary>Upgrade Rancher from 2.10.5-alpha3 to 2.11.1-alpha2</summary>

https://github.com/user-attachments/assets/0a6442d0-55d5-4dbc-b270-8f7e6b749b40
</details> 

_**Note: Please play video in 1.5x speed.**_

### Before Upgrade

| Rancher Version | Fleet Version |
|--------|--------|
| v2.10.5-alpha3 | 105.0.6+up0.11.6-rc.2 | 

<details><summary>Screenshots of GitRepo YAML missing resource on 2.10.5-alpha3 for perClusterState</summary>

![Image](https://github.com/user-attachments/assets/2e19a896-fbb4-4b28-b61d-106d53e75626)

</details> 

### After Upgrade

| Rancher Version | Fleet Version |
|--------|--------|
| v2.11.1-alpha2 | 106.0.1+up0.12.1-rc.1 | 


<details><summary>Screenshots of GitRepo YAML missing resource on 2.11.1-alpha2 for perClusterState</summary>

![Image](https://github.com/user-attachments/assets/4b547680-3e6a-4ff1-b42d-f159b9a8d7ab)

</details> 

Solution 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{