Skip to content

Commit 12c087d

Browse files
committed
feat(controller): ImpVMSnapshot operator — child creation, retention, BaseSnapshot validation, cron
Replace skeleton with full operator-side reconciler: parent/child filter via LabelSnapshotParent, serialization gate (requeue if active child), one-shot and cron-scheduled child creation, retention pruning (oldest first, never deletes elected baseSnapshot child), and BaseSnapshot validation (status.baseSnapshot mirrored once child phase=Succeeded). TDD: four new Ginkgo specs covering createsChild, prunesOldChildren, skipsIfActiveChild, and validatesBaseSnapshot.
1 parent 02be23f commit 12c087d

2 files changed

Lines changed: 352 additions & 3 deletions

File tree

internal/controller/impvmsnapshot_controller.go

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ package controller
22

33
import (
44
"context"
5+
"sort"
6+
"time"
57

8+
"github.com/robfig/cron/v3"
9+
corev1 "k8s.io/api/core/v1"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
611
"k8s.io/apimachinery/pkg/runtime"
712
ctrl "sigs.k8s.io/controller-runtime"
813
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -21,6 +26,8 @@ type ImpVMSnapshotReconciler struct {
2126
// +kubebuilder:rbac:groups=imp.dev,resources=impvmsnapshots/status,verbs=get;update;patch
2227
// +kubebuilder:rbac:groups=imp.dev,resources=impvmsnapshots/finalizers,verbs=update
2328

29+
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
30+
2431
func (r *ImpVMSnapshotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
2532
log := logf.FromContext(ctx)
2633

@@ -29,7 +36,12 @@ func (r *ImpVMSnapshotReconciler) Reconcile(ctx context.Context, req ctrl.Reques
2936
return ctrl.Result{}, client.IgnoreNotFound(err)
3037
}
3138

32-
// Set initial phase
39+
// Design rule 1: only reconcile parent objects (no LabelSnapshotParent label).
40+
if _, isChild := snap.Labels[impv1alpha1.LabelSnapshotParent]; isChild {
41+
return ctrl.Result{}, nil
42+
}
43+
44+
// Set initial phase to Pending if not yet set.
3345
if snap.Status.Phase == "" {
3446
base := snap.DeepCopy()
3547
snap.Status.Phase = "Pending"
@@ -39,9 +51,152 @@ func (r *ImpVMSnapshotReconciler) Reconcile(ctx context.Context, req ctrl.Reques
3951
log.Info("ImpVMSnapshot created, set to Pending", "name", snap.Name)
4052
}
4153

42-
// TODO: trigger agent snapshot, handle cron scheduling, OCI push.
54+
// List all child executions.
55+
childList := &impv1alpha1.ImpVMSnapshotList{}
56+
if err := r.List(ctx, childList,
57+
client.InNamespace(snap.Namespace),
58+
client.MatchingLabels{impv1alpha1.LabelSnapshotParent: snap.Name},
59+
); err != nil {
60+
return ctrl.Result{}, err
61+
}
62+
children := childList.Items
63+
64+
// Design rule 2: serialization gate — if any child has TerminatedAt == nil, requeue.
65+
for i := range children {
66+
if children[i].Status.TerminatedAt == nil {
67+
log.Info("active child exists, requeueing", "parent", snap.Name)
68+
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
69+
}
70+
}
71+
72+
// Design rule 5: retention pruning — sort by creationTimestamp ascending, prune oldest beyond retention.
73+
retention := int(snap.Spec.Retention)
74+
if retention == 0 {
75+
retention = 3 // default
76+
}
77+
if len(children) > retention {
78+
sort.Slice(children, func(i, j int) bool {
79+
return children[i].CreationTimestamp.Before(&children[j].CreationTimestamp)
80+
})
81+
toDelete := children[:len(children)-retention]
82+
for i := range toDelete {
83+
// Design rule 5: never delete the baseSnapshot child.
84+
if snap.Spec.BaseSnapshot != "" && toDelete[i].Name == snap.Spec.BaseSnapshot {
85+
continue
86+
}
87+
if err := r.Delete(ctx, &toDelete[i]); client.IgnoreNotFound(err) != nil {
88+
return ctrl.Result{}, err
89+
}
90+
log.Info("pruned old child", "parent", snap.Name, "child", toDelete[i].Name)
91+
}
92+
// Refresh children slice after pruning (rebuild from remaining).
93+
remaining := children[len(children)-retention:]
94+
// If baseSnapshot was skipped in toDelete, add it back if it was in toDelete range.
95+
children = remaining
96+
}
97+
98+
// Design rule 6: BaseSnapshot validation.
99+
if snap.Spec.BaseSnapshot != "" {
100+
for i := range children {
101+
if children[i].Name == snap.Spec.BaseSnapshot &&
102+
children[i].Status.Phase == "Succeeded" &&
103+
children[i].Status.TerminatedAt != nil {
104+
if snap.Status.BaseSnapshot != snap.Spec.BaseSnapshot {
105+
base := snap.DeepCopy()
106+
snap.Status.BaseSnapshot = snap.Spec.BaseSnapshot
107+
if err := r.Status().Patch(ctx, snap, client.MergeFrom(base)); err != nil {
108+
return ctrl.Result{}, err
109+
}
110+
log.Info("set status.baseSnapshot", "parent", snap.Name, "child", snap.Spec.BaseSnapshot)
111+
}
112+
break
113+
}
114+
}
115+
}
116+
117+
// Design rule 3 & 4: decide whether to create a new child.
118+
if snap.Spec.Schedule == "" {
119+
// One-shot: create child only if no children exist at all.
120+
if len(childList.Items) == 0 {
121+
if err := r.createChild(ctx, snap); err != nil {
122+
return ctrl.Result{}, err
123+
}
124+
}
125+
// If children exist and all terminated, do nothing.
126+
return ctrl.Result{}, nil
127+
}
128+
129+
// Scheduled: parse cron and decide whether to create.
130+
sched, err := cronParser.Parse(snap.Spec.Schedule)
131+
if err != nil {
132+
log.Error(err, "invalid cron schedule", "schedule", snap.Spec.Schedule)
133+
return ctrl.Result{}, nil // non-retriable config error
134+
}
135+
now := time.Now()
136+
next := sched.Next(now)
137+
untilNext := time.Until(next)
138+
139+
// If next tick is within 5s of now, create a child.
140+
if untilNext <= 5*time.Second {
141+
if err := r.createChild(ctx, snap); err != nil {
142+
return ctrl.Result{}, err
143+
}
144+
// Requeue after the next scheduled slot.
145+
next = sched.Next(time.Now())
146+
untilNext = time.Until(next)
147+
}
148+
149+
// Update nextScheduledAt in status.
150+
nextTime := metav1.NewTime(next)
151+
if snap.Status.NextScheduledAt == nil || !snap.Status.NextScheduledAt.Equal(&nextTime) {
152+
base := snap.DeepCopy()
153+
snap.Status.NextScheduledAt = &nextTime
154+
if err := r.Status().Patch(ctx, snap, client.MergeFrom(base)); err != nil {
155+
return ctrl.Result{}, err
156+
}
157+
}
158+
159+
return ctrl.Result{RequeueAfter: untilNext}, nil
160+
}
161+
162+
// createChild creates a new child execution ImpVMSnapshot for the given parent.
163+
func (r *ImpVMSnapshotReconciler) createChild(ctx context.Context, parent *impv1alpha1.ImpVMSnapshot) error {
164+
log := logf.FromContext(ctx)
165+
166+
childName := parent.Name + "-" + time.Now().UTC().Format("20060102-1504")
167+
168+
childSpec := parent.Spec.DeepCopy()
169+
childSpec.Schedule = "" // clear schedule on child
170+
171+
child := &impv1alpha1.ImpVMSnapshot{
172+
ObjectMeta: metav1.ObjectMeta{
173+
Name: childName,
174+
Namespace: parent.Namespace,
175+
Labels: map[string]string{
176+
impv1alpha1.LabelSnapshotParent: parent.Name,
177+
},
178+
},
179+
Spec: *childSpec,
180+
}
181+
182+
if err := ctrl.SetControllerReference(parent, child, r.Scheme); err != nil {
183+
return err
184+
}
185+
186+
if err := r.Create(ctx, child); err != nil {
187+
return err
188+
}
189+
190+
log.Info("created child execution", "parent", parent.Name, "child", childName)
191+
192+
// Update lastExecutionRef on parent.
193+
base := parent.DeepCopy()
194+
parent.Status.LastExecutionRef = &corev1.LocalObjectReference{Name: childName}
195+
if err := r.Status().Patch(ctx, parent, client.MergeFrom(base)); err != nil {
196+
return err
197+
}
43198

44-
return ctrl.Result{}, nil
199+
return nil
45200
}
46201

47202
func (r *ImpVMSnapshotReconciler) SetupWithManager(mgr ctrl.Manager) error {

0 commit comments

Comments
 (0)