Skip to content
Merged
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
26 changes: 26 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: SAP SE or an SAP affiliate company
# SPDX-License-Identifier: Apache-2.0

root = true

[*]
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

[{Makefile,go.mod,go.sum,*.go}]
indent_style = tab
indent_size = unset

[*.md]
trim_trailing_whitespace = false

[{LICENSE,LICENSES/*,vendor/**}]
charset = unset
end_of_line = unset
indent_size = unset
indent_style = unset
insert_final_newline = unset
trim_trailing_whitespace = unset
3 changes: 3 additions & 0 deletions api/v1/eviction_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ type EvictionSpec struct {
}

const (
// ConditionTypeMigration is the type of condition for migration status of a server
ConditionTypeMigration = "MigratingInstance"

// ConditionTypePreflight is a condition for preflight checks, e.g. OS Hypervisor validation
ConditionTypePreflight = "PreflightChecksSucceeded"

Expand Down
11 changes: 11 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"flag"
"os"

kvmv1alpha1 "github.com/cobaltcore-dev/kvm-node-agent/api/v1alpha1"
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
_ "k8s.io/client-go/plugin/pkg/client/auth"
Expand Down Expand Up @@ -56,6 +57,8 @@ func init() {
// +kubebuilder:scaffold:scheme

utilruntime.Must(cmapi.AddToScheme(scheme))

utilruntime.Must(kvmv1alpha1.AddToScheme(scheme))
}

func main() {
Expand Down Expand Up @@ -160,6 +163,14 @@ func main() {
os.Exit(1)
}

if err = (&controller.HypervisorController{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Hypervisor")
os.Exit(1)
}

if err = (&controller.EvictionReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module github.com/cobaltcore-dev/openstack-hypervisor-operator
go 1.24.4

require (
github.com/cobaltcore-dev/kvm-node-agent v0.0.0-20250821153446-8085302c1d22
github.com/go-openapi/swag v0.23.1
github.com/gophercloud/gophercloud/v2 v2.7.0
github.com/gophercloud/utils/v2 v2.0.0-20250711132455-9770683b100a
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/cert-manager/cert-manager v1.18.2 h1:H2P75ycGcTMauV3gvpkDqLdS3RSXonWF
github.com/cert-manager/cert-manager v1.18.2/go.mod h1:icDJx4kG9BCNpGjBvrmsFd99d+lXUvWdkkcrSSQdIiw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cobaltcore-dev/kvm-node-agent v0.0.0-20250821153446-8085302c1d22 h1:VZsnEJH+X5pSQ3lrXri5niDL3iNEeb1FOuu6toBd/Io=
github.com/cobaltcore-dev/kvm-node-agent v0.0.0-20250821153446-8085302c1d22/go.mod h1:sn1SVcUVxzuQPhOo3hKdK8LbF8jSopRDP4ZXIe5EfYc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
35 changes: 33 additions & 2 deletions internal/controller/eviction_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,12 @@ func (r *EvictionReconciler) evictNext(ctx context.Context, eviction *kvmv1.Evic
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
log.Info("Instance is gone")
*instances = (*instances)[:len(*instances)-1]
meta.SetStatusCondition(&eviction.Status.Conditions, metav1.Condition{
Type: kvmv1.ConditionTypeMigration,
Status: metav1.ConditionFalse,
Message: fmt.Sprintf("Instance %s is gone", uuid),
Reason: kvmv1.ConditionReasonSuceeded,
})
return ctrl.Result{}, r.Status().Update(ctx, eviction)
}
return reconcile.Result{}, err
Expand All @@ -261,6 +267,12 @@ func (r *EvictionReconciler) evictNext(ctx context.Context, eviction *kvmv1.Evic
copy((*instances)[1:], (*instances)[:len(*instances)-1])
(*instances)[0] = uuid
log.Info("error", "faultMessage", vm.Fault.Message)
meta.SetStatusCondition(&eviction.Status.Conditions, metav1.Condition{
Type: kvmv1.ConditionTypeMigration,
Status: metav1.ConditionFalse,
Message: fmt.Sprintf("Migration of instance %s failed: %s", vm.ID, vm.Fault.Message),
Reason: kvmv1.ConditionReasonFailed,
})
if err := r.Status().Update(ctx, eviction); err != nil {
return ctrl.Result{}, err
}
Expand All @@ -272,6 +284,13 @@ func (r *EvictionReconciler) evictNext(ctx context.Context, eviction *kvmv1.Evic

if currentHypervisor != eviction.Spec.Hypervisor {
log.Info("migrated")
meta.SetStatusCondition(&eviction.Status.Conditions, metav1.Condition{
Type: kvmv1.ConditionTypeMigration,
Status: metav1.ConditionFalse,
Message: fmt.Sprintf("Migration of instance %s finished", vm.ID),
Reason: kvmv1.ConditionReasonSuceeded,
})

// So, it is already off this one, do we need to verify it?
if vm.Status == "VERIFY_RESIZE" {
if err := servers.ConfirmResize(ctx, r.computeClient, vm.ID).ExtractErr(); err != nil {
Expand All @@ -296,11 +315,17 @@ func (r *EvictionReconciler) evictNext(ctx context.Context, eviction *kvmv1.Evic
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
log.Info("Instance is gone")
// Fall-back to beginning, which will clean it out
return reconcile.Result{Requeue: true}, nil
return reconcile.Result{RequeueAfter: 0}, nil
}
copy((*instances)[1:], (*instances)[:len(*instances)-1])
(*instances)[0] = uuid

meta.SetStatusCondition(&eviction.Status.Conditions, metav1.Condition{
Type: kvmv1.ConditionTypeMigration,
Status: metav1.ConditionTrue,
Message: fmt.Sprintf("Live migration of instance %s triggered", vm.ID),
Reason: kvmv1.ConditionReasonRunning,
})
if err2 := r.Status().Update(ctx, eviction); err != nil {
return ctrl.Result{}, fmt.Errorf("could not live-migrate due to %w and %w", err, err2)
}
Expand All @@ -312,11 +337,17 @@ func (r *EvictionReconciler) evictNext(ctx context.Context, eviction *kvmv1.Evic
if err := r.coldMigrate(ctx, vm.ID, eviction); err != nil {
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
log.Info("Instance is gone")
return reconcile.Result{Requeue: true}, nil
return reconcile.Result{RequeueAfter: 0}, nil
}
copy((*instances)[1:], (*instances)[:len(*instances)-1])
(*instances)[0] = uuid

meta.SetStatusCondition(&eviction.Status.Conditions, metav1.Condition{
Type: kvmv1.ConditionTypeMigration,
Status: metav1.ConditionTrue,
Message: fmt.Sprintf("Cold-migration of instance %s triggered", vm.ID),
Reason: kvmv1.ConditionReasonRunning,
})
if err2 := r.Status().Update(ctx, eviction); err != nil {
return ctrl.Result{}, fmt.Errorf("could not cold-migrate due to %w and %w", err, err2)
}
Expand Down
109 changes: 109 additions & 0 deletions internal/controller/hypervisor_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors
SPDX-License-Identifier: Apache-2.0

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 controller

import (
"context"
"fmt"

corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logger "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"

kvmv1alpha1 "github.com/cobaltcore-dev/kvm-node-agent/api/v1alpha1"
)

type HypervisorController struct {
k8sclient.Client
Scheme *runtime.Scheme
}

// +kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=nodes/status,verbs=get
// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors,verbs=get;list;watch;create;delete
func (hv *HypervisorController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logger.FromContext(ctx).WithName(req.Name)

node := &corev1.Node{}
if err := hv.Get(ctx, req.NamespacedName, node); err != nil {
// Ignore not found errors, could be deleted
return ctrl.Result{}, k8sclient.IgnoreNotFound(err)
}

hypervisor := &kvmv1alpha1.Hypervisor{
ObjectMeta: metav1.ObjectMeta{
Name: node.Name,
Labels: map[string]string{
corev1.LabelHostname: node.Name,
},
},
}

// Ensure corresponding hypervisor exists
log.Info("Reconcile", "name", req.Name, "namespace", req.Namespace)
if err := hv.Get(ctx, k8sclient.ObjectKeyFromObject(hypervisor), hypervisor); err != nil {
if k8serrors.IsNotFound(err) {
// attach ownerReference for cascading deletion
if err = controllerutil.SetControllerReference(node, hypervisor, hv.Scheme); err != nil {
return ctrl.Result{}, fmt.Errorf("failed setting controller reference: %w", err)
}

log.Info("Setup hypervisor", "name", node.Name)
if err = hv.Create(ctx, hypervisor); err != nil {
return ctrl.Result{}, err
}

// Requeue to update status
return ctrl.Result{}, nil
}

return ctrl.Result{}, err
}

if node.DeletionTimestamp != nil {
// node is being deleted, cleanup hypervisor
if err := hv.Delete(ctx, hypervisor); k8sclient.IgnoreNotFound(err) != nil {
return ctrl.Result{}, fmt.Errorf("failed cleaning up hypervisor: %w", err)
}
}

return ctrl.Result{}, nil
}

func (hv *HypervisorController) SetupWithManager(mgr ctrl.Manager) error {
novaVirtLabeledPredicate, err := predicate.LabelSelectorPredicate(metav1.LabelSelector{
MatchLabels: map[string]string{
labelHypervisor: "true",
},
})
if err != nil {
return fmt.Errorf("failed to create label selector predicate: %w", err)
}

return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Node{}).
Owns(&kvmv1alpha1.Hypervisor{}).
WithEventFilter(novaVirtLabeledPredicate).
Complete(hv)
}
Loading