Files
cozystack/internal/controller/workloadmonitor_controller.go
Aleksei Sviridkin 2a7f6cd7bc feat(cozystack-controller): stop counting device GPUs on virt-launcher pods
Each GPUDevice now has a usage record of its own, so the virt-launcher
pod of a VMI with device references would report the same GPUs a
second time. The units the VMI's attached devices hold are subtracted
after the resources annotation is merged, since that annotation may
name the GPU too; names the devices do not cover, such as a NIC next to
the GPU, stay reported.

The Workload controller already deletes a record only when its monitor
is gone, which is what lets GPU records live under a monitor that
selects nothing; a test now pins that.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-21 05:24:04 +03:00

1253 lines
45 KiB
Go

package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/wait"
toolscache "k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/record"
"k8s.io/client-go/util/retry"
"k8s.io/client-go/util/workqueue"
"k8s.io/utils/pointer"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
gpuv1alpha1 "github.com/cozystack/cozystack/api/gpu/v1alpha1"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
"github.com/cozystack/cozystack/internal/gpu"
cosiv1alpha1 "sigs.k8s.io/container-object-storage-interface-api/apis/objectstorage/v1alpha1"
)
const (
// namespaceMonitoringLabel is the namespace label that indicates which tenant
// namespace hosts the monitoring stack (VictoriaMetrics/Prometheus).
namespaceMonitoringLabel = "namespace.cozystack.io/monitoring"
workloadLabelPrefix = "workloads.cozystack.io/"
// workloadMonitorLabel is reserved: it names the WorkloadMonitor that owns
// the Workload and is always set by the reconciler, so it is never copied
// from monitor labels.
workloadMonitorLabel = workloadLabelPrefix + "monitor"
// vmSelectService is the well-known service name for VictoriaMetrics vmselect
// within a monitoring namespace. Port 8481, path /select/0/prometheus.
vmSelectService = "vmselect-shortterm"
vmSelectPort = "8481"
vmSelectPath = "/select/0/prometheus"
// Resource keys reported on bucket Workloads.
resourceS3StorageBytes = "s3-storage-bytes"
resourceS3PhysicalStorageBytes = "s3-physical-storage-bytes"
)
// prometheusHTTPClient is a dedicated HTTP client for Prometheus queries,
// avoiding the shared http.DefaultClient global.
var prometheusHTTPClient = &http.Client{Timeout: 10 * time.Second}
// WorkloadMonitorReconciler reconciles a WorkloadMonitor object
type WorkloadMonitorReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
// SeaweedfsMetricsEndpoint, when non-empty, is the base URL of the
// Prometheus-compatible query API to fetch SeaweedFS bucket size metrics
// from, instead of discovering the monitoring stack via the
// namespace.cozystack.io/monitoring label. Used when SeaweedFS and the
// stack that scrapes it run outside this cluster.
SeaweedfsMetricsEndpoint string
// DataVolumeReader reads CDI DataVolumes. Nil until the DataVolume informer
// has synced; until then a monitor keeps its stored DataVolume verdict.
DataVolumeReader client.Reader
dataVolumeMu sync.RWMutex
// dataVolumeWatchSyncing is set while the DataVolume watch is started and
// its reader is not installed yet. The source replays every DataVolume to
// the handler in that window, and a reconcile it queues must be requeued:
// nothing queues it again once the reader is in place.
dataVolumeWatchSyncing bool
// dataVolumeWatchStarted is set once Watch has started the DataVolume
// source. A started Kind source adds an event handler to the informer and
// never removes it, so a retry waits for the sync instead of watching again.
dataVolumeWatchStarted bool
// reconciled is closed by the first Reconcile. controller-runtime starts the
// workers only after the controller has started its sources
// (pkg/internal/controller/controller.go, Controller.Start), so from then on
// Watch starts a new source itself instead of queueing it for Start.
reconciled chan struct{}
reconciledOnce sync.Once
}
const (
// dataVolumeAPIPollInterval is how often the controller looks for the
// DataVolume kind until CDI is installed.
dataVolumeAPIPollInterval = 30 * time.Second
// dataVolumeSyncTimeout bounds the wait for the DataVolume informer to sync.
// A reconcile context has no deadline, so a reader installed on an informer
// that never syncs would park every WorkloadMonitor reconcile for good.
dataVolumeSyncTimeout = time.Minute
dataVolumeSyncRequeue = 5 * time.Second
)
var errDataVolumeWatchSyncing = errors.New("the DataVolume watch has not synced yet")
func (r *WorkloadMonitorReconciler) dataVolumeReader() (client.Reader, error) {
r.dataVolumeMu.RLock()
defer r.dataVolumeMu.RUnlock()
if r.DataVolumeReader == nil && r.dataVolumeWatchSyncing {
return nil, errDataVolumeWatchSyncing
}
return r.DataVolumeReader, nil
}
func (r *WorkloadMonitorReconciler) reconciledCh() chan struct{} {
r.dataVolumeMu.Lock()
defer r.dataVolumeMu.Unlock()
if r.reconciled == nil {
r.reconciled = make(chan struct{})
}
return r.reconciled
}
func (r *WorkloadMonitorReconciler) setDataVolumeWatchSyncing(syncing bool) {
r.dataVolumeMu.Lock()
defer r.dataVolumeMu.Unlock()
r.dataVolumeWatchSyncing = syncing
}
var dataVolumeGVK = schema.GroupVersionKind{Group: "cdi.kubevirt.io", Version: "v1beta1", Kind: "DataVolume"}
// dataVolumeInFlightPhases are the DataVolumePhase values in which CDI still has
// work to do on the disk (containerized-data-importer-api pkg/apis/core/v1beta1).
// A phase outside this set counts as settled, so a CDI release that adds or
// renames a phase cannot turn working disks non-operational; the price is that
// a renamed in-flight phase reads as settled until this list follows it.
// PendingPopulation and WaitForFirstConsumer are where a disk on a
// WaitForFirstConsumer class rests until a VM consumes it, so neither is in
// flight. UploadReady is in flight although it lasts until someone uploads:
// CDI holds it from the moment the upload server is ready until the transfer
// succeeds (pkg/controller/datavolume/upload-controller.go), so it covers an
// empty disk and a half-uploaded one alike.
// Paused is where a multi-stage (checkpoint) import waits for its next
// checkpoint, which only an outside actor supplies; the
// vm-disk chart renders no checkpoint source, so no disk of it gets there.
var dataVolumeInFlightPhases = map[string]bool{
"Pending": true,
"PVCBound": true,
"ImportScheduled": true,
"ImportInProgress": true,
"CloneScheduled": true,
"CloneInProgress": true,
"SnapshotForSmartCloneInProgress": true,
"CloneFromSnapshotSourceInProgress": true,
"SmartClonePVCInProgress": true,
"CSICloneInProgress": true,
"PrepClaimInProgress": true,
"RebindInProgress": true,
"ExpansionInProgress": true,
"NamespaceTransferInProgress": true,
"UploadScheduled": true,
"UploadReady": true,
}
// isDataVolumeReady reports whether CDI is done with the disk. Failed and
// Unknown are not ready, and neither is a DataVolume without a phase: CDI
// leaves one there when it could not render the PVC spec.
func isDataVolumeReady(dv *unstructured.Unstructured) bool {
phase, _, _ := unstructured.NestedString(dv.Object, "status", "phase")
switch phase {
case "", "Failed", "Unknown":
return false
}
return !dataVolumeInFlightPhases[phase]
}
// dataVolumeFailureMessage returns why the DataVolume's pod is not running, or
// "" when nothing says so. A source that keeps failing (404, DNS) never moves
// the phase off ImportInProgress, because CDI only fails the import on a fatal
// error and restarts the pod otherwise (updateStatusPhase in
// pkg/controller/datavolume/import-controller.go). The Running condition holds
// the clean error text: CDI keeps it through the back-off between restarts and
// clears it while the pod runs, and Bound repeats it behind PVC details.
// Unknown is not a failure, so only False is read. For a host-assisted clone
// CDI joins the target and source messages as "<target> and <source>"
// (updateWithTargetNotRunning in pkg/controller/datavolume/conditions.go), so
// empty halves are dropped instead of reporting " and ".
func dataVolumeFailureMessage(dv *unstructured.Unstructured) string {
conditions, _, _ := unstructured.NestedSlice(dv.Object, "status", "conditions")
for _, c := range conditions {
cond, ok := c.(map[string]any)
if !ok || cond["type"] != "Running" || cond["status"] != "False" {
continue
}
message, _ := cond["message"].(string)
var parts []string
for _, part := range strings.Split(message, " and ") {
if part != "" {
parts = append(parts, part)
}
}
return strings.Join(parts, " and ")
}
return ""
}
// dataVolumeAPIServed treats a NoMatch as not served and returns any other
// discovery error, so the caller retries instead of concluding CDI is absent.
func dataVolumeAPIServed(mapper meta.RESTMapper) (bool, error) {
_, err := mapper.RESTMapping(dataVolumeGVK.GroupKind(), dataVolumeGVK.Version)
if meta.IsNoMatchError(err) {
return false, nil
}
return err == nil, err
}
type sourceWatcher interface {
Watch(src source.Source) error
}
// tryStartDataVolumeWatch registers the DataVolume watch and reader once the
// kind is served, and reports whether it did. The reader is set only after the
// informer has synced: the cache blocks a List on an unsynced informer until
// the caller's context ends. The source is started once, on the manager's
// context, and a call that times out leaves it running for the next call to
// wait on.
func (r *WorkloadMonitorReconciler) tryStartDataVolumeWatch(ctx context.Context, mapper meta.RESTMapper, c sourceWatcher, informers cache.Cache, syncTimeout time.Duration) (bool, error) {
served, err := dataVolumeAPIServed(mapper)
if err != nil || !served {
return false, err
}
dv := &unstructured.Unstructured{}
dv.SetGroupVersionKind(dataVolumeGVK)
if !r.dataVolumeWatchStarted {
src := source.Kind[client.Object](informers, dv,
handler.EnqueueRequestsFromMapFunc(mapObjectToMonitor(client.Object(dv), r.Client)))
r.setDataVolumeWatchSyncing(true)
if err := c.Watch(src); err != nil {
r.setDataVolumeWatchSyncing(false)
return false, fmt.Errorf("watching DataVolumes: %w", err)
}
r.dataVolumeWatchStarted = true
}
syncCtx, cancel := context.WithTimeout(ctx, syncTimeout)
defer cancel()
informer, err := informers.GetInformer(syncCtx, dv)
if err == nil && !toolscache.WaitForCacheSync(syncCtx.Done(), informer.HasSynced) {
err = syncCtx.Err()
}
if ctxErr := ctx.Err(); ctxErr != nil {
return false, ctxErr
}
if err != nil {
return false, fmt.Errorf("waiting for the DataVolume informer to sync: %w", err)
}
r.dataVolumeMu.Lock()
// The manager's client reads unstructured objects straight from the API
// server; the cache shares the informer the watch above starts.
r.DataVolumeReader = informers
r.dataVolumeWatchSyncing = false
r.dataVolumeMu.Unlock()
// A reconcile that ran before the watch started kept its stored verdict and
// was not requeued, and the source's replay cannot deliver a DataVolume
// deleted while the controller was down.
if err := c.Watch(source.Func(r.enqueueMonitorsWithDataVolumeVerdict)); err != nil {
return false, fmt.Errorf("queueing monitors with a DataVolume verdict: %w", err)
}
return true, nil
}
func (r *WorkloadMonitorReconciler) enqueueMonitorsWithDataVolumeVerdict(ctx context.Context, queue workqueue.TypedRateLimitingInterface[reconcile.Request]) error {
var monitors cozyv1alpha1.WorkloadMonitorList
if err := r.List(ctx, &monitors); err != nil {
return err
}
for i := range monitors.Items {
if monitors.Items[i].Status.Message != "" {
queue.Add(reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&monitors.Items[i])})
}
}
return nil
}
// watchDataVolumes adds the DataVolume watch once the first reconcile has run
// and the kind is served, checking discovery again every interval until it is.
// Starting on the first reconcile rather than on the next tick keeps a disk
// created right after a controller restart from reading populated until then.
func (r *WorkloadMonitorReconciler) watchDataVolumes(ctx context.Context, mapper meta.RESTMapper, c sourceWatcher, informers cache.Cache, interval time.Duration) {
select {
case <-ctx.Done():
return
case <-r.reconciledCh():
}
logger := log.FromContext(ctx)
// The condition never returns an error, so the poll ends only when the
// watch is added or the manager stops.
_ = wait.PollUntilContextCancel(ctx, interval, true, func(ctx context.Context) (bool, error) {
done, err := r.tryStartDataVolumeWatch(ctx, mapper, c, informers, dataVolumeSyncTimeout)
if err != nil {
logger.Error(err, "Unable to start the DataVolume watch, retrying")
}
return done, nil
})
}
// dataVolumesMessage names every DataVolume the monitor selects that is not
// ready, with its phase, and is empty when there is none; notReady holds their
// names. A NoMatch from the reader counts as no DataVolumes. read is false when
// the DataVolumes could not be read: no reader yet, or a failed List.
func (r *WorkloadMonitorReconciler) dataVolumesMessage(ctx context.Context, monitor *cozyv1alpha1.WorkloadMonitor) (message string, notReady map[string]bool, read bool, err error) {
reader, err := r.dataVolumeReader()
if reader == nil {
return "", nil, false, err
}
list := &unstructured.UnstructuredList{}
list.SetGroupVersionKind(dataVolumeGVK.GroupVersion().WithKind(dataVolumeGVK.Kind + "List"))
if err := reader.List(
ctx,
list,
client.InNamespace(monitor.Namespace),
client.MatchingLabels(monitor.Spec.Selector),
); err != nil {
if meta.IsNoMatchError(err) {
return "", nil, true, nil
}
return "", nil, false, err
}
var stuck []string
notReady = map[string]bool{}
for i := range list.Items {
dv := &list.Items[i]
if isDataVolumeReady(dv) {
continue
}
notReady[dv.GetName()] = true
phase, _, _ := unstructured.NestedString(dv.Object, "status", "phase")
entry := fmt.Sprintf("DataVolume %s is %s", dv.GetName(), phase)
if phase == "" {
entry = fmt.Sprintf("DataVolume %s has no phase", dv.GetName())
}
if cause := dataVolumeFailureMessage(dv); cause != "" {
entry += ": " + cause
}
stuck = append(stuck, entry)
}
sort.Strings(stuck)
return strings.Join(stuck, "; "), notReady, true, nil
}
// +kubebuilder:rbac:groups=cozystack.io,resources=workloadmonitors,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=cozystack.io,resources=workloadmonitors/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=cozystack.io,resources=workloads,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=cozystack.io,resources=workloads/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch
// +kubebuilder:rbac:groups=core,resources=persistentvolumeclaims,verbs=get;list;watch
// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
// +kubebuilder:rbac:groups=objectstorage.k8s.io,resources=bucketclaims,verbs=get;list;watch
// +kubebuilder:rbac:groups=cdi.kubevirt.io,resources=datavolumes,verbs=get;list;watch
// isBucketClaimReady checks if the BucketClaim has been provisioned.
func (r *WorkloadMonitorReconciler) isBucketClaimReady(bc *cosiv1alpha1.BucketClaim) bool {
return bc.Status.BucketReady
}
// isServiceReady checks if the service has an external IP bound
func (r *WorkloadMonitorReconciler) isServiceReady(svc *corev1.Service) bool {
return len(svc.Status.LoadBalancer.Ingress) > 0
}
// isPVCReady checks if the PVC is bound
func (r *WorkloadMonitorReconciler) isPVCReady(pvc *corev1.PersistentVolumeClaim) bool {
return pvc.Status.Phase == corev1.ClaimBound
}
// isPodReady checks if the Pod is in the Ready condition.
func (r *WorkloadMonitorReconciler) isPodReady(pod *corev1.Pod) bool {
for _, c := range pod.Status.Conditions {
if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue {
return true
}
}
return false
}
// updateOwnerReferences adds the given monitor as a new owner reference to the object if not already present.
// It then sorts the owner references to enforce a consistent order.
func updateOwnerReferences(obj metav1.Object, monitor client.Object) {
// Retrieve current owner references
owners := obj.GetOwnerReferences()
// Check if current monitor is already in owner references
var alreadyOwned bool
for _, ownerRef := range owners {
if ownerRef.UID == monitor.GetUID() {
alreadyOwned = true
break
}
}
runtimeObj, ok := monitor.(runtime.Object)
if !ok {
return
}
gvk := runtimeObj.GetObjectKind().GroupVersionKind()
// If not already present, add new owner reference without controller flag
if !alreadyOwned {
newOwnerRef := metav1.OwnerReference{
APIVersion: gvk.GroupVersion().String(),
Kind: gvk.Kind,
Name: monitor.GetName(),
UID: monitor.GetUID(),
// Set Controller to false to avoid conflict as multiple controllers are not allowed
Controller: pointer.BoolPtr(false),
BlockOwnerDeletion: pointer.BoolPtr(true),
}
owners = append(owners, newOwnerRef)
}
// Sort owner references to enforce a consistent order by UID
sort.SliceStable(owners, func(i, j int) bool {
return owners[i].UID < owners[j].UID
})
// Update the owner references of the object
obj.SetOwnerReferences(owners)
}
// ParseMetricsEndpointURL validates an administrator-supplied metrics endpoint
// URL and normalizes it to a base without a trailing slash, so that appending
// /api/v1/query never doubles or drops a slash. Userinfo is allowed: net/http
// turns it into basic auth on every request.
func ParseMetricsEndpointURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("scheme must be http or https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("URL must be absolute with a host")
}
if u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("URL must not carry a query string or fragment")
}
return strings.TrimRight(u.String(), "/"), nil
}
// resolvePrometheusURL returns the Prometheus-compatible API base URL for the given namespace.
// The SeaweedfsMetricsEndpoint override, when configured, takes precedence; otherwise the
// namespace.cozystack.io/monitoring label names the monitoring namespace and the well-known
// vmselect URL inside it is constructed. Returns empty string if monitoring is not configured.
func (r *WorkloadMonitorReconciler) resolvePrometheusURL(ctx context.Context, namespace string) (string, error) {
if r.SeaweedfsMetricsEndpoint != "" {
return r.SeaweedfsMetricsEndpoint, nil
}
ns := &corev1.Namespace{}
if err := r.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
// The namespace is the monitor's own, so NotFound only happens while
// it is being deleted; there is no monitoring to resolve then.
if apierrors.IsNotFound(err) {
return "", nil
}
return "", fmt.Errorf("reading namespace %s: %w", namespace, err)
}
monitoringNS := ns.Labels[namespaceMonitoringLabel]
if monitoringNS == "" {
return "", nil
}
return fmt.Sprintf("http://%s.%s.svc:%s%s", vmSelectService, monitoringNS, vmSelectPort, vmSelectPath), nil
}
// bucketMetrics holds size metrics for a single bucket, keyed by metric name.
type bucketMetrics struct {
LogicalSize int64
PhysicalSize int64
HasLogical bool
HasPhysical bool
}
// queryAllBucketMetrics fetches SeaweedFS bucket size metrics for the given
// bucket names in a single Prometheus query and returns them keyed by bucket
// name. The query is scoped to only the requested buckets to avoid fetching
// metrics for buckets belonging to other WorkloadMonitors.
//
// Any failure to obtain a well-formed answer is returned as an error, never as
// an empty result: the sizes feed billing, and the caller must be able to tell
// "the endpoint said the bucket is empty" apart from "the endpoint could not
// be queried".
func (r *WorkloadMonitorReconciler) queryAllBucketMetrics(ctx context.Context, prometheusBaseURL string, bucketNames []string) (map[string]*bucketMetrics, error) {
result := make(map[string]*bucketMetrics)
if prometheusBaseURL == "" || len(bucketNames) == 0 {
return result, nil
}
query := fmt.Sprintf(`{__name__=~"SeaweedFS_s3_bucket_(size|physical_size)_bytes",bucket=~"%s"}`, strings.Join(bucketNames, "|"))
u, err := url.Parse(strings.TrimRight(prometheusBaseURL, "/") + "/api/v1/query")
if err != nil {
return nil, fmt.Errorf("parsing Prometheus URL: %w", err)
}
u.RawQuery = url.Values{"query": {query}}.Encode()
httpCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating Prometheus request: %w", err)
}
resp, err := prometheusHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("querying Prometheus: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Prometheus returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("reading Prometheus response: %w", err)
}
var promResp struct {
Status string `json:"status"`
Data struct {
Result []struct {
Metric map[string]string `json:"metric"`
Value [2]json.RawMessage `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &promResp); err != nil {
return nil, fmt.Errorf("parsing Prometheus response: %w", err)
}
if promResp.Status != "success" {
return nil, fmt.Errorf("Prometheus query finished with status %q", promResp.Status)
}
for _, r := range promResp.Data.Result {
bucket := r.Metric["bucket"]
metricName := r.Metric["__name__"]
if bucket == "" || metricName == "" {
continue
}
var valueStr string
if err := json.Unmarshal(r.Value[1], &valueStr); err != nil {
continue
}
val, err := strconv.ParseFloat(valueStr, 64)
if err != nil {
continue
}
bm, ok := result[bucket]
if !ok {
bm = &bucketMetrics{}
result[bucket] = bm
}
switch metricName {
case "SeaweedFS_s3_bucket_size_bytes":
bm.LogicalSize = int64(val)
bm.HasLogical = true
case "SeaweedFS_s3_bucket_physical_size_bytes":
bm.PhysicalSize = int64(val)
bm.HasPhysical = true
}
}
return result, nil
}
// reconcileBucketClaimForMonitor creates or updates a Workload object for the given BucketClaim and WorkloadMonitor.
func (r *WorkloadMonitorReconciler) reconcileBucketClaimForMonitor(
ctx context.Context,
monitor *cozyv1alpha1.WorkloadMonitor,
bc cosiv1alpha1.BucketClaim,
allMetrics map[string]*bucketMetrics,
) error {
logger := log.FromContext(ctx)
workload := &cozyv1alpha1.Workload{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("bucket-%s", bc.Name),
Namespace: bc.Namespace,
Labels: make(map[string]string, len(bc.Labels)),
},
}
monitorLabels := r.getMonitorLabels(monitor)
_, err := ctrl.CreateOrUpdate(ctx, r.Client, workload, func() error {
updateOwnerReferences(workload.GetObjectMeta(), &bc)
if workload.Labels == nil {
workload.Labels = make(map[string]string)
}
// Apply monitor-level labels first so source-object labels can override on conflict
maps.Copy(workload.Labels, monitorLabels)
maps.Copy(workload.Labels, bc.Labels)
workload.Labels[workloadMonitorLabel] = monitor.Name
delete(workload.Labels, "workloads.cozystack.io/bucket-class")
if bc.Spec.BucketClassName != "" {
if errs := validation.IsValidLabelValue(bc.Spec.BucketClassName); len(errs) == 0 {
workload.Labels["workloads.cozystack.io/bucket-class"] = bc.Spec.BucketClassName
} else {
logger.Info("Skipping bucket-class label: not a valid label value",
"bucketClaim", bc.Name, "bucketClassName", bc.Spec.BucketClassName, "errors", errs)
}
}
// Start from the sizes already recorded on the Workload: when the
// metrics endpoint is unreachable or reports nothing for this bucket,
// the last known good values must survive rather than collapse to
// "no size", which billing would read as an empty bucket.
resources := make(map[string]resource.Quantity)
for _, key := range []string{resourceS3StorageBytes, resourceS3PhysicalStorageBytes} {
if q, ok := workload.Status.Resources[key]; ok {
resources[key] = q
}
}
// Look up pre-fetched bucket metrics by the SeaweedFS bucket name.
// bc.Status.BucketName is the COSI Bucket name, which the COSI driver
// uses directly as the SeaweedFS bucket name.
if bm, ok := allMetrics[bc.Status.BucketName]; ok {
if bm.HasLogical {
resources[resourceS3StorageBytes] = *resource.NewQuantity(bm.LogicalSize, resource.BinarySI)
}
if bm.HasPhysical {
resources[resourceS3PhysicalStorageBytes] = *resource.NewQuantity(bm.PhysicalSize, resource.BinarySI)
}
}
workload.Status.Kind = monitor.Spec.Kind
workload.Status.Type = monitor.Spec.Type
workload.Status.Resources = resources
workload.Status.Operational = r.isBucketClaimReady(&bc)
return nil
})
if err != nil {
logger.Error(err, "Failed to CreateOrUpdate Workload", "workload", workload.Name)
return err
}
return nil
}
// reconcileServiceForMonitor creates or updates a Workload object for the given Service and WorkloadMonitor.
func (r *WorkloadMonitorReconciler) reconcileServiceForMonitor(
ctx context.Context,
monitor *cozyv1alpha1.WorkloadMonitor,
svc corev1.Service,
) error {
logger := log.FromContext(ctx)
workload := &cozyv1alpha1.Workload{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("svc-%s", svc.Name),
Namespace: svc.Namespace,
Labels: make(map[string]string, len(svc.Labels)),
},
}
resources := make(map[string]resource.Quantity)
quantity := resource.MustParse("0")
for _, ing := range svc.Status.LoadBalancer.Ingress {
if ing.IP != "" {
quantity.Add(resource.MustParse("1"))
}
}
var resourceLabel string
if svc.Annotations != nil {
var ok bool
resourceLabel, ok = svc.Annotations["metallb.universe.tf/ip-allocated-from-pool"]
if !ok {
resourceLabel = "default"
}
}
resourceLabel = fmt.Sprintf("%s.ipaddresspool.metallb.io/requests.ipaddresses", resourceLabel)
resources[resourceLabel] = quantity
monitorLabels := r.getMonitorLabels(monitor)
_, err := ctrl.CreateOrUpdate(ctx, r.Client, workload, func() error {
// Update owner references with the new monitor
updateOwnerReferences(workload.GetObjectMeta(), &svc)
// Apply monitor-level labels first so source-object labels can override on conflict
if workload.Labels == nil {
workload.Labels = make(map[string]string)
}
maps.Copy(workload.Labels, monitorLabels)
maps.Copy(workload.Labels, svc.Labels)
workload.Labels[workloadMonitorLabel] = monitor.Name
// Fill Workload status fields:
workload.Status.Kind = monitor.Spec.Kind
workload.Status.Type = monitor.Spec.Type
workload.Status.Resources = resources
workload.Status.Operational = r.isServiceReady(&svc)
return nil
})
if err != nil {
logger.Error(err, "Failed to CreateOrUpdate Workload", "workload", workload.Name)
return err
}
return nil
}
// reconcilePVCForMonitor creates or updates a Workload object for the given PVC and WorkloadMonitor.
// A PVC whose controller is a DataVolume in notReadyDataVolumes is not
// operational however it is bound; when the DataVolumes could not be read,
// such a PVC keeps the verdict its Workload already carries.
func (r *WorkloadMonitorReconciler) reconcilePVCForMonitor(
ctx context.Context,
monitor *cozyv1alpha1.WorkloadMonitor,
pvc corev1.PersistentVolumeClaim,
notReadyDataVolumes map[string]bool,
dataVolumesRead bool,
) error {
logger := log.FromContext(ctx)
workload := &cozyv1alpha1.Workload{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("pvc-%s", pvc.Name),
Namespace: pvc.Namespace,
Labels: make(map[string]string, len(pvc.Labels)),
},
}
resources := make(map[string]resource.Quantity)
for resourceName, resourceQuantity := range pvc.Status.Capacity {
storageClass := "default"
if pvc.Spec.StorageClassName != nil && *pvc.Spec.StorageClassName != "" {
storageClass = *pvc.Spec.StorageClassName
}
resourceLabel := fmt.Sprintf("%s.storageclass.storage.k8s.io/requests.%s", storageClass, resourceName.String())
resources[resourceLabel] = resourceQuantity
}
monitorLabels := r.getMonitorLabels(monitor)
_, err := ctrl.CreateOrUpdate(ctx, r.Client, workload, func() error {
// Update owner references with the new monitor
updateOwnerReferences(workload.GetObjectMeta(), &pvc)
// Apply monitor-level labels first so source-object labels can override on conflict
if workload.Labels == nil {
workload.Labels = make(map[string]string)
}
maps.Copy(workload.Labels, monitorLabels)
maps.Copy(workload.Labels, pvc.Labels)
workload.Labels[workloadMonitorLabel] = monitor.Name
// Fill Workload status fields:
workload.Status.Kind = monitor.Spec.Kind
workload.Status.Type = monitor.Spec.Type
workload.Status.Resources = resources
operational := r.isPVCReady(&pvc)
if owner := metav1.GetControllerOf(&pvc); owner != nil && owner.Kind == dataVolumeGVK.Kind && owner.APIVersion == dataVolumeGVK.GroupVersion().String() {
switch {
case !dataVolumesRead:
if workload.ResourceVersion != "" {
operational = workload.Status.Operational
}
case notReadyDataVolumes[owner.Name]:
operational = false
}
}
workload.Status.Operational = operational
return nil
})
if err != nil {
logger.Error(err, "Failed to CreateOrUpdate Workload", "workload", workload.Name)
return err
}
return nil
}
// reconcilePodForMonitor creates or updates a Workload object for the given Pod and WorkloadMonitor.
func (r *WorkloadMonitorReconciler) reconcilePodForMonitor(
ctx context.Context,
monitor *cozyv1alpha1.WorkloadMonitor,
pod corev1.Pod,
) error {
logger := log.FromContext(ctx)
// totalResources will store the sum of all container resource requests
totalResources := make(map[string]resource.Quantity)
// Iterate over all containers to aggregate their requests
for _, container := range pod.Spec.Containers {
for name, qty := range container.Resources.Requests {
if existing, exists := totalResources[name.String()]; exists {
existing.Add(qty)
totalResources[name.String()] = existing
} else {
totalResources[name.String()] = qty.DeepCopy()
}
}
}
// If annotation "workload.cozystack.io/resources" is present, parse and merge
if resourcesStr, ok := pod.Annotations["workload.cozystack.io/resources"]; ok {
annRes := map[string]string{}
if err := json.Unmarshal([]byte(resourcesStr), &annRes); err != nil {
logger.Error(err, "Failed to parse resources annotation", "pod", pod.Name)
} else {
for k, v := range annRes {
parsed, err := resource.ParseQuantity(v)
if err != nil {
logger.Error(err, "Failed to parse resource quantity from annotation", "key", k, "value", v)
continue
}
totalResources[k] = parsed
}
}
}
if pod.Labels[gpu.LabelDeviceReference] == gpu.LabelValueTrue {
covered, err := r.gpuUnitsCoveredByDevices(ctx, &pod)
if err != nil {
return err
}
for name, units := range covered {
q, ok := totalResources[name]
if !ok {
continue
}
left := q.Value() - units
if left <= 0 {
delete(totalResources, name)
continue
}
totalResources[name] = *resource.NewQuantity(left, q.Format)
}
}
workload := &cozyv1alpha1.Workload{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("pod-%s", pod.Name),
Namespace: pod.Namespace,
Labels: make(map[string]string, len(pod.Labels)),
},
}
metaLabels := r.getWorkloadMetadata(&pod)
monitorLabels := r.getMonitorLabels(monitor)
_, err := ctrl.CreateOrUpdate(ctx, r.Client, workload, func() error {
// Update owner references with the new monitor
updateOwnerReferences(workload.GetObjectMeta(), &pod)
// Apply monitor-level labels first so source-object labels can override on conflict
if workload.Labels == nil {
workload.Labels = make(map[string]string)
}
maps.Copy(workload.Labels, monitorLabels)
maps.Copy(workload.Labels, pod.Labels)
workload.Labels[workloadMonitorLabel] = monitor.Name
// Add workload meta to labels
maps.Copy(workload.Labels, metaLabels)
// Fill Workload status fields:
workload.Status.Kind = monitor.Spec.Kind
workload.Status.Type = monitor.Spec.Type
workload.Status.Resources = totalResources
workload.Status.Operational = r.isPodReady(&pod)
return nil
})
if err != nil {
logger.Error(err, "Failed to CreateOrUpdate Workload", "workload", workload.Name)
return err
}
return nil
}
// gpuUnitsCoveredByDevices sums, per resource name, the GPU units of the
// devices attached to the pod's VMI. The device decides whether those GPUs
// are in the usage records: its own record covers them while it is Bound or
// Attached, and the phase table leaves them out of the usage records while
// it is Lost, so the pod must not report them in either case.
func (r *WorkloadMonitorReconciler) gpuUnitsCoveredByDevices(ctx context.Context, pod *corev1.Pod) (map[string]int64, error) {
vmi := ""
for _, ref := range pod.OwnerReferences {
if ref.Kind == "VirtualMachineInstance" {
vmi = ref.Name
}
}
if vmi == "" {
return nil, nil
}
devices := &gpuv1alpha1.GPUDeviceList{}
if err := r.List(ctx, devices, client.InNamespace(pod.Namespace)); err != nil {
return nil, fmt.Errorf("list GPUDevices: %w", err)
}
covered := map[string]int64{}
for i := range devices.Items {
d := &devices.Items[i]
if d.Status.AttachedTo == nil || d.Status.AttachedTo.VMI != vmi {
continue
}
class := &gpuv1alpha1.GPUClass{}
if err := r.Get(ctx, client.ObjectKey{Name: d.Spec.ClassName}, class); err != nil {
if apierrors.IsNotFound(err) {
continue
}
return nil, fmt.Errorf("get GPUClass %s: %w", d.Spec.ClassName, err)
}
if dp := class.Spec.Source.DevicePlugin; dp != nil {
covered[dp.ResourceName] += int64(d.Spec.Count)
}
}
return covered, nil
}
// Reconcile is the main reconcile loop.
// 1. It reconciles WorkloadMonitor objects themselves (create/update/delete).
// 2. It also reconciles Pod events mapped to WorkloadMonitor via label selector.
func (r *WorkloadMonitorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
r.reconciledOnce.Do(func() { close(r.reconciledCh()) })
logger := log.FromContext(ctx)
// Fetch the WorkloadMonitor object if it exists
monitor := &cozyv1alpha1.WorkloadMonitor{}
err := r.Get(ctx, req.NamespacedName, monitor)
if err != nil {
// If the resource is not found, it may be a Pod event (mapFunc).
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
logger.Error(err, "Unable to fetch WorkloadMonitor")
return ctrl.Result{}, err
}
// List Pods that match the WorkloadMonitor's selector
podList := &corev1.PodList{}
if err := r.List(
ctx,
podList,
client.InNamespace(monitor.Namespace),
client.MatchingLabels(monitor.Spec.Selector),
); err != nil {
logger.Error(err, "Unable to list Pods for WorkloadMonitor", "monitor", monitor.Name)
return ctrl.Result{}, err
}
var observedReplicas, availableReplicas int32
// For each matching Pod, reconcile the corresponding Workload
for _, pod := range podList.Items {
observedReplicas++
if err := r.reconcilePodForMonitor(ctx, monitor, pod); err != nil {
logger.Error(err, "Failed to reconcile Workload for Pod", "pod", pod.Name)
continue
}
if r.isPodReady(&pod) {
availableReplicas++
}
}
dataVolumesMessage, notReadyDataVolumes, dataVolumesRead, dataVolumesErr := r.dataVolumesMessage(ctx, monitor)
// The sync window is expected; an error here would count as a
// reconcile failure for every monitor in the cluster while it lasts.
dataVolumeWatchSyncing := errors.Is(dataVolumesErr, errDataVolumeWatchSyncing)
if dataVolumeWatchSyncing {
dataVolumesErr = nil
}
if dataVolumesErr != nil {
logger.Error(dataVolumesErr, "Unable to read DataVolumes for WorkloadMonitor, keeping the last DataVolume verdict", "monitor", monitor.Name)
if r.Recorder != nil {
r.Recorder.Eventf(monitor, corev1.EventTypeWarning, "DataVolumesUnavailable",
"Failed to read DataVolumes, keeping the last DataVolume verdict: %v", dataVolumesErr)
}
}
pvcList := &corev1.PersistentVolumeClaimList{}
if err := r.List(
ctx,
pvcList,
client.InNamespace(monitor.Namespace),
client.MatchingLabels(monitor.Spec.Selector),
); err != nil {
logger.Error(err, "Unable to list PVCs for WorkloadMonitor", "monitor", monitor.Name)
return ctrl.Result{}, err
}
for _, pvc := range pvcList.Items {
if err := r.reconcilePVCForMonitor(ctx, monitor, pvc, notReadyDataVolumes, dataVolumesRead); err != nil {
logger.Error(err, "Failed to reconcile Workload for PVC", "PVC", pvc.Name)
continue
}
}
svcList := &corev1.ServiceList{}
if err := r.List(
ctx,
svcList,
client.InNamespace(monitor.Namespace),
client.MatchingLabels(monitor.Spec.Selector),
); err != nil {
logger.Error(err, "Unable to list Services for WorkloadMonitor", "monitor", monitor.Name)
return ctrl.Result{}, err
}
for _, svc := range svcList.Items {
if svc.Spec.Type != corev1.ServiceTypeLoadBalancer {
continue
}
if err := r.reconcileServiceForMonitor(ctx, monitor, svc); err != nil {
logger.Error(err, "Failed to reconcile Workload for Service", "Service", svc.Name)
continue
}
}
bucketClaimList := &cosiv1alpha1.BucketClaimList{}
if err := r.List(
ctx,
bucketClaimList,
client.InNamespace(monitor.Namespace),
client.MatchingLabels(monitor.Spec.Selector),
); err != nil {
logger.Error(err, "Unable to list BucketClaims for WorkloadMonitor", "monitor", monitor.Name)
return ctrl.Result{}, err
}
var bucketMetricsErr error
if len(bucketClaimList.Items) > 0 {
var bucketNames []string
for _, bc := range bucketClaimList.Items {
if bc.Status.BucketName != "" {
bucketNames = append(bucketNames, bc.Status.BucketName)
}
}
var allBucketMetrics map[string]*bucketMetrics
bucketPromURL, err := r.resolvePrometheusURL(ctx, monitor.Namespace)
if err == nil {
allBucketMetrics, err = r.queryAllBucketMetrics(ctx, bucketPromURL, bucketNames)
}
if err != nil {
// Never let a metrics failure degrade into "bucket size = 0":
// the Workloads below are still reconciled, but they keep their
// last known sizes, and the failure is surfaced via an event,
// the log, and the returned error.
bucketMetricsErr = fmt.Errorf("fetching bucket size metrics: %w", err)
logger.Error(bucketMetricsErr, "Retaining last known bucket sizes", "monitor", monitor.Name)
if r.Recorder != nil {
r.Recorder.Eventf(monitor, corev1.EventTypeWarning, "BucketMetricsUnavailable",
"Failed to fetch bucket size metrics, retaining last known values: %v", err)
}
}
for _, bc := range bucketClaimList.Items {
if err := r.reconcileBucketClaimForMonitor(ctx, monitor, bc, allBucketMetrics); err != nil {
logger.Error(err, "Failed to reconcile Workload for BucketClaim", "BucketClaim", bc.Name)
continue
}
}
}
// Update WorkloadMonitor status based on observed pods
monitor.Status.ObservedReplicas = observedReplicas
monitor.Status.AvailableReplicas = availableReplicas
// Update the WorkloadMonitor status in the cluster
err = retry.RetryOnConflict(retry.DefaultBackoff, func() error {
fresh := &cozyv1alpha1.WorkloadMonitor{}
if err := r.Get(ctx, req.NamespacedName, fresh); err != nil {
return err
}
stored := fresh.Status.DeepCopy()
fresh.Status.ObservedReplicas = observedReplicas
fresh.Status.AvailableReplicas = availableReplicas
// Default to operational = true, but check MinReplicas if set.
// Use fresh.Spec to avoid making decisions based on a stale cached copy
// when the spec was updated between the initial read and this retry.
fresh.Status.Operational = pointer.Bool(true)
if fresh.Spec.MinReplicas != nil && availableReplicas < *fresh.Spec.MinReplicas {
fresh.Status.Operational = pointer.Bool(false)
}
// Only the DataVolume check writes Message and Reason, so the stored ones
// are the last DataVolume verdict: kept while no reader is installed yet, so a
// controller restart does not report a stuck disk as ready.
if dataVolumesRead {
fresh.Status.Message = dataVolumesMessage
fresh.Status.Reason = ""
if dataVolumesMessage != "" {
fresh.Status.Reason = cozyv1alpha1.WorkloadMonitorReasonDataVolumeNotReady
}
}
if fresh.Status.Message != "" {
fresh.Status.Operational = pointer.Bool(false)
}
if equality.Semantic.DeepEqual(stored, &fresh.Status) {
return nil
}
return r.Status().Update(ctx, fresh)
})
if err != nil {
logger.Error(err, "unable to update WorkloadMonitor status after retries")
return ctrl.Result{}, err
}
// Returning the metrics or DataVolume error makes the failure count in
// controller-runtime's reconcile error metrics and retries with backoff
// instead of quietly waiting for the next periodic requeue.
if bucketMetricsErr != nil || dataVolumesErr != nil {
return ctrl.Result{}, errors.Join(bucketMetricsErr, dataVolumesErr)
}
if dataVolumeWatchSyncing {
return ctrl.Result{RequeueAfter: dataVolumeSyncRequeue}, nil
}
// Requeue periodically if there are BucketClaims to keep sizes up to date.
// Bucket sizes come from Prometheus metrics that update every 60s.
if len(bucketClaimList.Items) > 0 {
return ctrl.Result{RequeueAfter: 60 * time.Second}, nil
}
return ctrl.Result{}, nil
}
// SetupWithManager registers our controller with the Manager and sets up watches.
func (r *WorkloadMonitorReconciler) SetupWithManager(mgr ctrl.Manager) error {
builder := ctrl.NewControllerManagedBy(mgr).
// Watch WorkloadMonitor objects
For(&cozyv1alpha1.WorkloadMonitor{}).
// Also watch Pod objects and map them back to WorkloadMonitor if labels match
Watches(
&corev1.Pod{},
handler.EnqueueRequestsFromMapFunc(mapObjectToMonitor(&corev1.Pod{}, r.Client)),
).
// Watch PVCs as well
Watches(
&corev1.PersistentVolumeClaim{},
handler.EnqueueRequestsFromMapFunc(mapObjectToMonitor(&corev1.PersistentVolumeClaim{}, r.Client)),
).
// Watch BucketClaims for S3 bucket billing
Watches(
&cosiv1alpha1.BucketClaim{},
handler.EnqueueRequestsFromMapFunc(mapObjectToMonitor(&cosiv1alpha1.BucketClaim{}, r.Client)),
).
// Watch for changes to Workload objects we create (owned by WorkloadMonitor)
Owns(&cozyv1alpha1.Workload{})
c, err := builder.Build(r)
if err != nil {
return err
}
// A watch on a kind whose CRD is absent never syncs, and controller-runtime
// does not start a controller with unsynced caches. CDI ships with the IaaS
// bundle only and can be installed while this controller is running, so
// the DataVolume watch is added once the kind appears rather than at setup.
return mgr.Add(manager.RunnableFunc(func(ctx context.Context) error {
r.watchDataVolumes(ctx, mgr.GetRESTMapper(), c, mgr.GetCache(), dataVolumeAPIPollInterval)
return nil
}))
}
func mapObjectToMonitor[T client.Object](_ T, c client.Client) func(ctx context.Context, obj client.Object) []reconcile.Request {
return func(ctx context.Context, obj client.Object) []reconcile.Request {
concrete, ok := obj.(T)
if !ok {
return nil
}
var monitorList cozyv1alpha1.WorkloadMonitorList
// List all WorkloadMonitors in the same namespace
if err := c.List(ctx, &monitorList, client.InNamespace(concrete.GetNamespace())); err != nil {
return nil
}
labels := concrete.GetLabels()
// Match each monitor's selector with the Pod's labels
var requests []reconcile.Request
for _, m := range monitorList.Items {
matches := true
for k, v := range m.Spec.Selector {
if labelVal, exists := labels[k]; !exists || labelVal != v {
matches = false
break
}
}
if matches {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Namespace: m.Namespace,
Name: m.Name,
},
})
}
}
return requests
}
}
func (r *WorkloadMonitorReconciler) getWorkloadMetadata(obj client.Object) map[string]string {
labels := make(map[string]string)
annotations := obj.GetAnnotations()
if instanceType, ok := annotations["kubevirt.io/cluster-instancetype-name"]; ok {
labels["workloads.cozystack.io/kubevirt-vmi-instance-type"] = instanceType
}
if instanceProfile, ok := annotations["kubevirt.io/cluster-preference-name"]; ok {
labels["workloads.cozystack.io/kubevirt-vmi-instance-profile"] = instanceProfile
}
return labels
}
// getMonitorLabels extracts workloads.cozystack.io/* labels from a WorkloadMonitor
// so they can be propagated onto Workload objects created for pods, PVCs, services,
// or bucket claims. The monitor label "workloads.cozystack.io/monitor" is reserved
// and set separately per Workload, so it is excluded here.
func (r *WorkloadMonitorReconciler) getMonitorLabels(monitor *cozyv1alpha1.WorkloadMonitor) map[string]string {
labels := make(map[string]string)
for k, v := range monitor.GetLabels() {
if !strings.HasPrefix(k, workloadLabelPrefix) {
continue
}
if k == workloadMonitorLabel {
continue
}
labels[k] = v
}
return labels
}