Repository: kubernetes-sigs/cluster-api
Pull request #5052 (2021-08-04 19:10:47)

🌱 Reconcile infrastructure cluster for managed topologies
**What this PR does / why we need it**:

This is part of the activities for the implementation of the [ClusterClass proposal](https://github.com/kubernetes-sigs/cluster-api/blob/master/docs/proposals/202105256-cluster-class-and-managed-topologies.md). 

This PR is about implementing reconcile current and desired state for the infrastructureCluster object in a managed topology.

NOTE: this PR will wait for https://github.com/kubernetes-sigs/cluster-api/issues/5038 to be implemented first

**Which issue(s) this PR fixes**:
Fixes https://github.com/kubernetes-sigs/cluster-api/issues/5036


k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please ask for approval from **fabriziopandini** after the PR has been reviewed.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcluster-api).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/cluster-api/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["fabriziopandini"]} -->
k8s-ci-robot: @fabriziopandini: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-cluster-api-test-main | 0eed4cab2d1f4d9663ad04e95c380d0c00aab71c | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_cluster-api/5052/pull-cluster-api-test-main/1422998473388593152) | `/test pull-cluster-api-test-main`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=cluster-api&pr=5052). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Afabriziopandini). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/cluster-api/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
fabriziopandini: /retest
sbueringer: Looks great at a first glance!
sbueringer: @fabriziopandini Does this PR already fulfil: `NOTE: The func must provide logs at different level to allow debug`? 

Just asking because it would be probably really good to get consensus on one PR and then be able to just do the same in reconcile ControlPlane and MachineDeployments. (Instead of implementing three different logging variants and then making them consistent again)

/cc @killianmuldoon 
k8s-ci-robot: @sbueringer: GitHub didn't allow me to request PR reviews from the following users: killianmuldoon.

Note that only [kubernetes-sigs members](https://github.com/orgs/kubernetes-sigs/people) and repo collaborators can review this PR, and authors cannot review their own PRs.

<details>

In response to [this](https://github.com/kubernetes-sigs/cluster-api/pull/5052#issuecomment-895275239):

>@fabriziopandini Does this PR already fulfil: `NOTE: The func must provide logs at different level to allow debug`? 
>
>Just asking because it would be probably really good to get consensus on one PR and then be able to just do the same in reconcile ControlPlane and MachineDeployments. (Instead of implementing three different logging variants and then making them consistent again)
>
>/cc @killianmuldoon 


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
k8s-ci-robot: @fabriziopandini: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
fabriziopandini: /test pull-cluster-api-test-main
k8s-ci-robot: @fabriziopandini: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-cluster-api-test-main | 0887a1d8b6e99dfca29ddede4d449c0bf7a71335 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_cluster-api/5052/pull-cluster-api-test-main/1425386359501099008) | `/test pull-cluster-api-test-main`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=cluster-api&pr=5052). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Afabriziopandini). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/cluster-api/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->

Patch:
diff --git a/Makefile b/Makefile
index d9322f695..8747427ed 100644
--- a/Makefile
+++ b/Makefile
@@ -39,7 +39,7 @@ export GO111MODULE=on
 #
 # Kubebuilder.
 #
-export KUBEBUILDER_ENVTEST_KUBERNETES_VERSION ?= 1.22.0
+export KUBEBUILDER_ENVTEST_KUBERNETES_VERSION ?= 1.21.2
 export KUBEBUILDER_CONTROLPLANE_START_TIMEOUT ?= 60s
 export KUBEBUILDER_CONTROLPLANE_STOP_TIMEOUT ?= 60s
 
diff --git a/api/v1alpha4/index/cluster.go b/api/v1alpha4/index/cluster.go
deleted file mode 100644
index fcd895300..000000000
--- a/api/v1alpha4/index/cluster.go
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
-Copyright 2021 The Kubernetes 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 index
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/pkg/errors"
-	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
-	ctrl "sigs.k8s.io/controller-runtime"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-)
-
-const (
-	// ClusterClassNameField is used by the Cluster controller to index Clusters by ClusterClass name.
-	ClusterClassNameField = "spec.topology.class"
-)
-
-// ByClusterClassName adds the cluster class name  index to the
-// managers cache.
-func ByClusterClassName(ctx context.Context, mgr ctrl.Manager) error {
-	if err := mgr.GetCache().IndexField(ctx, &clusterv1.Cluster{},
-		ClusterClassNameField,
-		clusterByClassName,
-	); err != nil {
-		return errors.Wrap(err, "error setting index field")
-	}
-	return nil
-}
-
-func clusterByClassName(o client.Object) []string {
-	cluster, ok := o.(*clusterv1.Cluster)
-	if !ok {
-		panic(fmt.Sprintf("Expected Cluster but got a %T", o))
-	}
-	if cluster.Spec.Topology != nil {
-		return []string{cluster.Spec.Topology.Class}
-	}
-	return nil
-}
diff --git a/api/v1alpha4/index/index.go b/api/v1alpha4/index/index.go
index fdbed5663..4a0df4375 100644
--- a/api/v1alpha4/index/index.go
+++ b/api/v1alpha4/index/index.go
@@ -20,7 +20,6 @@ package index
 import (
 	"context"
 
-	"sigs.k8s.io/cluster-api/feature"
 	ctrl "sigs.k8s.io/controller-runtime"
 )
 
@@ -30,15 +29,10 @@ func AddDefaultIndexes(ctx context.Context, mgr ctrl.Manager) error {
 		return err
 	}
 
+	//nolint:revive
 	if err := ByMachineProviderID(ctx, mgr); err != nil {
 		return err
 	}
 
-	if feature.Gates.Enabled(feature.ClusterTopology) {
-		if err := ByClusterClassName(ctx, mgr); err != nil {
-			return err
-		}
-	}
-
 	return nil
 }
diff --git a/cmd/clusterctl/client/repository/client.go b/cmd/clusterctl/client/repository/client.go
index 610518868..e9c2f403e 100644
--- a/cmd/clusterctl/client/repository/client.go
+++ b/cmd/clusterctl/client/repository/client.go
@@ -22,6 +22,7 @@ import (
 	"github.com/pkg/errors"
 	"sigs.k8s.io/cluster-api/cmd/clusterctl/client/config"
 	yaml "sigs.k8s.io/cluster-api/cmd/clusterctl/client/yamlprocessor"
+	"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/test"
 )
 
 // Client is used to interact with provider repositories.
@@ -147,6 +148,8 @@ type Repository interface {
 	GetVersions() ([]string, error)
 }
 
+var _ Repository = &test.FakeRepository{}
+
 // repositoryFactory returns the repository implementation corresponding to the provider URL.
 func repositoryFactory(providerConfig config.Provider, configVariablesClient config.VariablesClient) (Repository, error) {
 	// parse the repository url
diff --git a/cmd/clusterctl/client/repository/repository_github.go b/cmd/clusterctl/client/repository/repository_github.go
index 803712d4e..4e4ce9b23 100644
--- a/cmd/clusterctl/client/repository/repository_github.go
+++ b/cmd/clusterctl/client/repository/repository_github.go
@@ -25,7 +25,12 @@ import (
 	"path/filepath"
 	"strings"
 
+	clusterctlv1 "sigs.k8s.io/cluster-api/cmd/clusterctl/api/v1alpha3"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/serializer"
 	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
+	"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
 
 	"github.com/google/go-github/v33/github"
 	"github.com/pkg/errors"
@@ -173,7 +178,7 @@ func newGitHubRepository(providerConfig config.Provider, configVariablesClient c
 	}
 
 	if defaultVersion == githubLatestReleaseLabel {
-		repo.defaultVersion, err = latestContractRelease(repo, clusterv1.GroupVersion.Version)
+		repo.defaultVersion, err = repo.getLatestContractRelease(clusterv1.GroupVersion.Version)
 		if err != nil {
 			return nil, errors.Wrap(err, "failed to get GitHub latest version")
 		}
@@ -240,6 +245,104 @@ func (g *gitHubRepository) getVersions() ([]string, error) {
 	return versions, nil
 }
 
+// getLatestContractRelease returns the latest patch release for a github repository for the current API contract, according to
+// semantic version order of the release tag name.
+func (g *gitHubRepository) getLatestContractRelease(contract string) (string, error) {
+	latest, err := g.getLatestRelease()
+	if err != nil {
+		return latest, err
+	}
+	// Attempt to check if the latest release satisfies the API Contract
+	// This is a best-effort attempt to find the latest release for an older API contract if it's not the latest Github release.
+	// If an error occurs, we just return the latest release.
+	file, err := g.GetFile(latest, metadataFile)
+	if err != nil {
+		// if we can't get the metadata file from the release, we return latest.
+		return latest, nil // nolint:nilerr
+	}
+	latestMetadata := &clusterctlv1.Metadata{}
+	codecFactory := serializer.NewCodecFactory(scheme.Scheme)
+	if err := runtime.DecodeInto(codecFactory.UniversalDecoder(), file, latestMetadata); err != nil {
+		return latest, nil // nolint:nilerr
+	}
+
+	releaseSeries := latestMetadata.GetReleaseSeriesForContract(contract)
+	if releaseSeries == nil {
+		return latest, nil
+	}
+
+	sv, err := version.ParseSemantic(latest)
+	if err != nil {
+		return latest, nil // nolint:nilerr
+	}
+
+	// If the Major or Minor version of the latest release doesn't match the release series for the current contract,
+	// return the latest patch release of the desired Major/Minor version.
+	if sv.Major() != releaseSeries.Major || sv.Minor() != releaseSeries.Minor {
+		return g.getLatestPatchRelease(&releaseSeries.Major, &releaseSeries.Minor)
+	}
+	return latest, nil
+}
+
+// getLatestRelease returns the latest release for a github repository, according to
+// semantic version order of the release tag name.
+func (g *gitHubRepository) getLatestRelease() (string, error) {
+	return g.getLatestPatchRelease(nil, nil)
+}
+
+// getLatestRelease returns the latest patch release for a given Major and Minor version.
+func (g *gitHubRepository) getLatestPatchRelease(major, minor *uint) (string, error) {
+	versions, err := g.getVersions()
+	if err != nil {
+		return "", g.handleGithubErr(err, "failed to get the list of versions")
+	}
+
+	// Search for the latest release according to semantic version ordering.
+	// Releases with tag name that are not in semver format are ignored.
+	var latestTag string
+	var latestPrereleaseTag string
+
+	var latestReleaseVersion *version.Version
+	var latestPrereleaseVersion *version.Version
+
+	for _, v := range versions {
+		sv, err := version.ParseSemantic(v)
+		if err != nil {
+			// discard releases with tags that are not a valid semantic versions (the user can point explicitly to such releases)
+			continue
+		}
+
+		if (major != nil && sv.Major() != *major) || (minor != nil && sv.Minor() != *minor) {
+			// skip versions that don't match the desired Major.Minor version.
+			continue
+		}
+
+		// track prereleases separately
+		if sv.PreRelease() != "" {
+			if latestPrereleaseVersion == nil || latestPrereleaseVersion.LessThan(sv) {
+				latestPrereleaseTag = v
+				latestPrereleaseVersion = sv
+			}
+			continue
+		}
+
+		if latestReleaseVersion == nil || latestReleaseVersion.LessThan(sv) {
+			latestTag = v
+			latestReleaseVersion = sv
+		}
+	}
+
+	// Fall back to returning latest prereleases if no release has been cut or bail if it's also empty
+	if latestTag == "" {
+		if latestPrereleaseTag == "" {
+			return "", errors.New("failed to find releases tagged with a valid semantic version number")
+		}
+
+		return latestPrereleaseTag, nil
+	}
+	return latestTag, nil
+}
+
 // getReleaseByTag returns the github repository release with a specific tag name.
 func (g *gitHubRepository) getReleaseByTag(tag string) (*github.RepositoryRelease, error) {
 	cacheID := fmt.Sprintf("%s/%s:%s", g.owner, g.repository, tag)
diff --git a/cmd/clusterctl/client/repository/repository_local.go b/cmd/clusterctl/client/repository/repository_local.go
index 6e353fd66..0ec29dede 100644
--- a/cmd/clusterctl/client/repository/repository_local.go
+++ b/cmd/clusterctl/client/repository/repository_local.go
@@ -23,13 +23,21 @@ import (
 	"runtime"
 	"strings"
 
+	apiruntime "k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/serializer"
 	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
+	clusterctlv1 "sigs.k8s.io/cluster-api/cmd/clusterctl/api/v1alpha3"
+	"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
 
 	"github.com/pkg/errors"
 	"k8s.io/apimachinery/pkg/util/version"
 	"sigs.k8s.io/cluster-api/cmd/clusterctl/client/config"
 )
 
+const (
+	latestVersionTag = "latest"
+)
+
 // localRepository provides support for providers located on the local filesystem.
 // As part of the provider object, the URL is expected to contain the absolute
 // path to the components yaml on the local filesystem.
@@ -86,7 +94,7 @@ func (r *localRepository) GetFile(version, fileName string) ([]byte, error) {
 	var err error
 
 	if version == latestVersionTag {
-		version, err = latestRelease(r)
+		version, err = r.getLatestRelease()
 		if err != nil {
 			return nil, errors.Wrapf(err, "failed to get the latest release")
 		}
@@ -190,10 +198,103 @@ func newLocalRepository(providerConfig config.Provider, configVariablesClient co
 	}
 
 	if defaultVersion == latestVersionTag {
-		repo.defaultVersion, err = latestContractRelease(repo, clusterv1.GroupVersion.Version)
+		repo.defaultVersion, err = repo.getLatestContractRelease(clusterv1.GroupVersion.Version)
 		if err != nil {
 			return nil, errors.Wrap(err, "failed to get latest version")
 		}
 	}
 	return repo, nil
 }
+
+// getLatestContractRelease returns the latest patch release for a local repository for the current API contract.
+func (r *localRepository) getLatestContractRelease(contract string) (string, error) {
+	latest, err := r.getLatestRelease()
+	if err != nil {
+		return latest, err
+	}
+	// Attempt to check if the latest release satisfies the API Contract
+	// This is a best-effort attempt to find the latest release for an older API contract if it's not the latest Github release.
+	// If an error occurs, we just return the latest release.
+	file, err := r.GetFile(latest, metadataFile)
+	if err != nil {
+		// if we can't get the metadata file from the release, we return latest.
+		return latest, nil // nolint:nilerr
+	}
+	latestMetadata := &clusterctlv1.Metadata{}
+	codecFactory := serializer.NewCodecFactory(scheme.Scheme)
+	if err := apiruntime.DecodeInto(codecFactory.UniversalDecoder(), file, latestMetadata); err != nil {
+		return latest, nil // nolint:nilerr
+	}
+
+	releaseSeries := latestMetadata.GetReleaseSeriesForContract(contract)
+	if releaseSeries == nil {
+		return latest, nil
+	}
+
+	sv, err := version.ParseSemantic(latest)
+	if err != nil {
+		return latest, nil // nolint:nilerr
+	}
+
+	// If the Major or Minor version of the latest release doesn't match the release series for the current contract,
+	// return the latest patch release of the desired Major/Minor version.
+	if sv.Major() != releaseSeries.Major || sv.Minor() != releaseSeries.Minor {
+		return r.getLatestPatchRelease(&releaseSeries.Major, &releaseSeries.Minor)
+	}
+	return latest, nil
+}
+
+// getLatestRelease returns the latest release for the local repository.
+func (r *localRepository) getLatestRelease() (string, error) {
+	return r.getLatestPatchRelease(nil, nil)
+}
+
+// getLatestPatchRelease returns the latest patch release for a given Major and Minor version.
+func (r *localRepository) getLatestPatchRelease(major, minor *uint) (string, error) {
+	versions, err := r.GetVersions()
+	if err != nil {
+		return "", errors.Wrapf(err, "failed to get local repository versions")
+	}
+
+	var latestTag string
+	var latestPrereleaseTag string
+
+	var latestReleaseVersion *version.Version
+	var latestPrereleaseVersion *version.Version
+
+	for _, v := range versions {
+		sv, err := version.ParseSemantic(v)
+		if err != nil {
+			continue
+		}
+
+		if (major != nil && sv.Major() != *major) || (minor != nil && sv.Minor() != *minor) {
+			// skip versions that don't match the desired Major.Minor version.
+			continue
+		}
+
+		// track prereleases separately
+		if sv.PreRelease() != "" {
+			if latestPrereleaseVersion == nil || latestPrereleaseVersion.LessThan(sv) {
+				latestPrereleaseTag = v
+				latestPrereleaseVersion = sv
+			}
+			continue
+		}
+
+		if latestReleaseVersion == nil || latestReleaseVersion.LessThan(sv) {
+			latestTag = v
+			latestReleaseVersion = sv
+		}
+	}
+
+	// Fall back to returning latest prereleases if no release has been cut or bail if it's also empty
+	if latestTag == "" {
+		if latestPrereleaseTag == "" {
+			return "", errors.New("failed to find releases tagged with a valid semantic version number")
+		}
+
+		return latestPrereleaseTag, nil
+	}
+	return latestTag, nil
+}
diff --git a/cmd/clusterctl/client/repository/repository_memory.go b/cmd/clusterctl/client/repository/repository_memory.go
deleted file mode 100644
index 237ae7255..000000000
--- a/cmd/clusterctl/client/repository/repository_memory.go
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
-Copyright 2021 The Kubernetes 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 repository
-
-import (
-	"fmt"
-
-	"github.com/pkg/errors"
-	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/runtime/serializer"
-	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
-	clusterctlv1 "sigs.k8s.io/cluster-api/cmd/clusterctl/api/v1alpha3"
-	"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
-)
-
-// MemoryRepository contains an instance of the repository data.
-type MemoryRepository struct {
-	defaultVersion string
-	rootPath       string
-	componentsPath string
-	versions       map[string]bool
-	files          map[string][]byte
-}
-
-var _ Repository = &MemoryRepository{}
-
-// NewMemoryRepository returns a new MemoryRepository instance.
-func NewMemoryRepository() *MemoryRepository {
-	return &MemoryRepository{
-		versions: map[string]bool{},
-		files:    map[string][]byte{},
-	}
-}
-
-// DefaultVersion returns the default version for this repository.
-// NOTE: The DefaultVersion is a required info usually derived from the repository configuration,
-// and it is used whenever the users gets files from the repository without providing a specific version.
-func (f *MemoryRepository) DefaultVersion() string {
-	if f.defaultVersion == "" {
-		return latestVersionTag
-	}
-	return f.defaultVersion
-}
-
-// RootPath returns the RootPath for this repository.
-// NOTE: The RootPath is a required info usually derived from the repository configuration,
-// and it is used to map the file path to the internal repository structure.
-func (f *MemoryRepository) RootPath() string {
-	return f.rootPath
-}
-
-// ComponentsPath returns ComponentsPath for this repository
-// NOTE: The ComponentsPath is a required info usually derived from the repository configuration,
-// and it is used to identify the components yaml for the provider.
-func (f *MemoryRepository) ComponentsPath() string {
-	return f.componentsPath
-}
-
-// GetFile returns a file for a given provider version.
-// NOTE: If the provided version is missing, the default version is used.
-func (f *MemoryRepository) GetFile(version string, path string) ([]byte, error) {
-	if version == "" {
-		version = f.DefaultVersion()
-	}
-	if version == latestVersionTag {
-		var err error
-		version, err = latestContractRelease(f, clusterv1.GroupVersion.Version)
-		if err != nil {
-			return nil, err
-		}
-	}
-	if _, ok := f.versions[version]; !ok {
-		return nil, errors.Errorf("unable to get files for version %s", version)
-	}
-
-	for p, c := range f.files {
-		if p == vpath(version, path) {
-			return c, nil
-		}
-	}
-	return nil, errors.Errorf("unable to get file %s for version %s", path, version)
-}
-
-// GetVersions returns the list of versions that are available.
-func (f *MemoryRepository) GetVersions() ([]string, error) {
-	v := make([]string, 0, len(f.versions))
-	for k := range f.versions {
-		v = append(v, k)
-	}
-	return v, nil
-}
-
-// WithPaths allows setting of the rootPath and componentsPath fields.
-func (f *MemoryRepository) WithPaths(rootPath, componentsPath string) *MemoryRepository {
-	f.rootPath = rootPath
-	f.componentsPath = componentsPath
-	return f
-}
-
-// WithVersions allows setting of the available versions.
-// NOTE: When adding a file to the repository for a specific version, a version
-// is automatically generated if missing; this func allows to define versions without any file.
-func (f *MemoryRepository) WithVersions(version ...string) *MemoryRepository {
-	for _, v := range version {
-		f.versions[v] = true
-	}
-	return f
-}
-
-// WithDefaultVersion allows setting of the default version.
-func (f *MemoryRepository) WithDefaultVersion(version string) *MemoryRepository {
-	f.defaultVersion = version
-	return f
-}
-
-// WithFile allows setting of a file for a given version.
-// NOTE:
-// - If the provided version is missing, a new one will be generated automatically.
-// - If the defaultVersion has not been set, it will be initialized with the first version passed in WithFile().
-// - If the version is "latest" or "", nothing will be added.
-func (f *MemoryRepository) WithFile(version, path string, content []byte) *MemoryRepository {
-	if version == latestVersionTag || version == "" {
-		return f
-	}
-
-	f.versions[version] = true
-	f.files[vpath(version, path)] = content
-
-	if f.defaultVersion == "" {
-		f.defaultVersion = version
-	}
-	return f
-}
-
-// WithMetadata allows setting of the metadata.
-func (f *MemoryRepository) WithMetadata(version string, metadata *clusterctlv1.Metadata) *MemoryRepository {
-	codecs := serializer.NewCodecFactory(scheme.Scheme)
-
-	mediaType := "application/yaml"
-	info, match := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), mediaType)
-	if !match {
-		panic("failed to get SerializerInfo for application/yaml")
-	}
-
-	metadata.SetGroupVersionKind(clusterctlv1.GroupVersion.WithKind("Metadata"))
-
-	encoder := codecs.EncoderForVersion(info.Serializer, metadata.GroupVersionKind().GroupVersion())
-	data, err := runtime.Encode(encoder, metadata)
-	if err != nil {
-		panic(err)
-	}
-
-	return f.WithFile(version, "metadata.yaml", data)
-}
-
-func vpath(version string, path string) string {
-	return fmt.Sprintf("%s/%s", version, path)
-}
diff --git a/cmd/clusterctl/client/repository/repository_versions.go b/cmd/clusterctl/client/repository/repository_versions.go
deleted file mode 100644
index 486b0cd45..000000000
--- a/cmd/clusterctl/client/repository/repository_versions.go
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
-Copyright 2021 The Kubernetes 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 repository
-
-import (
-	"github.com/pkg/errors"
-	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/runtime/serializer"
-	"k8s.io/apimachinery/pkg/util/version"
-	clusterctlv1 "sigs.k8s.io/cluster-api/cmd/clusterctl/api/v1alpha3"
-	"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
-)
-
-const (
-	latestVersionTag = "latest"
-)
-
-// latestContractRelease returns the latest patch release for a repository for the current API contract, according to
-// semantic version order of the release tag name.
-func latestContractRelease(repo Repository, contract string) (string, error) {
-	latest, err := latestRelease(repo)
-	if err != nil {
-		return latest, err
-	}
-	// Attempt to check if the latest release satisfies the API Contract
-	// This is a best-effort attempt to find the latest release for an older API contract if it's not the latest release.
-	// If an error occurs, we just return the latest release.
-	file, err := repo.GetFile(latest, metadataFile)
-	if err != nil {
-		// if we can't get the metadata file from the release, we return latest.
-		return latest, nil // nolint:nilerr
-	}
-	latestMetadata := &clusterctlv1.Metadata{}
-	codecFactory := serializer.NewCodecFactory(scheme.Scheme)
-	if err := runtime.DecodeInto(codecFactory.UniversalDecoder(), file, latestMetadata); err != nil {
-		return latest, nil // nolint:nilerr
-	}
-
-	releaseSeries := latestMetadata.GetReleaseSeriesForContract(contract)
-	if releaseSeries == nil {
-		return latest, nil
-	}
-
-	sv, err := version.ParseSemantic(latest)
-	if err != nil {
-		return latest, nil // nolint:nilerr
-	}
-
-	// If the Major or Minor version of the latest release doesn't match the release series for the current contract,
-	// return the latest patch release of the desired Major/Minor version.
-	if sv.Major() != releaseSeries.Major || sv.Minor() != releaseSeries.Minor {
-		return latestPatchRelease(repo, &releaseSeries.Major, &releaseSeries.Minor)
-	}
-	return latest, nil
-}
-
-// latestRelease returns the latest release for a repository, according to
-// semantic version order of the release tag name.
-func latestRelease(repo Repository) (string, error) {
-	return latestPatchRelease(repo, nil, nil)
-}
-
-// latestPatchRelease returns the latest patch release for a given Major and Minor version.
-func latestPatchRelease(repo Repository, major, minor *uint) (string, error) {
-	versions, err := repo.GetVersions()
-	if err != nil {
-		return "", errors.Wrapf(err, "failed to get repository versions")
-	}
-
-	// Search for the latest release according to semantic version ordering.
-	// Releases with tag name that are not in semver format are ignored.
-	var latestTag string
-	var latestPrereleaseTag string
-
-	var latestReleaseVersion *version.Version
-	var latestPrereleaseVersion *version.Version
-
-	for _, v := range versions {
-		sv, err := version.ParseSemantic(v)
-		if err != nil {
-			// discard releases with tags that are not a valid semantic versions (the user can point explicitly to such releases)
-			continue
-		}
-
-		if (major != nil && sv.Major() != *major) || (minor != nil && sv.Minor() != *minor) {
-			// skip versions that don't match the desired Major.Minor version.
-			continue
-		}
-
-		// track prereleases separately
-		if sv.PreRelease() != "" {
-			if latestPrereleaseVersion == nil || latestPrereleaseVersion.LessThan(sv) {
-				latestPrereleaseTag = v
-				latestPrereleaseVersion = sv
-			}
-			continue
-		}
-
-		if latestReleaseVersion == nil || latestReleaseVersion.LessThan(sv) {
-			latestTag = v
-			latestReleaseVersion = sv
-		}
-	}
-
-	// Fall back to returning latest prereleases if no release has been cut or bail if it's also empty
-	if latestTag == "" {
-		if latestPrereleaseTag == "" {
-			return "", errors.New("failed to find releases tagged with a valid semantic version number")
-		}
-
-		return latestPrereleaseTag, nil
-	}
-	return latestTag, nil
-}
diff --git a/controllers/topology/reconcile_state.go b/controllers/topology/reconcile_state.go
index cc1e708bb..b5b4b35d8 100644
--- a/controllers/topology/reconcile_state.go
+++ b/controllers/topology/reconcile_state.go
@@ -16,10 +16,120 @@ limitations under the License.
 
 package topology
 
-import "context"
+import (
+	"context"
+
+	"github.com/pkg/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
 
 // reconcileState reconciles the current and desired state of the managed Cluster topology.
+// NOTE: We are assuming all the required objects are provided as input; also, in case of any error,
+// the entire reconcile operation will fail. This might be improved in the future if support for reconciling
+// subset of a topology will be implemented.
 func (r *ClusterReconciler) reconcileState(ctx context.Context, current, desired *clusterTopologyState) error {
-	// TODO: add reconcile logic
+	// Reconcile desired state of the InfrastructureCluster object.
+	if err := r.reconcileInfrastructureCluster(ctx, current, desired); err != nil {
+		return err
+	}
+
+	// TODO: reconcile control plane
+
+	// Reconcile desired state of the InfrastructureCluster object.
+	if err := r.reconcileCluster(ctx, current, desired); err != nil {
+		return err
+	}
+
+	// TODO: reconcile machine deployments
+
 	return nil
 }
+
+// reconcileInfrastructureCluster reconciles the desired state of the InfrastructureCluster object.
+func (r *ClusterReconciler) reconcileInfrastructureCluster(ctx context.Context, current, desired *clusterTopologyState) error {
+	return r.reconcileReferencedObject(ctx, current.infrastructureCluster, desired.infrastructureCluster)
+}
+
+// reconcileCluster reconciles the desired state of the Cluster object.
+// NOTE: this assumes reconcileInfrastructureCluster and reconcileControlPlane being already completed;
+// most specifically, after a Cluster is created it is assumed that the reference to the InfrastructureCluster /
+// ControlPlane objects should never change (only the content of the objects can change).
+func (r *ClusterReconciler) reconcileCluster(ctx context.Context, current, desired *clusterTopologyState) error {
+	log := ctrl.LoggerFrom(ctx)
+
+	// Check differences between current and desired state, and eventually patch the current object.
+	patchHelper, err := newMergePatchHelper(current.cluster, desired.cluster, r.Client)
+	if err != nil {
+		return errors.Wrap(err, "failed to create patch helper for the Cluster object")
+	}
+	if patchHelper.HasChanges() {
+		log.Info("updating Cluster")
+		if err := patchHelper.Patch(ctx); err != nil {
+			return errors.Wrap(err, "failed to patch the Cluster object")
+		}
+	}
+	return nil
+}
+
+// reconcileReferencedObject reconciles the desired state of the referenced object.
+// NOTE: After a referenced object is created it is assumed that the reference should
+// never change (only the content of the object can eventually change). Thus, we are checking for strict compatibility.
+func (r *ClusterReconciler) reconcileReferencedObject(ctx context.Context, current, desired *unstructured.Unstructured) error {
+	log := ctrl.LoggerFrom(ctx)
+
+	// If there is no current object, create it.
+	if current == nil {
+		log.Info("creating", desired.GroupVersionKind().String(), desired.GetName())
+		if err := r.Client.Create(ctx, desired.DeepCopy()); err != nil {
+			return errors.Wrapf(err, "failed to create the %s object", desired.GetKind())
+		}
+		return nil
+	}
+
+	// Check if the current and desired referenced object are compatible.
+	if err := checkReferencedObjectsAreStrictlyCompatible(current, desired); err != nil {
+		return err
+	}
+
+	// Check differences between current and desired state, and eventually patch the current object.
+	patchHelper, err := newMergePatchHelper(current, desired, r.Client)
+	if err != nil {
+		return errors.Wrapf(err, "failed to create patch helper for the %s object", current.GetKind())
+	}
+	if patchHelper.HasChanges() {
+		log.Info("updating", current.GroupVersionKind().String(), current.GetName())
+		if err := patchHelper.Patch(ctx); err != nil {
+			return errors.Wrapf(err, "failed to patch the %s object", current.GetKind())
+		}
+	}
+	return nil
+}
+
+// checkReferencedObjectsAreCompatible check if two referenced objects are compatible, meaning that
+// they are of the same GroupKind.
+func checkReferencedObjectsAreCompatible(current, desired client.Object) error {
+	currentGK := current.GetObjectKind().GroupVersionKind().GroupKind()
+	desiredGK := desired.GetObjectKind().GroupVersionKind().GroupKind()
+
+	if currentGK.String() != desiredGK.String() {
+		return errors.Errorf("invalid operation: it is not possible to change GroupKind from %s to %s", currentGK.String(), desiredGK.String())
+	}
+
+	// NOTE: this should never happen (webhooks prevent it), but checking for extra safety.
+	if current.GetNamespace() != desired.GetNamespace() {
+		return errors.Errorf("invalid operation: it is not possible to change namespace from %s to %s", current.GetNamespace(), desired.GetNamespace())
+	}
+
+	return nil
+}
+
+// checkReferencedObjectsAreStrictlyCompatible check if two referenced objects are strictly compatible, meaning that
+// they are compatible and the name of the objects does not change.
+func checkReferencedObjectsAreStrictlyCompatible(current, desired client.Object) error {
+	if current.GetName() != desired.GetName() {
+		return errors.Errorf("invalid operation: it is not possible to change names from %s to %s", current.GetName(), desired.GetName())
+	}
+	return checkReferencedObjectsAreCompatible(current, desired)
+}
diff --git a/go.mod b/go.mod
index 43777e294..b27575588 100644
--- a/go.mod
+++ b/go.mod
@@ -40,6 +40,6 @@ require (
 	k8s.io/klog/v2 v2.9.0
 	k8s.io/kubectl v0.21.2
 	k8s.io/utils v0.0.0-20210722164352-7f3ee0f31471
-	sigs.k8s.io/controller-runtime v0.9.6
+	sigs.k8s.io/controller-runtime v0.9.5
 	sigs.k8s.io/yaml v1.2.0
 )
diff --git a/go.sum b/go.sum
index c085a50de..f089bb12b 100644
--- a/go.sum
+++ b/go.sum
@@ -1099,8 +1099,8 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.19/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
-sigs.k8s.io/controller-runtime v0.9.6 h1:EevVMlgUj4fC1NVM4+DB3iPkWkmGRNarA66neqv9Qew=
-sigs.k8s.io/controller-runtime v0.9.6/go.mod h1:q6PpkM5vqQubEKUKOM6qr06oXGzOBcCby1DA9FbyZeA=
+sigs.k8s.io/controller-runtime v0.9.5 h1:WThcFE6cqctTn2jCZprLICO6BaKZfhsT37uAapTNfxc=
+sigs.k8s.io/controller-runtime v0.9.5/go.mod h1:q6PpkM5vqQubEKUKOM6qr06oXGzOBcCby1DA9FbyZeA=
 sigs.k8s.io/kustomize/api v0.8.8/go.mod h1:He1zoK0nk43Pc6NlV085xDXDXTNprtcyKZVm3swsdNY=
 sigs.k8s.io/kustomize/cmd/config v0.9.10/go.mod h1:Mrby0WnRH7hA6OwOYnYpfpiY0WJIMgYrEDfwOeFdMK0=
 sigs.k8s.io/kustomize/kustomize/v4 v4.1.2/go.mod h1:PxBvo4WGYlCLeRPL+ziT64wBXqbgfcalOS/SXa/tcyo=


Test patch:
diff --git a/api/v1alpha4/index/cluster_test.go b/api/v1alpha4/index/cluster_test.go
deleted file mode 100644
index 6976e4b1f..000000000
--- a/api/v1alpha4/index/cluster_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
-Copyright 2021 The Kubernetes 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 index
-
-import (
-	"testing"
-
-	. "github.com/onsi/gomega"
-	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-)
-
-func TestClusterByClassName(t *testing.T) {
-	testCases := []struct {
-		name     string
-		object   client.Object
-		expected []string
-	}{
-		{
-			name:     "when cluster has no Topology",
-			object:   &clusterv1.Cluster{},
-			expected: nil,
-		},
-		{
-			name: "when cluster has a valid Topology",
-			object: &clusterv1.Cluster{
-				Spec: clusterv1.ClusterSpec{
-					Topology: &clusterv1.Topology{
-						Class: "class1",
-					},
-				},
-			},
-			expected: []string{"class1"},
-		},
-	}
-
-	for _, test := range testCases {
-		t.Run(test.name, func(t *testing.T) {
-			g := NewWithT(t)
-			got := clusterByClassName(test.object)
-			g.Expect(got).To(Equal(test.expected))
-		})
-	}
-}
diff --git a/cmd/clusterctl/client/client_test.go b/cmd/clusterctl/client/client_test.go
index fb5679c13..7f4ab441d 100644
--- a/cmd/clusterctl/client/client_test.go
+++ b/cmd/clusterctl/client/client_test.go
@@ -394,7 +394,7 @@ func (f *fakeConfigClient) WithProvider(provider config.Provider) *fakeConfigCli
 // The implementation stores configuration settings in a map; you can use
 // the WithPaths or WithDefaultVersion methods to configure the repository and WithFile to set the map values.
 func newFakeRepository(provider config.Provider, configClient config.Client) *fakeRepositoryClient {
-	fakeRepository := repository.NewMemoryRepository()
+	fakeRepository := test.NewFakeRepository()
 
 	if configClient == nil {
 		configClient = newFakeConfig()
@@ -411,7 +411,7 @@ func newFakeRepository(provider config.Provider, configClient config.Client) *fa
 type fakeRepositoryClient struct {
 	config.Provider
 	configClient   config.Client
-	fakeRepository *repository.MemoryRepository
+	fakeRepository *test.FakeRepository
 	processor      yaml.Processor
 }
 
@@ -481,7 +481,7 @@ func (f *fakeRepositoryClient) WithFile(version, path string, content []byte) *f
 // fakeTemplateClient provides a super simple TemplateClient (e.g. without support for local overrides).
 type fakeTemplateClient struct {
 	version               string
-	fakeRepository        *repository.MemoryRepository
+	fakeRepository        *test.FakeRepository
 	configVariablesClient config.VariablesClient
 	processor             yaml.Processor
 }
@@ -509,7 +509,7 @@ func (f *fakeTemplateClient) Get(flavor, targetNamespace string, skipTemplatePro
 // fakeMetadataClient provides a super simple MetadataClient (e.g. without support for local overrides/embedded metadata).
 type fakeMetadataClient struct {
 	version        string
-	fakeRepository *repository.MemoryRepository
+	fakeRepository *test.FakeRepository
 }
 
 func (f *fakeMetadataClient) Get() (*clusterctlv1.Metadata, error) {
@@ -530,7 +530,7 @@ func (f *fakeMetadataClient) Get() (*clusterctlv1.Metadata, error) {
 // fakeComponentClient provides a super simple ComponentClient (e.g. without support for local overrides).
 type fakeComponentClient struct {
 	provider       config.Provider
-	fakeRepository *repository.MemoryRepository
+	fakeRepository *test.FakeRepository
 	configClient   config.Client
 	processor      yaml.Processor
 }
diff --git a/cmd/clusterctl/client/cluster/cert_manager_test.go b/cmd/clusterctl/client/cluster/cert_manager_test.go
index d3b5d2658..0f89313b5 100644
--- a/cmd/clusterctl/client/cluster/cert_manager_test.go
+++ b/cmd/clusterctl/client/cluster/cert_manager_test.go
@@ -75,7 +75,7 @@ func Test_getManifestObjs(t *testing.T) {
 			name: "successfully gets the cert-manager components",
 			fields: fields{
 				configClient: defaultConfigClient,
-				repository: repository.NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion(config.CertManagerDefaultVersion).
 					WithFile(config.CertManagerDefaultVersion, "components.yaml", utilyaml.JoinYaml(certManagerNamespaceYaml, certManagerDeploymentYaml)),
@@ -86,7 +86,7 @@ func Test_getManifestObjs(t *testing.T) {
 			name: "fails if the file does not exists",
 			fields: fields{
 				configClient: defaultConfigClient,
-				repository: repository.NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0"),
 			},
@@ -96,7 +96,7 @@ func Test_getManifestObjs(t *testing.T) {
 			name: "fails if the file does not exists for the desired version",
 			fields: fields{
 				configClient: defaultConfigClient,
-				repository: repository.NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v99.0.0").
 					WithFile("v99.0.0", "components.yaml", utilyaml.JoinYaml(certManagerNamespaceYaml, certManagerDeploymentYaml)),
@@ -111,7 +111,7 @@ func Test_getManifestObjs(t *testing.T) {
 					g.Expect(err).ToNot(HaveOccurred())
 					return configClient
 				}(),
-				repository: repository.NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion(config.CertManagerDefaultVersion).
 					WithFile(config.CertManagerDefaultVersion, "components.yaml", utilyaml.JoinYaml(certManagerNamespaceYaml, certManagerDeploymentYaml)),
diff --git a/cmd/clusterctl/client/cluster/installer_test.go b/cmd/clusterctl/client/cluster/installer_test.go
index a4e43c2a4..7ee159e20 100644
--- a/cmd/clusterctl/client/cluster/installer_test.go
+++ b/cmd/clusterctl/client/cluster/installer_test.go
@@ -35,7 +35,7 @@ func Test_providerInstaller_Validate(t *testing.T) {
 		WithProvider("infra2", clusterctlv1.InfrastructureProviderType, "https://somewhere.com")
 
 	repositoryMap := map[string]repository.Repository{
-		"cluster-api": repository.NewMemoryRepository().
+		"cluster-api": test.NewFakeRepository().
 			WithVersions("v0.9.0", "v1.0.0", "v1.0.1", "v2.0.0").
 			WithMetadata("v0.9.0", &clusterctlv1.Metadata{
 				ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -55,7 +55,7 @@ func Test_providerInstaller_Validate(t *testing.T) {
 					{Major: 2, Minor: 0, Contract: test.NextCAPIContractNotSupported},
 				},
 			}),
-		"infrastructure-infra1": repository.NewMemoryRepository().
+		"infrastructure-infra1": test.NewFakeRepository().
 			WithVersions("v0.9.0", "v1.0.0", "v1.0.1", "v2.0.0").
 			WithMetadata("v0.9.0", &clusterctlv1.Metadata{
 				ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -75,7 +75,7 @@ func Test_providerInstaller_Validate(t *testing.T) {
 					{Major: 2, Minor: 0, Contract: test.NextCAPIContractNotSupported},
 				},
 			}),
-		"infrastructure-infra2": repository.NewMemoryRepository().
+		"infrastructure-infra2": test.NewFakeRepository().
 			WithVersions("v0.9.0", "v1.0.0", "v1.0.1", "v2.0.0").
 			WithMetadata("v0.9.0", &clusterctlv1.Metadata{
 				ReleaseSeries: []clusterctlv1.ReleaseSeries{
diff --git a/cmd/clusterctl/client/cluster/upgrader_info_test.go b/cmd/clusterctl/client/cluster/upgrader_info_test.go
index dd290e1ea..d31205aaa 100644
--- a/cmd/clusterctl/client/cluster/upgrader_info_test.go
+++ b/cmd/clusterctl/client/cluster/upgrader_info_test.go
@@ -31,8 +31,8 @@ import (
 
 func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 	type fields struct {
-		reader config.Reader
-		repo   repository.Repository
+		reader     config.Reader
+		repository repository.Repository
 	}
 	type args struct {
 		provider clusterctlv1.Provider
@@ -49,7 +49,7 @@ func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 			fields: fields{
 				reader: test.NewFakeReader().
 					WithProvider("p1", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
-				repo: repository.NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithVersions("v1.0.0", "v1.0.1", "v1.0.2", "v1.1.0").
 					WithMetadata("v1.1.0", &clusterctlv1.Metadata{
 						ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -87,7 +87,7 @@ func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 			fields: fields{
 				reader: test.NewFakeReader().
 					WithProvider("p1", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
-				repo: repository.NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithVersions("v1.0.0", "v1.0.1", "v1.0.2", "v1.1.0").
 					WithMetadata("v1.1.0", &clusterctlv1.Metadata{
 						ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -125,7 +125,7 @@ func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 			fields: fields{
 				reader: test.NewFakeReader().
 					WithProvider("p1", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
-				repo: repository.NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithVersions("v1.0.0", "v1.0.1", "v1.0.2", "v1.1.0").
 					WithMetadata("v1.1.0", &clusterctlv1.Metadata{
 						ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -163,7 +163,7 @@ func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 			fields: fields{
 				reader: test.NewFakeReader().
 					WithProvider("p1", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
-				repo: repository.NewMemoryRepository(). // without metadata
+				repository: test.NewFakeRepository(). // without metadata
 									WithVersions("v1.0.0", "v1.0.1"),
 			},
 			args: args{
@@ -177,7 +177,7 @@ func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 			fields: fields{
 				reader: test.NewFakeReader().
 					WithProvider("p1", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
-				repo: repository.NewMemoryRepository(). // with metadata but only for versions <= current version (not for next versions)
+				repository: test.NewFakeRepository(). // with metadata but only for versions <= current version (not for next versions)
 									WithVersions("v1.0.0", "v1.0.1").
 									WithMetadata("v1.0.0", &clusterctlv1.Metadata{}),
 			},
@@ -192,7 +192,7 @@ func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 			fields: fields{
 				reader: test.NewFakeReader().
 					WithProvider("p1", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
-				repo: repository.NewMemoryRepository(). // without metadata
+				repository: test.NewFakeRepository(). // without metadata
 									WithVersions("v1.0.0", "v1.0.1").
 									WithMetadata("v1.0.1", &clusterctlv1.Metadata{}),
 			},
@@ -207,7 +207,7 @@ func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 			fields: fields{
 				reader: test.NewFakeReader().
 					WithProvider("p1", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
-				repo: repository.NewMemoryRepository(). // without metadata
+				repository: test.NewFakeRepository(). // without metadata
 									WithVersions("v1.0.0", "v1.0.1", "v1.1.1").
 									WithMetadata("v1.1.1", &clusterctlv1.Metadata{
 						ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -232,7 +232,7 @@ func Test_providerUpgrader_getUpgradeInfo(t *testing.T) {
 			u := &providerUpgrader{
 				configClient: configClient,
 				repositoryClientFactory: func(provider config.Provider, configClient config.Client, options ...repository.Option) (repository.Client, error) {
-					return repository.New(provider, configClient, repository.InjectRepository(tt.fields.repo))
+					return repository.New(provider, configClient, repository.InjectRepository(tt.fields.repository))
 				},
 			}
 			got, err := u.getUpgradeInfo(tt.args.provider)
diff --git a/cmd/clusterctl/client/cluster/upgrader_test.go b/cmd/clusterctl/client/cluster/upgrader_test.go
index 91dfa4790..e81fe6148 100644
--- a/cmd/clusterctl/client/cluster/upgrader_test.go
+++ b/cmd/clusterctl/client/cluster/upgrader_test.go
@@ -47,14 +47,14 @@ func Test_providerUpgrader_Plan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1").
 						WithMetadata("v1.0.1", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
 								{Major: 1, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1").
 						WithMetadata("v2.0.1", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -92,14 +92,14 @@ func Test_providerUpgrader_Plan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1").
 						WithMetadata("v1.0.1", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
 								{Major: 1, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1", "v3.0.0-alpha.0").
 						WithMetadata("v2.0.1", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -143,7 +143,7 @@ func Test_providerUpgrader_Plan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -151,7 +151,7 @@ func Test_providerUpgrader_Plan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -203,7 +203,7 @@ func Test_providerUpgrader_Plan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -211,7 +211,7 @@ func Test_providerUpgrader_Plan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.NextCAPIContractNotSupported},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -263,7 +263,7 @@ func Test_providerUpgrader_Plan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -271,7 +271,7 @@ func Test_providerUpgrader_Plan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.NextCAPIContractNotSupported},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0"). // no new releases available for the infra provider (only the current release exists)
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -353,14 +353,14 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1").
 						WithMetadata("v1.0.1", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
 								{Major: 1, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1").
 						WithMetadata("v2.0.1", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -401,14 +401,14 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1").
 						WithMetadata("v1.0.1", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
 								{Major: 1, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1").
 						WithMetadata("v2.0.1", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -449,7 +449,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -457,7 +457,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -507,7 +507,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -515,7 +515,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.NextCAPIContractNotSupported},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -549,7 +549,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -557,7 +557,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -591,7 +591,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -599,7 +599,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.NextCAPIContractNotSupported},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -633,7 +633,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -641,7 +641,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -675,7 +675,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -683,7 +683,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.NextCAPIContractNotSupported},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -721,7 +721,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 					WithProvider("cluster-api", clusterctlv1.CoreProviderType, "https://somewhere.com").
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -729,7 +729,7 @@ func Test_providerUpgrader_createCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infra": repository.NewMemoryRepository().
+					"infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -821,7 +821,7 @@ func Test_providerUpgrader_ApplyPlan(t *testing.T) {
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				// two provider repositories, with current v1alpha3 contract and new versions for v1alpha4 contract
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -829,7 +829,7 @@ func Test_providerUpgrader_ApplyPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -857,7 +857,7 @@ func Test_providerUpgrader_ApplyPlan(t *testing.T) {
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				// two provider repositories, with current v1alpha3 contract and new versions for v1alpha4 contract
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -865,7 +865,7 @@ func Test_providerUpgrader_ApplyPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -935,7 +935,7 @@ func Test_providerUpgrader_ApplyCustomPlan(t *testing.T) {
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				// two provider repositories, with current v1alpha3 contract and new versions for v1alpha4 contract
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -943,7 +943,7 @@ func Test_providerUpgrader_ApplyCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -980,7 +980,7 @@ func Test_providerUpgrader_ApplyCustomPlan(t *testing.T) {
 					WithProvider("infra", clusterctlv1.InfrastructureProviderType, "https://somewhere.com"),
 				// two provider repositories, with current v1alpha3 contract and new versions for v1alpha4 contract
 				repository: map[string]repository.Repository{
-					"cluster-api": repository.NewMemoryRepository().
+					"cluster-api": test.NewFakeRepository().
 						WithVersions("v1.0.0", "v1.0.1", "v2.0.0").
 						WithMetadata("v2.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
@@ -988,7 +988,7 @@ func Test_providerUpgrader_ApplyCustomPlan(t *testing.T) {
 								{Major: 2, Minor: 0, Contract: test.CurrentCAPIContract},
 							},
 						}),
-					"infrastructure-infra": repository.NewMemoryRepository().
+					"infrastructure-infra": test.NewFakeRepository().
 						WithVersions("v2.0.0", "v2.0.1", "v3.0.0").
 						WithMetadata("v3.0.0", &clusterctlv1.Metadata{
 							ReleaseSeries: []clusterctlv1.ReleaseSeries{
diff --git a/cmd/clusterctl/client/repository/client_test.go b/cmd/clusterctl/client/repository/client_test.go
index 97316a154..3a6059c8b 100644
--- a/cmd/clusterctl/client/repository/client_test.go
+++ b/cmd/clusterctl/client/repository/client_test.go
@@ -114,7 +114,7 @@ func Test_newRepositoryClient_YamlProcessor(t *testing.T) {
 			configClient, err := config.New("", config.InjectReader(test.NewFakeReader()))
 			g.Expect(err).NotTo(HaveOccurred())
 
-			tt.opts = append(tt.opts, InjectRepository(NewMemoryRepository()))
+			tt.opts = append(tt.opts, InjectRepository(test.NewFakeRepository()))
 
 			repoClient, err := newRepositoryClient(
 				configProvider,
diff --git a/cmd/clusterctl/client/repository/components_client_test.go b/cmd/clusterctl/client/repository/components_client_test.go
index d5d3626ca..21e31d8cf 100644
--- a/cmd/clusterctl/client/repository/components_client_test.go
+++ b/cmd/clusterctl/client/repository/components_client_test.go
@@ -96,7 +96,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "successfully gets the components",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0").
 					WithFile("v1.0.0", "components.yaml", utilyaml.JoinYaml(namespaceYaml, controllerYaml, configMapYaml)),
@@ -117,7 +117,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "successfully gets the components even with SkipTemplateProcess defined",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0").
 					WithFile("v1.0.0", "components.yaml", utilyaml.JoinYaml(namespaceYaml, controllerYaml, configMapYaml)),
@@ -139,7 +139,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "targetNamespace overrides default targetNamespace",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0").
 					WithFile("v1.0.0", "components.yaml", utilyaml.JoinYaml(namespaceYaml, controllerYaml, configMapYaml)),
@@ -160,7 +160,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "Fails if components file does not exists",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0"),
 			},
@@ -174,7 +174,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "Fails if default targetNamespace does not exists",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0").
 					WithFile("v1.0.0", "components.yaml", utilyaml.JoinYaml(controllerYaml, configMapYaml)),
@@ -189,7 +189,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "Pass if default targetNamespace does not exists but a target targetNamespace is set",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0").
 					WithFile("v1.0.0", "components.yaml", utilyaml.JoinYaml(controllerYaml, configMapYaml)),
@@ -210,7 +210,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "Fails if requested version does not exists",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0").
 					WithFile("v1.0.0", "components.yaml", utilyaml.JoinYaml(controllerYaml, configMapYaml)),
@@ -225,7 +225,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "Fails if yaml processor cannot get Variables",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0").
 					WithFile("v1.0.0", "components.yaml", utilyaml.JoinYaml(namespaceYaml, controllerYaml, configMapYaml)),
@@ -241,7 +241,7 @@ func Test_componentsClient_Get(t *testing.T) {
 			name: "Fails if yaml processor cannot process the raw yaml",
 			fields: fields{
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "components.yaml").
 					WithDefaultVersion("v1.0.0").
 					WithFile("v1.0.0", "components.yaml", utilyaml.JoinYaml(namespaceYaml, controllerYaml, configMapYaml)),
diff --git a/cmd/clusterctl/client/repository/metadata_client_test.go b/cmd/clusterctl/client/repository/metadata_client_test.go
index 0f8635d88..c7070b0b5 100644
--- a/cmd/clusterctl/client/repository/metadata_client_test.go
+++ b/cmd/clusterctl/client/repository/metadata_client_test.go
@@ -44,7 +44,7 @@ func Test_metadataClient_Get(t *testing.T) {
 			fields: fields{
 				provider: config.NewProvider("p1", "", clusterctlv1.CoreProviderType),
 				version:  "v1.0.0",
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v1.0.0").
 					WithMetadata("v1.0.0", &clusterctlv1.Metadata{
@@ -73,7 +73,7 @@ func Test_metadataClient_Get(t *testing.T) {
 			fields: fields{
 				provider: config.NewProvider("p1", "", clusterctlv1.CoreProviderType),
 				version:  "v1.0.0",
-				repository: NewMemoryRepository(). // repository without a metadata file
+				repository: test.NewFakeRepository(). // repository without a metadata file
 									WithPaths("root", "").
 									WithDefaultVersion("v1.0.0"),
 			},
@@ -85,7 +85,7 @@ func Test_metadataClient_Get(t *testing.T) {
 			fields: fields{
 				provider: config.NewProvider("p1", "", clusterctlv1.CoreProviderType),
 				version:  "v1.0.0",
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v2.0.0").
 					WithMetadata("v2.0.0", &clusterctlv1.Metadata{ // metadata file exists for version 2.0.0, while we are checking metadata for v1.0.0
@@ -102,7 +102,7 @@ func Test_metadataClient_Get(t *testing.T) {
 			fields: fields{
 				provider: config.NewProvider("p1", "", clusterctlv1.CoreProviderType),
 				version:  "v1.0.0",
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v2.0.0").
 					WithFile("v2.0.0", "metadata.yaml", []byte("not a valid metadata file!")), // metadata file exists but is invalid
diff --git a/cmd/clusterctl/client/repository/repository_github_test.go b/cmd/clusterctl/client/repository/repository_github_test.go
index 9f59f7e7b..d27e17f25 100644
--- a/cmd/clusterctl/client/repository/repository_github_test.go
+++ b/cmd/clusterctl/client/repository/repository_github_test.go
@@ -360,7 +360,7 @@ func Test_gitHubRepository_getLatestContractRelease(t *testing.T) {
 			gRepo, err := newGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGithubClient(client))
 			g.Expect(err).NotTo(HaveOccurred())
 
-			got, err := latestContractRelease(gRepo, tt.contract)
+			got, err := gRepo.getLatestContractRelease(tt.contract)
 			if tt.wantErr {
 				g.Expect(err).To(HaveOccurred())
 				return
@@ -446,7 +446,7 @@ func Test_gitHubRepository_getLatestRelease(t *testing.T) {
 			gRepo, err := newGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGithubClient(client))
 			g.Expect(err).NotTo(HaveOccurred())
 
-			got, err := latestRelease(gRepo)
+			got, err := gRepo.getLatestRelease()
 			if tt.wantErr {
 				g.Expect(err).To(HaveOccurred())
 				return
@@ -528,7 +528,7 @@ func Test_gitHubRepository_getLatestPatchRelease(t *testing.T) {
 			gRepo, err := newGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGithubClient(client))
 			g.Expect(err).NotTo(HaveOccurred())
 
-			got, err := latestPatchRelease(gRepo, tt.major, tt.minor)
+			got, err := gRepo.getLatestPatchRelease(tt.major, tt.minor)
 			if tt.wantErr {
 				g.Expect(err).To(HaveOccurred())
 				return
diff --git a/cmd/clusterctl/client/repository/repository_memory_test.go b/cmd/clusterctl/client/repository/repository_memory_test.go
deleted file mode 100644
index aac885f17..000000000
--- a/cmd/clusterctl/client/repository/repository_memory_test.go
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
-Copyright 2021 The Kubernetes 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 repository
-
-import (
-	"testing"
-
-	. "github.com/onsi/gomega"
-)
-
-func Test_memoryRepository(t *testing.T) {
-	metadata := `
-apiVersion: clusterctl.cluster.x-k8s.io/v1alpha3
-kind: Metadata
-releaseSeries:
-- major: 1
-  minor: 0
-  contract: v1alpha1
-- major: 2
-  minor: 0
-  contract: v1alpha3`
-
-	type want struct {
-		versions       []string
-		defaultVersion []byte
-		latestVersion  []byte
-	}
-	tests := []struct {
-		name        string
-		repo        *MemoryRepository
-		fileVersion string
-		want        want
-	}{
-		{
-			name: "Get the only release available from release directory",
-			repo: NewMemoryRepository().
-				WithFile("v1.0.0", "metadata.yaml", []byte(metadata)).
-				WithFile("v1.0.0", "components.yaml", []byte("v1.0.0")).
-				WithPaths("", "components.yaml"),
-			want: want{
-				versions:       []string{"v1.0.0"},
-				defaultVersion: []byte("v1.0.0"),
-				latestVersion:  []byte("v1.0.0"),
-			},
-		},
-		{
-			name: "WithDefaultVersion overrides initial version",
-			repo: NewMemoryRepository().
-				WithFile("v1.0.0", "metadata.yaml", []byte(metadata)).
-				WithFile("v1.0.0", "components.yaml", []byte("v1.0.0")).
-				WithFile("v2.0.0", "metadata.yaml", []byte(metadata)).
-				WithFile("v2.0.0", "components.yaml", []byte("v2.0.0")).
-				WithPaths("", "components.yaml").
-				WithDefaultVersion("v2.0.0"),
-			want: want{
-				versions:       []string{"v1.0.0", "v2.0.0"},
-				defaultVersion: []byte("v2.0.0"),
-				latestVersion:  []byte("v2.0.0"),
-			},
-		},
-		{
-			name: "GetFile can use latest as default",
-			repo: NewMemoryRepository().
-				WithFile("v1.0.0", "metadata.yaml", []byte(metadata)).
-				WithFile("v1.0.0", "components.yaml", []byte("v1.0.0")).
-				WithFile("v2.0.0", "metadata.yaml", []byte(metadata)).
-				WithFile("v2.0.0", "components.yaml", []byte("v2.0.0")).
-				WithPaths("", "components.yaml").
-				WithDefaultVersion("latest"),
-			want: want{
-				versions:       []string{"v1.0.0", "v2.0.0"},
-				defaultVersion: []byte("v2.0.0"),
-				latestVersion:  []byte("v2.0.0"),
-			},
-		},
-		{
-			name: "Get all valid releases available from release directory",
-			repo: NewMemoryRepository().
-				WithFile("v1.0.0", "components.yaml", []byte("v1.0.0")).
-				WithFile("v1.0.0", "metadata.yaml", []byte(metadata)).
-				WithFile("v1.0.1", "components.yaml", []byte("v1.0.1")).
-				WithFile("v1.0.1", "metadata.yaml", []byte(metadata)).
-				WithFile("v2.0.1", "components.yaml", []byte("v2.0.1")).
-				WithFile("v2.0.1", "metadata.yaml", []byte(metadata)).
-				WithFile("v2.0.2+exp.sha.5114f85", "components.yaml", []byte("v2.0.2+exp.sha.5114f85")).
-				WithFile("v2.0.2+exp.sha.5114f85", "metadata.yaml", []byte(metadata)).
-				WithFile("v2.0.3-alpha", "components.yaml", []byte("v2.0.3-alpha")).
-				WithFile("v2.0.3-alpha", "metadata.yaml", []byte(metadata)).
-				WithPaths("", "components.yaml"),
-			want: want{
-				versions:       []string{"v1.0.0", "v1.0.1", "v2.0.1", "v2.0.2+exp.sha.5114f85", "v2.0.3-alpha"},
-				defaultVersion: []byte("v1.0.0"),
-				latestVersion:  []byte("v2.0.2+exp.sha.5114f85"),
-			},
-		},
-		{
-			name: "Get pre-release",
-			repo: NewMemoryRepository().
-				WithFile("v2.0.3-alpha", "components.yaml", []byte("v2.0.3-alpha")).
-				WithFile("v2.0.3-alpha", "metadata.yaml", []byte(metadata)).
-				WithPaths("", "components.yaml"),
-			want: want{
-				versions:       []string{"v2.0.3-alpha"},
-				defaultVersion: []byte("v2.0.3-alpha"),
-				latestVersion:  []byte("v2.0.3-alpha"),
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			g := NewWithT(t)
-
-			r := tt.repo
-			g.Expect(r.RootPath()).To(Equal(""))
-
-			g.Expect(r.GetFile(r.DefaultVersion(), r.ComponentsPath())).To(Equal(tt.want.defaultVersion))
-			g.Expect(r.GetFile("", r.ComponentsPath())).To(Equal(tt.want.defaultVersion))
-			g.Expect(r.GetFile("latest", r.ComponentsPath())).To(Equal(tt.want.latestVersion))
-
-			got, err := r.GetVersions()
-			g.Expect(err).NotTo(HaveOccurred())
-			g.Expect(got).To(ConsistOf(tt.want.versions))
-		})
-	}
-}
diff --git a/cmd/clusterctl/client/repository/template_client_test.go b/cmd/clusterctl/client/repository/template_client_test.go
index 30a54b673..9e65d11e7 100644
--- a/cmd/clusterctl/client/repository/template_client_test.go
+++ b/cmd/clusterctl/client/repository/template_client_test.go
@@ -60,7 +60,7 @@ func Test_templates_Get(t *testing.T) {
 			fields: fields{
 				version:  "v1.0",
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v1.0").
 					WithFile("v1.0", "cluster-template.yaml", templateMapYaml),
@@ -83,7 +83,7 @@ func Test_templates_Get(t *testing.T) {
 			fields: fields{
 				version:  "v1.0",
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v1.0").
 					WithFile("v1.0", "cluster-template-prod.yaml", templateMapYaml),
@@ -106,7 +106,7 @@ func Test_templates_Get(t *testing.T) {
 			fields: fields{
 				version:  "v1.0",
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v1.0"),
 				configVariablesClient: test.NewFakeVariableClient().WithVar(variableName, variableValue),
@@ -124,7 +124,7 @@ func Test_templates_Get(t *testing.T) {
 			fields: fields{
 				version:  "v1.0",
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v1.0").
 					WithFile("v1.0", "cluster-template.yaml", templateMapYaml),
@@ -143,7 +143,7 @@ func Test_templates_Get(t *testing.T) {
 			fields: fields{
 				version:  "v1.0",
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v1.0").
 					WithFile("v1.0", "cluster-template.yaml", templateMapYaml),
@@ -166,7 +166,7 @@ func Test_templates_Get(t *testing.T) {
 			fields: fields{
 				version:  "v1.0",
 				provider: p1,
-				repository: NewMemoryRepository().
+				repository: test.NewFakeRepository().
 					WithPaths("root", "").
 					WithDefaultVersion("v1.0").
 					WithFile("v1.0", "cluster-template.yaml", templateMapYaml),
diff --git a/cmd/clusterctl/internal/test/fake_repository.go b/cmd/clusterctl/internal/test/fake_repository.go
new file mode 100644
index 000000000..f6b901be3
--- /dev/null
+++ b/cmd/clusterctl/internal/test/fake_repository.go
@@ -0,0 +1,123 @@
+/*
+Copyright 2019 The Kubernetes 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 test
+
+import (
+	"fmt"
+
+	"github.com/pkg/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/serializer"
+	clusterctlv1 "sigs.k8s.io/cluster-api/cmd/clusterctl/api/v1alpha3"
+	"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
+)
+
+type FakeRepository struct {
+	defaultVersion string
+	rootPath       string
+	componentsPath string
+	versions       map[string]bool
+	files          map[string][]byte
+}
+
+func (f *FakeRepository) DefaultVersion() string {
+	return f.defaultVersion
+}
+
+func (f *FakeRepository) RootPath() string {
+	return f.rootPath
+}
+
+func (f *FakeRepository) ComponentsPath() string {
+	return f.componentsPath
+}
+
+func (f FakeRepository) GetFile(version string, path string) ([]byte, error) {
+	if _, ok := f.versions[version]; !ok {
+		return nil, errors.Errorf("unable to get files for version %s", version)
+	}
+
+	for p, c := range f.files {
+		if p == vpath(version, path) {
+			return c, nil
+		}
+	}
+	return nil, errors.Errorf("unable to get file %s for version %s", path, version)
+}
+
+func (f *FakeRepository) GetVersions() ([]string, error) {
+	v := make([]string, 0, len(f.versions))
+	for k := range f.versions {
+		v = append(v, k)
+	}
+	return v, nil
+}
+
+func NewFakeRepository() *FakeRepository {
+	return &FakeRepository{
+		versions: map[string]bool{},
+		files:    map[string][]byte{},
+	}
+}
+
+func (f *FakeRepository) WithPaths(rootPath, componentsPath string) *FakeRepository {
+	f.rootPath = rootPath
+	f.componentsPath = componentsPath
+	return f
+}
+
+func (f *FakeRepository) WithDefaultVersion(version string) *FakeRepository {
+	f.defaultVersion = version
+	return f
+}
+
+func (f *FakeRepository) WithFile(version, path string, content []byte) *FakeRepository {
+	f.versions[version] = true
+	f.files[vpath(version, path)] = content
+	return f
+}
+
+func (f *FakeRepository) WithVersions(version ...string) *FakeRepository {
+	for _, v := range version {
+		f.versions[v] = true
+	}
+	return f
+}
+
+func (f *FakeRepository) WithMetadata(version string, metadata *clusterctlv1.Metadata) *FakeRepository {
+	codecs := serializer.NewCodecFactory(scheme.Scheme)
+
+	mediaType := "application/yaml"
+	info, match := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), mediaType)
+	if !match {
+		panic("failed to get SerializerInfo for application/yaml")
+	}
+
+	metadata.SetGroupVersionKind(clusterctlv1.GroupVersion.WithKind("Metadata"))
+
+	encoder := codecs.EncoderForVersion(info.Serializer, metadata.GroupVersionKind().GroupVersion())
+	data, err := runtime.Encode(encoder, metadata)
+	if err != nil {
+		panic(err)
+	}
+
+	return f.WithFile(version, "metadata.yaml", data)
+}
+
+func vpath(version string, path string) string {
+	return fmt.Sprintf("%s/%s", version, path)
+}
diff --git a/controllers/topology/cluster_class_test.go b/controllers/topology/cluster_class_test.go
index 91f6189db..143e4cf47 100644
--- a/controllers/topology/cluster_class_test.go
+++ b/controllers/topology/cluster_class_test.go
@@ -213,8 +213,14 @@ func TestGetClass(t *testing.T) {
 			cluster := newFakeCluster(metav1.NamespaceDefault, "cluster1").Obj()
 
 			if tt.clusterClass != nil {
-				cluster.Spec.Topology.Class = tt.clusterClass.Name
+				cluster.Spec.Topology = &clusterv1.Topology{
+					Class: tt.clusterClass.Name,
+				}
 				objs = append(objs, tt.clusterClass)
+			} else {
+				cluster.Spec.Topology = &clusterv1.Topology{
+					Class: "foo",
+				}
 			}
 
 			fakeClient := fake.NewClientBuilder().
diff --git a/controllers/topology/object_builders_test.go b/controllers/topology/object_builders_test.go
index d256008e8..9103dca14 100644
--- a/controllers/topology/object_builders_test.go
+++ b/controllers/topology/object_builders_test.go
@@ -22,6 +22,7 @@ import (
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
+	"sigs.k8s.io/cluster-api/internal/testtypes"
 )
 
 // TODO: move under internal/testtypes
@@ -106,8 +107,10 @@ var (
 )
 
 type fakeCluster struct {
-	namespace string
-	name      string
+	namespace             string
+	name                  string
+	infrastructureCluster *unstructured.Unstructured
+	controlPlane          *unstructured.Unstructured
 }
 
 func newFakeCluster(namespace, name string) *fakeCluster {
@@ -117,6 +120,16 @@ func newFakeCluster(namespace, name string) *fakeCluster {
 	}
 }
 
+func (f *fakeCluster) WithInfrastructureCluster(t *unstructured.Unstructured) *fakeCluster {
+	f.infrastructureCluster = t
+	return f
+}
+
+func (f *fakeCluster) WithControlPlane(t *unstructured.Unstructured) *fakeCluster {
+	f.controlPlane = t
+	return f
+}
+
 func (f *fakeCluster) Obj() *clusterv1.Cluster {
 	obj := &clusterv1.Cluster{
 		TypeMeta: metav1.TypeMeta{
@@ -130,9 +143,12 @@ func (f *fakeCluster) Obj() *clusterv1.Cluster {
 			// added to the fakeClient and expected objects easier.
 			ResourceVersion: "999",
 		},
-		Spec: clusterv1.ClusterSpec{
-			Topology: &clusterv1.Topology{},
-		},
+	}
+	if f.infrastructureCluster != nil {
+		obj.Spec.InfrastructureRef = objToRef(f.infrastructureCluster)
+	}
+	if f.controlPlane != nil {
+		obj.Spec.ControlPlaneRef = objToRef(f.controlPlane)
 	}
 	return obj
 }
@@ -376,8 +392,8 @@ func newFakeInfrastructureCluster(namespace, name string) *fakeInfrastructureClu
 
 func (f *fakeInfrastructureCluster) Obj() *unstructured.Unstructured {
 	obj := &unstructured.Unstructured{}
-	obj.SetAPIVersion(fakeControlPlaneProviderGroupVersion.String())
-	obj.SetKind("FakeInfrastructureCluster")
+	obj.SetAPIVersion(testtypes.InfrastructureGroupVersion.String())
+	obj.SetKind("GenericInfrastructureCluster")
 	obj.SetNamespace(f.namespace)
 	obj.SetName(f.name)
 	// Nb. This is set to the same resourceVersion the fakeClient uses internally to make comparison between objects
diff --git a/controllers/topology/reconcile_state_test.go b/controllers/topology/reconcile_state_test.go
new file mode 100644
index 000000000..6e1172456
--- /dev/null
+++ b/controllers/topology/reconcile_state_test.go
@@ -0,0 +1,361 @@
+/*
+Copyright 2021 The Kubernetes 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 topology
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+func TestReconcileCluster(t *testing.T) {
+	cluster1 := newFakeCluster(metav1.NamespaceDefault, "cluster1").Obj()
+	cluster1WithReferences := newFakeCluster(metav1.NamespaceDefault, "cluster1").
+		WithInfrastructureCluster(newFakeInfrastructureCluster(metav1.NamespaceDefault, "infrastructure-cluster1").Obj()).
+		WithControlPlane(newFakeControlPlane(metav1.NamespaceDefault, "control-plane1").Obj()).
+		Obj()
+	cluster2WithReferences := cluster1WithReferences.DeepCopy()
+	cluster2WithReferences.SetGroupVersionKind(cluster1WithReferences.GroupVersionKind())
+	cluster2WithReferences.Name = "cluster2"
+
+	tests := []struct {
+		name    string
+		current *clusterv1.Cluster
+		desired *clusterv1.Cluster
+		want    *clusterv1.Cluster
+		wantErr bool
+	}{
+		{
+			name:    "Should update the cluster if infrastructure and control plane references are not set",
+			current: cluster1,
+			desired: cluster1WithReferences,
+			want:    cluster1WithReferences,
+			wantErr: false,
+		},
+		{
+			name:    "Should be a no op if infrastructure and control plane references are already set",
+			current: cluster2WithReferences,
+			desired: cluster2WithReferences,
+			want:    cluster2WithReferences,
+			wantErr: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			g := NewWithT(t)
+
+			fakeObjs := make([]client.Object, 0)
+			if tt.current != nil {
+				fakeObjs = append(fakeObjs, tt.current)
+			}
+			fakeClient := fake.NewClientBuilder().
+				WithScheme(fakeScheme).
+				WithObjects(fakeObjs...).
+				Build()
+
+			currentState := &clusterTopologyState{cluster: tt.current}
+
+			// TODO: stop setting ResourceVersion when building objects
+			tt.desired.SetResourceVersion("")
+			desiredState := &clusterTopologyState{cluster: tt.desired}
+
+			r := ClusterReconciler{
+				Client: fakeClient,
+			}
+			err := r.reconcileCluster(ctx, currentState, desiredState)
+			if tt.wantErr {
+				g.Expect(err).To(HaveOccurred())
+				return
+			}
+			g.Expect(err).ToNot(HaveOccurred())
+
+			got := tt.want.DeepCopy()
+			err = fakeClient.Get(ctx, client.ObjectKeyFromObject(tt.want), got)
+			g.Expect(err).ToNot(HaveOccurred())
+
+			g.Expect(got.Spec.InfrastructureRef).To(Equal(tt.want.Spec.InfrastructureRef), cmp.Diff(got, tt.want))
+			g.Expect(got.Spec.ControlPlaneRef).To(Equal(tt.want.Spec.ControlPlaneRef), cmp.Diff(got, tt.want))
+		})
+	}
+}
+
+func TestReconcileInfrastructureCluster(t *testing.T) {
+	g := NewWithT(t)
+
+	clusterInfrastructure1 := newFakeInfrastructureCluster(metav1.NamespaceDefault, "infrastructure-cluster1").Obj()
+	clusterInfrastructure2 := newFakeInfrastructureCluster(metav1.NamespaceDefault, "infrastructure-cluster2").Obj()
+	clusterInfrastructure3 := newFakeInfrastructureCluster(metav1.NamespaceDefault, "infrastructure-cluster3").Obj()
+	clusterInfrastructure3WithInstanceSpecificChanges := clusterInfrastructure3.DeepCopy()
+	clusterInfrastructure3WithInstanceSpecificChanges.SetLabels(map[string]string{"foo": "bar"})
+	clusterInfrastructure4 := newFakeInfrastructureCluster(metav1.NamespaceDefault, "infrastructure-cluster4").Obj()
+	clusterInfrastructure4WithTemplateOverridingChanges := clusterInfrastructure4.DeepCopy()
+	err := unstructured.SetNestedField(clusterInfrastructure4WithTemplateOverridingChanges.UnstructuredContent(), false, "spec", "fakeSetting")
+	g.Expect(err).ToNot(HaveOccurred())
+	clusterInfrastructure5 := newFakeInfrastructureCluster(metav1.NamespaceDefault, "infrastructure-cluster5").Obj()
+
+	tests := []struct {
+		name    string
+		current *unstructured.Unstructured
+		desired *unstructured.Unstructured
+		want    *unstructured.Unstructured
+		wantErr bool
+	}{
+		{
+			name:    "Should create desired InfrastructureCluster if the current does not exists yet",
+			current: nil,
+			desired: clusterInfrastructure1,
+			want:    clusterInfrastructure1,
+			wantErr: false,
+		},
+		{
+			name:    "No-op if current InfrastructureCluster is equal to desired",
+			current: clusterInfrastructure2,
+			desired: clusterInfrastructure2,
+			want:    clusterInfrastructure2,
+			wantErr: false,
+		},
+		{
+			name:    "Should preserve instance specific changes",
+			current: clusterInfrastructure3WithInstanceSpecificChanges,
+			desired: clusterInfrastructure3,
+			want:    clusterInfrastructure3WithInstanceSpecificChanges,
+			wantErr: false,
+		},
+		{
+			name:    "Should restore template values if overridden",
+			current: clusterInfrastructure4WithTemplateOverridingChanges,
+			desired: clusterInfrastructure4,
+			want:    clusterInfrastructure4,
+			wantErr: false,
+		},
+		{
+			name:    "Fails for incompatible changes",
+			current: clusterInfrastructure5,
+			desired: clusterInfrastructure1,
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			g := NewWithT(t)
+
+			fakeObjs := make([]client.Object, 0)
+			if tt.current != nil {
+				fakeObjs = append(fakeObjs, tt.current)
+			}
+			fakeClient := fake.NewClientBuilder().
+				WithScheme(fakeScheme).
+				WithObjects(fakeObjs...).
+				Build()
+
+			currentState := &clusterTopologyState{infrastructureCluster: tt.current}
+
+			// TODO: stop setting ResourceVersion when building objects
+			tt.desired.SetResourceVersion("")
+			desiredState := &clusterTopologyState{infrastructureCluster: tt.desired}
+
+			r := ClusterReconciler{
+				Client: fakeClient,
+			}
+			err := r.reconcileInfrastructureCluster(ctx, currentState, desiredState)
+			if tt.wantErr {
+				g.Expect(err).To(HaveOccurred())
+				return
+			}
+			g.Expect(err).ToNot(HaveOccurred())
+
+			got := tt.want.DeepCopy() // this is required otherwise Get will modify tt.want
+			err = fakeClient.Get(ctx, client.ObjectKeyFromObject(tt.want), got)
+			g.Expect(err).ToNot(HaveOccurred())
+
+			// Spec
+			wantSpec, ok, err := unstructured.NestedMap(tt.want.UnstructuredContent(), "spec")
+			g.Expect(err).NotTo(HaveOccurred())
+			g.Expect(ok).To(BeTrue())
+
+			gotSpec, ok, err := unstructured.NestedMap(got.UnstructuredContent(), "spec")
+			g.Expect(err).NotTo(HaveOccurred())
+			g.Expect(ok).To(BeTrue())
+			for k, v := range wantSpec {
+				g.Expect(gotSpec).To(HaveKeyWithValue(k, v))
+			}
+		})
+	}
+}
+
+type referencedObjectsCompatibilityTestCase struct {
+	name    string
+	current *unstructured.Unstructured
+	desired *unstructured.Unstructured
+	wantErr bool
+}
+
+var referencedObjectsCompatibilityTestCases = []referencedObjectsCompatibilityTestCase{
+	{
+		name: "Fails if group changes",
+		current: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"apiVersion": "foo/v1alpha4",
+			},
+		},
+		desired: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"apiVersion": "bar/v1alpha4",
+			},
+		},
+		wantErr: true,
+	},
+	{
+		name: "Fails if kind changes",
+		current: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"kind": "foo",
+			},
+		},
+		desired: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"kind": "bar",
+			},
+		},
+		wantErr: true,
+	},
+	{
+		name: "Pass if gvk remains the same",
+		current: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"apiVersion": "infrastructure.cluster.x-k8s.io/foo",
+				"kind":       "foo",
+			},
+		},
+		desired: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"apiVersion": "infrastructure.cluster.x-k8s.io/foo",
+				"kind":       "foo",
+			},
+		},
+		wantErr: false,
+	},
+	{
+		name: "Pass if version changes but group and kind remains the same",
+		current: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"apiVersion": "infrastructure.cluster.x-k8s.io/foo",
+				"kind":       "foo",
+			},
+		},
+		desired: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"apiVersion": "infrastructure.cluster.x-k8s.io/bar",
+				"kind":       "foo",
+			},
+		},
+		wantErr: false,
+	},
+	{
+		name: "Fails if namespace changes",
+		current: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"metadata": map[string]interface{}{
+					"namespace": "foo",
+				},
+			},
+		},
+		desired: &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"metadata": map[string]interface{}{
+					"namespace": "bar",
+				},
+			},
+		},
+		wantErr: true,
+	},
+}
+
+func TestCheckReferencedObjectsAreCompatible(t *testing.T) {
+	for _, tt := range referencedObjectsCompatibilityTestCases {
+		t.Run(tt.name, func(t *testing.T) {
+			g := NewWithT(t)
+
+			err := checkReferencedObjectsAreCompatible(tt.current, tt.desired)
+			if tt.wantErr {
+				g.Expect(err).To(HaveOccurred())
+				return
+			}
+			g.Expect(err).ToNot(HaveOccurred())
+		})
+	}
+}
+
+func TestCheckReferencedObjectsAreStrictlyCompatible(t *testing.T) {
+	referencedObjectsStrictCompatibilityTestCases := append(referencedObjectsCompatibilityTestCases, []referencedObjectsCompatibilityTestCase{
+		{
+			name: "Fails if name changes",
+			current: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"metadata": map[string]interface{}{
+						"name": "foo",
+					},
+				},
+			},
+			desired: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"metadata": map[string]interface{}{
+						"name": "bar",
+					},
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: "Pass if name remains the same",
+			current: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"metadata": map[string]interface{}{
+						"name": "foo",
+					},
+				},
+			},
+			desired: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"metadata": map[string]interface{}{
+						"name": "foo",
+					},
+				},
+			},
+			wantErr: false,
+		},
+	}...)
+
+	for _, tt := range referencedObjectsStrictCompatibilityTestCases {
+		t.Run(tt.name, func(t *testing.T) {
+			g := NewWithT(t)
+
+			err := checkReferencedObjectsAreStrictlyCompatible(tt.current, tt.desired)
+			if tt.wantErr {
+				g.Expect(err).To(HaveOccurred())
+				return
+			}
+			g.Expect(err).ToNot(HaveOccurred())
+		})
+	}
+}
diff --git a/test/go.mod b/test/go.mod
index a0cb67fff..c1850a8d5 100644
--- a/test/go.mod
+++ b/test/go.mod
@@ -24,7 +24,7 @@ require (
 	k8s.io/klog/v2 v2.9.0
 	k8s.io/utils v0.0.0-20210722164352-7f3ee0f31471
 	sigs.k8s.io/cluster-api v0.0.0-00010101000000-000000000000
-	sigs.k8s.io/controller-runtime v0.9.6
+	sigs.k8s.io/controller-runtime v0.9.5
 	sigs.k8s.io/kind v0.11.1
 	sigs.k8s.io/yaml v1.2.0
 )
diff --git a/test/go.sum b/test/go.sum
index 1adef6ead..1c870f5d0 100644
--- a/test/go.sum
+++ b/test/go.sum
@@ -1419,8 +1419,8 @@ rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.14/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.15/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.19/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
-sigs.k8s.io/controller-runtime v0.9.6 h1:EevVMlgUj4fC1NVM4+DB3iPkWkmGRNarA66neqv9Qew=
-sigs.k8s.io/controller-runtime v0.9.6/go.mod h1:q6PpkM5vqQubEKUKOM6qr06oXGzOBcCby1DA9FbyZeA=
+sigs.k8s.io/controller-runtime v0.9.5 h1:WThcFE6cqctTn2jCZprLICO6BaKZfhsT37uAapTNfxc=
+sigs.k8s.io/controller-runtime v0.9.5/go.mod h1:q6PpkM5vqQubEKUKOM6qr06oXGzOBcCby1DA9FbyZeA=
 sigs.k8s.io/kind v0.11.1 h1:pVzOkhUwMBrCB0Q/WllQDO3v14Y+o2V0tFgjTqIUjwA=
 sigs.k8s.io/kind v0.11.1/go.mod h1:fRpgVhtqAWrtLB9ED7zQahUimpUXuG/iHT88xYqEGIA=
 sigs.k8s.io/kustomize/api v0.8.8/go.mod h1:He1zoK0nk43Pc6NlV085xDXDXTNprtcyKZVm3swsdNY=