Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Kubernetes Cluster controller and add support for meta.externalName #43

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apis/civo/cluster/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,16 @@ type CivoKubernetesConnectionDetails struct {
type CivoKubernetesSpec struct {
xpv1.ResourceSpec `json:",inline"`
Name string `json:"name"`
Region string `json:"region,omitempty"`
Copy link
Collaborator

Choose a reason for hiding this comment

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

The destination region is set in the ProviderConfig, so we're not setting in the spec of the resource, so I would keep as it is

Pools []civogo.KubernetesClusterPoolConfig `json:"pools"`
// +optional
// A list of applications to install from civo marketplace.
Applications []string `json:"applications,omitempty"`
ConnectionDetails CivoKubernetesConnectionDetails `json:"connectionDetails"`
// +required
// +immutable
// NOTE: This can only be set at creation time. Changing this value after creation will not move the cluster into another network..
NetworkID *string `json:"networkId"`
Copy link
Collaborator

Choose a reason for hiding this comment

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

interesting addition, I would set is as optional to be backward compatible and I would add the optional firewallID and the optional firewallRule because civo API use these. If they're absent (as is situation), we still use the default ones.

Copy link
Author

Choose a reason for hiding this comment

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

I see, the network is actually required in the API, but the Terraform module has this field as optional. This is because when the network is missing, it queries the Civo API to find out the default network ID by doing client.GetDefaultNetwork().

I'll add the same thing here, and it should work to get the network back to optional, and make it consistent with Terraform.

// +optional
// +kubebuilder:validation:Enum=flannel;cilium
// +kubebuilder:default=flannel
Expand All @@ -59,7 +64,9 @@ type CivoKubernetesSpec struct {
// If not set, the default kubernetes version(1.22.2-k31) will be used.
// If set, the value must be a valid kubernetes version, you can use the following command to get the valid versions: `civo k3s versions`
// Changing the version to a higher version will upgrade the cluster. Note that this may cause breaking changes to the Kubernetes API so please check kubernetes deprecations/mitigations before upgrading.
Version *string `json:"version,omitempty"`
Version *string `json:"version,omitempty"`
Tags []string `json:"tags,omitempty"`
FirewallID *string `json:"firewallId,omitempty"`
}

// A CivoKubernetesStatus represents the observed state of a CivoKubernetes.
Expand Down
15 changes: 15 additions & 0 deletions apis/civo/cluster/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build
17 changes: 11 additions & 6 deletions examples/civo/cluster/cluster.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
kind: CivoKubernetes
apiVersion: cluster.civo.crossplane.io/v1alpha1
metadata:
metadata:
name: test-crossplane
spec:
name: test-crossplane
region: FRA1
networkId: 2b192fd5-7d33-42a9-95a1-faf2d9712871
firewallId: 2184b1cd-35bc-4dd7-9405-c7486c3c5141
tags:
- "test-tag"
pools:
- id: "8382e422-dcdd-461f-afb4-2ab67f171c3e"
Copy link
Collaborator

Choose a reason for hiding this comment

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

it's very uneasy to generate UUID and pass to the manifest, but it doesn't mean we can sent friendly names like these, because they must be unique in our database. I would left like it is. In the API we can auto generate those, but then it means we cannot update through crossplane if we don't provide a reference at all.

- id: "pool-small"
count: 2
size: g3.k3s.small
- id: "8482f422-dcdd-461g-afb4-2ab67f171c3e"
count: 1
- id: "pool-small-2"
count: 2
size: g3.k3s.small
applications:
applications:
- "argo-cd"
- "prometheus-operator"
version: "1.22.2-k3s1"
version: "1.23.6-k3s1"
connectionDetails:
connectionSecretNamePrefix: "cluster-details"
connectionSecretNamespace: "default"
Expand Down
185 changes: 163 additions & 22 deletions internal/controller/civokubernetes/civokubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ package civokubernetes
import (
"context"
"fmt"
"sort"
"strings"

"github.com/civo/civogo"
"github.com/crossplane-contrib/provider-civo/apis/civo/cluster/v1alpha1"
v1alpha1provider "github.com/crossplane-contrib/provider-civo/apis/civo/provider/v1alpha1"
"github.com/crossplane-contrib/provider-civo/pkg/civocli"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/event"
"github.com/crossplane/crossplane-runtime/pkg/logging"
"github.com/crossplane/crossplane-runtime/pkg/meta"
"github.com/crossplane/crossplane-runtime/pkg/ratelimiter"
"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
"github.com/crossplane/crossplane-runtime/pkg/resource"
Expand All @@ -38,7 +39,7 @@ type connecter struct {

type external struct {
kube client.Client
civoClient *civocli.CivoClient
civoClient *civogo.Client
}

// Setup sets up a Civo Kubernetes controller.
Expand Down Expand Up @@ -84,7 +85,7 @@ func (c *connecter) Connect(ctx context.Context, mg resource.Managed) (managed.E
return nil, errors.New("could not find secret")
}

civoClient, err := civocli.NewCivoClient(string(s.Data["credentials"]), providerConfig.Spec.Region)
civoClient, err := civogo.NewClient(string(s.Data["credentials"]), providerConfig.Spec.Region)

Comment on lines -87 to 89
Copy link
Collaborator

Choose a reason for hiding this comment

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

why removing the civocli wrapper object?

Copy link
Author

Choose a reason for hiding this comment

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

civocli looks to be an old package committed to this repo. In the meantime, I see that the new API is implemented in the civogo client. I think this is the right way to go, perhaps the API team can confirm?

if err != nil {
return nil, err
Expand All @@ -95,15 +96,15 @@ func (c *connecter) Connect(ctx context.Context, mg resource.Managed) (managed.E
}, nil
}

//nolint
// nolint
func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
cr, ok := mg.(*v1alpha1.CivoKubernetes)
if !ok {
return managed.ExternalObservation{}, errors.New("invalid object")
}
civoCluster, err := e.civoClient.GetK3sCluster(cr.Spec.Name)
civoCluster, err := e.civoClient.GetKubernetesCluster(meta.GetExternalName(cr.GetObjectMeta()))
Comment on lines -104 to +105
Copy link
Collaborator

Choose a reason for hiding this comment

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

Not sure what meta.GetExternalName would return for a crossplane resource: it should fetch the annotation crossplane.io/external-name but this doesn't correspond to the clusterID as it's requested by the GetKubernetesCluster method (that now, as I see, it's never used).

if err != nil {
return managed.ExternalObservation{ResourceExists: false}, err
return managed.ExternalObservation{}, nil
Comment on lines -106 to +107
Copy link
Collaborator

Choose a reason for hiding this comment

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

if err != nil why removing the error return and return nil error?

}
if civoCluster == nil {
return managed.ExternalObservation{ResourceExists: false}, nil
Expand Down Expand Up @@ -142,18 +143,23 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
return managed.ExternalObservation{ResourceExists: true}, err
}
}
// --------------------------------------------
_, err = e.Update(ctx, mg)
if err != nil {
log.Warnf("update error:%s ", err.Error())
}
// --------------------------------------------
// UPDATE CHECK --------------------------------------------
cr.SetConditions(xpv1.Available())
upToDate, _ := e.ResourceIsUpToDate(ctx, cr, civoCluster)

if upToDate {
cr.Status.Message = "Cluster is up to date"
} else {
cr.Status.Message = "Cluster is being updated"
}

Comment on lines -145 to +155
Copy link
Collaborator

Choose a reason for hiding this comment

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

not sure about the current logic and why we try to update the cluster in the Observe @RealHarshThakur any thoughts about it? At the meantime, you @vladfr removed this logic completely. Not sure what is a more correct approach here.

return managed.ExternalObservation{
ResourceExists: true,
ResourceUpToDate: true,
ResourceUpToDate: upToDate,
ConnectionDetails: cd,
}, nil
// --------------------------------------------

case "BUILDING":
cr.Status.Message = "Cluster is being created"
cr.SetConditions(xpv1.Creating())
Expand All @@ -170,33 +176,102 @@ func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext
if !ok {
return managed.ExternalCreation{}, errors.New("invalid object")
}
civoCluster, err := e.civoClient.GetK3sCluster(cr.Spec.Name)

// at the first call, this id will be the cluster name; civo should return 404
existingClusterID := meta.GetExternalName(cr.GetObjectMeta())
civoCluster, err := e.civoClient.GetKubernetesCluster(existingClusterID)
Comment on lines -173 to +182
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same as above: not sure what meta.GetExternalName would return for a crossplane resource: it should fetch the annotation crossplane.io/external-name but this doesn't correspond to the clusterID as it's requested by the GetKubernetesCluster method (that now, as I see, it's never used).

if err != nil {
return managed.ExternalCreation{}, err
if civogo.DatabaseKubernetesClusterNotFoundError.Is(err) {
// 404 cluster not found, we continue with the create
} else {
// cluster lookup error, return
return managed.ExternalCreation{}, err
}
}
if civoCluster != nil {
return managed.ExternalCreation{}, nil
}

clusterRegion := cr.Spec.Region
if clusterRegion == "" {
clusterRegion = e.civoClient.Region
}

Comment on lines +194 to +199
Copy link
Collaborator

Choose a reason for hiding this comment

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

we don't use region in the spec, it's implicit in the provider configs

// Create or Update
err = e.civoClient.CreateNewK3sCluster(cr.Spec.Name, cr.Spec.Pools, cr.Spec.Applications, cr.Spec.CNIPlugin, cr.Spec.Version)
kc := &civogo.KubernetesClusterConfig{
Name: cr.Spec.Name,
Region: clusterRegion,
NetworkID: *cr.Spec.NetworkID,
Pools: cr.Spec.Pools,
Applications: strings.Join(cr.Spec.Applications, ","),
Tags: strings.Join(cr.Spec.Tags, " "),
}

if cr.Spec.CNIPlugin != nil {
kc.CNIPlugin = *cr.Spec.CNIPlugin
}
if cr.Spec.Version != nil {
kc.KubernetesVersion = *cr.Spec.Version
}

if cr.Spec.FirewallID != nil {
kc.InstanceFirewall = *cr.Spec.FirewallID
}

newCluster, err := e.civoClient.NewKubernetesClusters(kc)
Comment on lines -181 to +221
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would readd the wrapper function and move the creation of the createCluster request in the originary class. Besides that, if you added optional networkID, firewallID, firewallRule I would pass those to the request OR, in their absence, I would pass the default network ID as it is now, without removing that logic.

if err != nil {
log.Warn("Cluster creation failed", err)
return managed.ExternalCreation{}, err
}

meta.SetExternalName(cr, newCluster.ID)

Comment on lines +227 to +228
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is interesting, effectively then you can use the function you proposed above to directly fetch the cluster by ID :claps

Copy link
Author

Choose a reason for hiding this comment

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

Yes, external-name is Crossplane magic to allow mapping Cloud IDs to Kubernetes IDs. During Create, a controller is supposed to fetch it and write it here.

Civo API uses cloud-generated IDs for resource identification, so this maps to using external-name in Crossplane. It looks like the Civo API always generates IDs, so this should be the main way of doing things. Because you can reference this ID directly to the API in most cases, and you would get the resource back. This is exactly how Upjet providers implement this functionality, so the main AWS/GCP/Azure Crossplane providers all store the Cloud IDs via external-name.

Your comment above about pools IDs is interesting, I need to look more into that. Right now, I don't use the pools api to directly update pools, since there is no pools CRD. So this works for pool updates, just because it uses a cluster-scoped Civo API.

cr.SetConditions(xpv1.Creating())

return managed.ExternalCreation{
ExternalNameAssigned: true,
}, nil
}

func (e *external) ResourceIsUpToDate(ctx context.Context, mg resource.Managed, remote *civogo.KubernetesCluster) (bool, error) {
desired, ok := mg.(*v1alpha1.CivoKubernetes)
if !ok {
return false, errors.New("invalid object")
}

if len(desired.Spec.Pools) != len(remote.Pools) || !arePoolsEqual(desired, remote) {
return false, nil
}

if desired.Spec.Version != nil && *desired.Spec.Version > remote.Version {
return false, nil
}

if stringSlicesNeedUpdate(desired.Spec.Tags, remote.Tags) {
return false, nil
}

// nolint
var remoteAppNames []string
for _, app := range remote.InstalledApplications {
remoteAppNames = append(remoteAppNames, app.Name)
}

if stringSlicesNeedUpdate(desired.Spec.Applications, remoteAppNames) {
return false, nil
}

return true, nil
}

// nolint
func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) {
desiredCivoCluster, ok := mg.(*v1alpha1.CivoKubernetes)
if !ok {
return managed.ExternalUpdate{}, errors.New("invalid object")
}
remoteCivoCluster, err := e.civoClient.GetK3sCluster(desiredCivoCluster.Spec.Name)
desiredClusterID := meta.GetExternalName(desiredCivoCluster.GetObjectMeta())
remoteCivoCluster, err := e.civoClient.GetKubernetesCluster(desiredClusterID)
if err != nil {
return managed.ExternalUpdate{}, err
}
Expand All @@ -215,30 +290,95 @@ func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext
if len(desiredCivoCluster.Spec.Pools) != len(remoteCivoCluster.Pools) || !arePoolsEqual(desiredCivoCluster, remoteCivoCluster) {

log.Debug("Pools are not equal")
//TODO: Set region in the civo client once to avoid passing the providerConfig
if err := e.civoClient.UpdateK3sCluster(desiredCivoCluster, remoteCivoCluster, providerConfig); err != nil {
desiredClusterConfig := &civogo.KubernetesClusterConfig{
Pools: desiredCivoCluster.Spec.Pools,
Region: desiredCivoCluster.Spec.Region,
Copy link
Collaborator

Choose a reason for hiding this comment

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

@RealHarshThakur thoughts if we should introduce Region in the crossplane resource's spec? Now we don't have those.

Copy link
Author

Choose a reason for hiding this comment

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

Region is required by the API, so this is why it needs to be passed here. I think the CRD should have it too, it would be good to follow the API as close as possible. And right now, it still picks up the default Region from the ProviderConfig.

Another argument in favour of the region is that if you want to deploy in multiple regions, you can use the same ProviderConfig.

}
if _, err := e.civoClient.UpdateKubernetesCluster(desiredClusterID, desiredClusterConfig); err != nil {
return managed.ExternalUpdate{}, err
}
}

if desiredCivoCluster.Spec.Version != nil {
if *desiredCivoCluster.Spec.Version > remoteCivoCluster.Version {
log.Info("Updating cluster version")
if err := e.civoClient.UpdateK3sClusterVersion(desiredCivoCluster, remoteCivoCluster, providerConfig); err != nil {
desiredClusterConfig := &civogo.KubernetesClusterConfig{
Name: desiredCivoCluster.Name,
KubernetesVersion: *desiredCivoCluster.Spec.Version,
Region: desiredCivoCluster.Spec.Region,
}
if _, err := e.civoClient.UpdateKubernetesCluster(desiredClusterID, desiredClusterConfig); err != nil {
return managed.ExternalUpdate{}, err
}
}
}

if desiredCivoCluster.Spec.FirewallID != nil && desiredCivoCluster.Spec.FirewallID != &remoteCivoCluster.FirewallID {
desiredClusterConfig := &civogo.KubernetesClusterConfig{
Name: desiredCivoCluster.Name,
InstanceFirewall: *desiredCivoCluster.Spec.FirewallID,
Region: desiredCivoCluster.Spec.Region,
}
if _, err := e.civoClient.UpdateKubernetesCluster(desiredClusterID, desiredClusterConfig); err != nil {
return managed.ExternalUpdate{}, err
}
}

if stringSlicesNeedUpdate(desiredCivoCluster.Spec.Tags, remoteCivoCluster.Tags) {
desiredClusterConfig := &civogo.KubernetesClusterConfig{
Name: desiredCivoCluster.Name,
Tags: strings.Join(desiredCivoCluster.Spec.Tags, " "),
Region: desiredCivoCluster.Spec.Region,
}
if _, err := e.civoClient.UpdateKubernetesCluster(desiredClusterID, desiredClusterConfig); err != nil {
return managed.ExternalUpdate{}, err
}
}

// nolint
var remoteAppNames []string
for _, app := range remoteCivoCluster.InstalledApplications {
remoteAppNames = append(remoteAppNames, app.Name)
}

if stringSlicesNeedUpdate(desiredCivoCluster.Spec.Applications, remoteAppNames) {
desiredClusterConfig := &civogo.KubernetesClusterConfig{
Name: desiredCivoCluster.Name,
Applications: strings.Join(desiredCivoCluster.Spec.Applications, " "),
Region: desiredCivoCluster.Spec.Region,
}
if _, err := e.civoClient.UpdateKubernetesCluster(desiredClusterID, desiredClusterConfig); err != nil {
return managed.ExternalUpdate{}, err
}
}

return managed.ExternalUpdate{}, nil
}

func stringSlicesNeedUpdate(desired, remote []string) bool {
if len(desired) != len(remote) {
return true
} else if len(desired) == 0 {
return false
}

sort.Strings(desired)
sort.Strings(remote)

for i := range desired {
if desired[i] != remote[i] {
return true
}
}
return false
}

func (e *external) Delete(ctx context.Context, mg resource.Managed) error {
cr, ok := mg.(*v1alpha1.CivoKubernetes)
if !ok {
return nil
}
civoCluster, err := e.civoClient.GetK3sCluster(cr.Spec.Name)
civoCluster, err := e.civoClient.GetKubernetesCluster(meta.GetExternalName(cr.GetObjectMeta()))
if err != nil {
return err
}
Expand All @@ -262,7 +402,8 @@ func (e *external) Delete(ctx context.Context, mg resource.Managed) error {
// ------------------------------------------------
cr.Status.Message = deletionMessage
cr.SetConditions(xpv1.Deleting())
return e.civoClient.DeleteK3sCluster(civoCluster.Name)
_, err = e.civoClient.DeleteKubernetesCluster(civoCluster.ID)
return err
}

func arePoolsEqual(desiredCivoCluster *v1alpha1.CivoKubernetes, remoteCivoCluster *civogo.KubernetesCluster) bool {
Expand Down
Loading