Skip to content
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
85 changes: 83 additions & 2 deletions controller/internal/exporterset/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,18 @@ import (
const (
annotationSurplusSince = "exporterset.jumpstarter.dev/surplus-since"

// Bridges the grandparent lookup (ExporterSet -> Exporter -> Pod).
// Bridges the grandparent lookup (ExporterSet -> Exporter -> Pod), and
// tells clients which pool an exporter came from: membership otherwise
// lives only in ownerReferences, which the client API does not expose.
labelExporterSetName = "exporterset.jumpstarter.dev/name"

// The VirtualTargetClass backing the pool, and the provisioner that
// class names, so clients can tell how an exporter is provisioned without
Comment on lines 68 to +71

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: the comment seems like cut in the middle? "The VirtualTargetClass backing the pool, and the provisioner that class names"

// cluster access. The provisioner is a property of the class, which a
// client cannot read, so it has to be carried here.
labelVirtualTargetClass = "exporterset.jumpstarter.dev/class"
labelProvisioner = "exporterset.jumpstarter.dev/provisioner"
Comment on lines 64 to +75

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These three labels will be visible to every client by default. The PR description says they "pass the controller's label denylist", but that's because hiddenLabels.keys defaults to empty , nothing is hidden unless an admin explicitly configures it.

In practice, every ExporterSet-managed exporter shown via jmp get exporters or the MCP jmp_list_exporters tool will render all three labels in the LABELS column:

exporterset.jumpstarter.dev/class=qemu-class,exporterset.jumpstarter.dev/name=demo-set,exporterset.jumpstarter.dev/provisioner=qemu.jumpstarter.dev,exporterset=demo-set

That's a lot of noise compared to a standalone exporter with just board=rpi4. A few options to consider:

I would add these to hiddenLabels.keys by default in the operator's default config, so they're hidden unless requested via --show-hidden-labels. Clients that want to filter by pool can still use them in --filter selectors even when hidden.


defaultScaleDownCooldown = 5 * time.Minute

// kindExporter is the Kind string used in OwnerReference lookups.
Expand Down Expand Up @@ -204,6 +213,12 @@ func (r *ExporterSetReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, err
}

// Keep identity labels current on exporters created before them, or after
// the set's class changed.
if err := r.reconcileExporterLabels(ctx, &exporterSet, ownedExporters); err != nil {
return ctrl.Result{}, err
}

// Single Pod List shared by terminal cleanup and ensureExporterPods.
podsByExporter, err := r.listPodsGroupedByExporter(ctx, &exporterSet)
if err != nil {
Expand Down Expand Up @@ -335,7 +350,7 @@ func (r *ExporterSetReconciler) scaleUp(
ObjectMeta: metav1.ObjectMeta{
GenerateName: es.Name + "-",
Namespace: es.Namespace,
Labels: maps.Clone(es.Spec.Template.Metadata.Labels),
Labels: r.exporterLabels(es),
Annotations: maps.Clone(es.Spec.Template.Metadata.Annotations),
},
Spec: jumpstarterdevv1alpha1.ExporterSpec{
Expand Down Expand Up @@ -919,6 +934,72 @@ func (r *ExporterSetReconciler) clearSurplusAnnotation(ctx context.Context, es *
}
}

// identityLabels mark which pool an exporter belongs to and how it is
// provisioned. Reconcile has already established that the referenced class
// names this reconciler's provisioner, so it is the provisioner in effect.
func (r *ExporterSetReconciler) identityLabels(
es *virtualtargetv1alpha1.ExporterSet,
) map[string]string {
labels := map[string]string{labelExporterSetName: es.Name}
if es.Spec.VirtualTargetClassName != "" {
labels[labelVirtualTargetClass] = es.Spec.VirtualTargetClassName
}
if r.Provisioner != nil {
labels[labelProvisioner] = r.Provisioner.Name()
}
return labels
}

// exporterLabels are the labels an Exporter of this set carries: the set's
// template labels plus the identity labels above.
func (r *ExporterSetReconciler) exporterLabels(
es *virtualtargetv1alpha1.ExporterSet,
) map[string]string {
labels := maps.Clone(es.Spec.Template.Metadata.Labels)
if labels == nil {
labels = map[string]string{}
}
maps.Copy(labels, r.identityLabels(es))
return labels
}

// reconcileExporterLabels stamps the identity labels on exporters that predate
// them, so a pool created before this controller version becomes groupable by
// clients without waiting for its exporters to be recycled.
func (r *ExporterSetReconciler) reconcileExporterLabels(
ctx context.Context,
es *virtualtargetv1alpha1.ExporterSet,
owned []jumpstarterdevv1alpha1.Exporter,
) error {
logger := log.FromContext(ctx)

desired := r.identityLabels(es)

for i := range owned {
exporter := &owned[i]
missing := map[string]string{}
for key, value := range desired {
if exporter.Labels[key] != value {
missing[key] = value
}
}
if len(missing) == 0 {
continue
}

patch := client.MergeFrom(exporter.DeepCopy())
if exporter.Labels == nil {
exporter.Labels = map[string]string{}
}
maps.Copy(exporter.Labels, missing)
if err := r.Patch(ctx, exporter, patch); err != nil {
Comment on lines +994 to +995

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This returns on the first patch error, leaving all remaining exporters in the slice unlabeled for that reconcile cycle. Replace with log-and-continue, accumulate errors, and return a combined error after the loop using errors.Join.

return fmt.Errorf("unable to label Exporter %s: %w", exporter.Name, err)
}
logger.Info("stamped exporter set labels", "exporter", exporter.Name, "labels", missing)
}
return nil
}

func (r *ExporterSetReconciler) listOwnedExporters(
ctx context.Context,
es *virtualtargetv1alpha1.ExporterSet,
Expand Down
71 changes: 71 additions & 0 deletions controller/internal/exporterset/reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2014,3 +2014,74 @@ func TestMergeImages_esOverridesVtc(t *testing.T) {
t.Errorf("runtime should be overridden by es, got %v", got.Runtime)
}
}

// --- client-visible identity labels -----------------------------------------

func TestScaleUp_stampsIdentityLabels(t *testing.T) {
es := makeExporterSet(func(es *virtualtargetv1alpha1.ExporterSet) {
es.Spec.MinReplicas = 1
es.Spec.MinAvailableReplicas = 0
})
r, c := newReconciler(t, es, makeVTC())
reconcileOnce(t, r)

exporters := listExporters(t, c)
if len(exporters) != 1 {
t.Fatalf("expected 1 exporter, got %d", len(exporters))
}
// Set membership otherwise lives only in ownerReferences, which the client
// API never exposes.
if got := exporters[0].Labels[labelExporterSetName]; got != "demo-set" {
t.Errorf("%s = %q, want %q", labelExporterSetName, got, "demo-set")
}
if got := exporters[0].Labels[labelVirtualTargetClass]; got != "qemu-class" {
t.Errorf("%s = %q, want %q", labelVirtualTargetClass, got, "qemu-class")
}
// The provisioner lives on the class, which a client cannot read.
if got := exporters[0].Labels[labelProvisioner]; got != qemu.ProvisionerName {
t.Errorf("%s = %q, want %q", labelProvisioner, got, qemu.ProvisionerName)
}
// Template labels still come through.
if got := exporters[0].Labels["exporterset"]; got != "demo-set" {
t.Errorf("template label lost: got %q", got)
}
}

func TestReconcile_backfillsIdentityLabelsOnExistingExporters(t *testing.T) {
es := makeExporterSet(func(es *virtualtargetv1alpha1.ExporterSet) {
es.Spec.MinReplicas = 1
es.Spec.MinAvailableReplicas = 0
})
// An exporter from before these labels existed.
existing := makeExporter("demo-set-old", true, false, true)
delete(existing.Labels, labelExporterSetName)

r, c := newReconciler(t, es, makeVTC(), existing)
reconcileOnce(t, r)

var got jumpstarterdevv1alpha1.Exporter
if err := c.Get(context.Background(),
types.NamespacedName{Name: "demo-set-old", Namespace: nsDefault}, &got); err != nil {
t.Fatalf("get exporter: %v", err)
}
if got.Labels[labelExporterSetName] != "demo-set" {
t.Errorf("existing exporter not labelled: %v", got.Labels)
}
if got.Labels[labelVirtualTargetClass] != "qemu-class" {
t.Errorf("existing exporter missing class label: %v", got.Labels)
}
if got.Labels[labelProvisioner] != qemu.ProvisionerName {
t.Errorf("existing exporter missing provisioner label: %v", got.Labels)
}
}

func TestExporterLabels_survivesNilTemplateLabels(t *testing.T) {
es := makeExporterSet(func(es *virtualtargetv1alpha1.ExporterSet) {
es.Spec.Template.Metadata.Labels = nil
})
r, _ := newReconciler(t, es, makeVTC())
labels := r.exporterLabels(es)
if labels[labelExporterSetName] != "demo-set" {
t.Errorf("expected set name label, got %v", labels)
}
}
Loading