diff --git a/api/v1alpha1/sandbox_types.go b/api/v1alpha1/sandbox_types.go index aff0df45..1bd29d61 100644 --- a/api/v1alpha1/sandbox_types.go +++ b/api/v1alpha1/sandbox_types.go @@ -24,14 +24,41 @@ type ConditionType string func (c ConditionType) String() string { return string(c) } +type TTLPolicyType string + +func (c TTLPolicyType) String() string { return string(c) } + const ( // SandboxConditionReady indicates readiness for Sandbox SandboxConditionReady ConditionType = "Ready" + + // TTL policy + TTLPolicyOnCreate TTLPolicyType = "onCreate" + TTLPolicyOnReady TTLPolicyType = "onReady" + TTLPolicyOnEnable TTLPolicyType = "onEnable" + TTLPolicyNever TTLPolicyType = "never" ) // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. // Important: Run "make" to regenerate code after modifying this file +type TTLConfig struct { + // Seconds sets after how many seconds should the sandbox be deleted + Seconds int32 `json:"seconds,omitempty"` + + // StartPolicy indicated when the count down for shutdown should start + // onCreate - TTL starts from sandbox creation + // onReady - TTL starts from sandbox ready + // onEnable - When this is set and .status.shutdownAt is nil + // never - TTL is disabled + // +kubebuilder:validation:Enum=onCreate;onReady;onEnable;disable + StartPolicy TTLPolicyType `json:"startPolicy,omitempty"` + + //ShutdownAt - Absolute time when the sandbox is deleted. + // setting this would override StartPolicy and Seconds + ShutdownAt string `json:"shutdownAt,omitempty"` +} + // SandboxSpec defines the desired state of Sandbox type SandboxSpec struct { // The following markers will use OpenAPI v3 schema to validate the value @@ -40,6 +67,9 @@ type SandboxSpec struct { // PodTemplate describes the pod spec that will be used to create an agent sandbox. // +kubebuilder:validation:Required PodTemplate corev1.PodTemplateSpec `json:"podTemplate" protobuf:"bytes,3,opt,name=podTemplate"` + + // +optional + TTL *TTLConfig `json:"ttl,omitempty"` } // SandboxStatus defines the observed state of Sandbox. @@ -54,6 +84,12 @@ type SandboxStatus struct { // status conditions array Conditions []metav1.Condition `json:"conditions,omitempty"` + + // FirstReadyTime - when did the sandbox become ready first + FirstReadyTime *metav1.Time `json:"firstReadyTime,omitempty"` + + // ShutdownAt - when will the sandbox be deleted + ShutdownAt *metav1.Time `json:"ttlShutdownAt,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8033f37b..d7acf36b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -72,6 +72,11 @@ func (in *SandboxList) DeepCopyObject() runtime.Object { func (in *SandboxSpec) DeepCopyInto(out *SandboxSpec) { *out = *in in.PodTemplate.DeepCopyInto(&out.PodTemplate) + if in.TTL != nil { + in, out := &in.TTL, &out.TTL + *out = new(TTLConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxSpec. @@ -94,6 +99,14 @@ func (in *SandboxStatus) DeepCopyInto(out *SandboxStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.FirstReadyTime != nil { + in, out := &in.FirstReadyTime, &out.FirstReadyTime + *out = (*in).DeepCopy() + } + if in.ShutdownAt != nil { + in, out := &in.ShutdownAt, &out.ShutdownAt + *out = (*in).DeepCopy() + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxStatus. @@ -105,3 +118,18 @@ func (in *SandboxStatus) DeepCopy() *SandboxStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TTLConfig) DeepCopyInto(out *TTLConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TTLConfig. +func (in *TTLConfig) DeepCopy() *TTLConfig { + if in == nil { + return nil + } + out := new(TTLConfig) + in.DeepCopyInto(out) + return out +} diff --git a/controllers/sandbox_controller.go b/controllers/sandbox_controller.go index b048a95d..bde68cc2 100644 --- a/controllers/sandbox_controller.go +++ b/controllers/sandbox_controller.go @@ -20,6 +20,7 @@ import ( "fmt" "hash/fnv" "reflect" + "time" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -33,6 +34,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" sandboxv1alpha1 "sigs.k8s.io/agent-sandbox/api/v1alpha1" ) @@ -95,21 +97,24 @@ func (r *SandboxReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct allErrors = errors.Join(allErrors, err) // compute and set overall Ready condition - readyCondition := r.computeReadyCondition(sandbox.Generation, allErrors, svc, pod) + readyCondition := r.computeReadyCondition(sandbox, allErrors, svc, pod) meta.SetStatusCondition(&sandbox.Status.Conditions, readyCondition) + result, err := r.ReconcileSandboxTTL(ctx, sandbox) + allErrors = errors.Join(allErrors, err) + // Update status err = r.updateStatus(ctx, oldStatus, sandbox) allErrors = errors.Join(allErrors, err) // return errors seen - return ctrl.Result{}, allErrors + return result, allErrors } -func (r *SandboxReconciler) computeReadyCondition(generation int64, err error, svc *corev1.Service, pod *corev1.Pod) metav1.Condition { +func (r *SandboxReconciler) computeReadyCondition(sandbox *sandboxv1alpha1.Sandbox, err error, svc *corev1.Service, pod *corev1.Pod) metav1.Condition { readyCondition := metav1.Condition{ Type: string(sandboxv1alpha1.SandboxConditionReady), - ObservedGeneration: generation, + ObservedGeneration: sandbox.Generation, Message: "", Status: metav1.ConditionFalse, Reason: "DependenciesNotReady", @@ -150,7 +155,10 @@ func (r *SandboxReconciler) computeReadyCondition(generation int64, err error, s if podReady && svcReady { readyCondition.Status = metav1.ConditionTrue readyCondition.Reason = "DependenciesReady" - return readyCondition + if sandbox.Status.FirstReadyTime == nil { + now := metav1.Now() + sandbox.Status.FirstReadyTime = &now + } } return readyCondition @@ -270,6 +278,71 @@ func (r *SandboxReconciler) reconcilePod(ctx context.Context, sandbox *sandboxv1 return pod, nil } +// ReconcileSandboxTTL will check if a sandbox has expired, and if so, delete it. +// If the sandbox has not expired, it will requeue the request for the remaining time. + +func (r *SandboxReconciler) ReconcileSandboxTTL(ctx context.Context, sandbox *sandboxv1alpha1.Sandbox) (ctrl.Result, error) { + log := log.FromContext(ctx) + sandbox.Status.ShutdownAt = nil + if sandbox.Spec.TTL == nil || (sandbox.Spec.TTL.Seconds == 0 && sandbox.Spec.TTL.ShutdownAt == "") { + log.Info("Sandbox TTL is not set, skipping TTL check.") + return reconcile.Result{}, nil + } + + var expiryTime time.Time + if sandbox.Spec.TTL.ShutdownAt != "" { + // Try parsing the endtime as a RFC 3339 string + fromTime, err := time.Parse(time.RFC3339, sandbox.Spec.TTL.ShutdownAt) + if err != nil { + log.Error(err, "Failed to parse TTLFromTime as RFC3339 string", "ShutdownAt", sandbox.Spec.TTL.ShutdownAt) + return reconcile.Result{}, err + } + expiryTime = fromTime.Add(time.Duration(sandbox.Spec.TTL.Seconds) * time.Second) + } else { + switch sandbox.Spec.TTL.StartPolicy { + case sandboxv1alpha1.TTLPolicyOnCreate: + // Look at the sandbox create time and calculate the endtime + expiryTime = sandbox.CreationTimestamp.Add(time.Duration(sandbox.Spec.TTL.Seconds) * time.Second) + case sandboxv1alpha1.TTLPolicyOnReady: + if sandbox.Status.FirstReadyTime != nil { + expiryTime = sandbox.Status.FirstReadyTime.Add(time.Duration(sandbox.Spec.TTL.Seconds) * time.Second) + } else { + log.Info("Sandbox not yet ready, cannot calculate TTLFromReady") + return reconcile.Result{RequeueAfter: 5 * time.Second}, nil + } + case sandboxv1alpha1.TTLPolicyNever: + log.Info("TTL is disabled for this sandbox") + return reconcile.Result{}, nil + case sandboxv1alpha1.TTLPolicyOnEnable: + if sandbox.Status.ShutdownAt == nil { + expiryTime = time.Now().Add(time.Duration(sandbox.Spec.TTL.Seconds) * time.Second) + } + default: + // should not happen + log.Info("TTL policy unknown: %s", sandbox.Spec.TTL.StartPolicy) + return reconcile.Result{}, nil + } + } + + // Set the .status.ttlExpiryTime + sandbox.Status.ShutdownAt = &metav1.Time{Time: expiryTime} + + // Calculate remaining time + remainingTime := time.Until(expiryTime) + if remainingTime <= 0 { + log.Info("Sandbox has expired, deleting") + if err := r.Delete(ctx, sandbox); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to delete sandbox: %w", err) + } + return ctrl.Result{}, nil + } + + requeueAfter := max(remainingTime/2, 2*time.Second) // Requeue at most every 2 seconds + log.Info("Requeuing sandbox for TTL", "remaining time", remainingTime, "requeue after", requeueAfter, + "expiry time", expiryTime) + return reconcile.Result{RequeueAfter: requeueAfter}, nil +} + // SetupWithManager sets up the controller with the Manager. func (r *SandboxReconciler) SetupWithManager(mgr ctrl.Manager) error { labelSelectorPredicate, err := predicate.LabelSelectorPredicate(metav1.LabelSelector{ diff --git a/controllers/sandbox_controller_test.go b/controllers/sandbox_controller_test.go index 9e266ee7..1154dd2f 100644 --- a/controllers/sandbox_controller_test.go +++ b/controllers/sandbox_controller_test.go @@ -142,7 +142,10 @@ func TestComputeReadyCondition(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - condition := r.computeReadyCondition(tc.generation, tc.err, tc.svc, tc.pod) + sandbox := &sandboxv1alpha1.Sandbox{ + ObjectMeta: metav1.ObjectMeta{Generation: tc.generation}, + } + condition := r.computeReadyCondition(sandbox, tc.err, tc.svc, tc.pod) g.Expect(condition.Type).To(Equal(string(sandboxv1alpha1.SandboxConditionReady))) g.Expect(condition.ObservedGeneration).To(Equal(tc.generation)) g.Expect(condition.Status).To(Equal(tc.expectedStatus)) diff --git a/examples/sandbox.yaml b/examples/sandbox.yaml index 5eae1752..1d504162 100644 --- a/examples/sandbox.yaml +++ b/examples/sandbox.yaml @@ -3,6 +3,9 @@ kind: Sandbox metadata: name: sandbox-example spec: + ttl: + seconds: 200 + startPolicy: onCreate podTemplate: spec: containers: diff --git a/k8s/crds/agents.x-k8s.io_sandboxes.yaml b/k8s/crds/agents.x-k8s.io_sandboxes.yaml index 0caf7ae1..c5ed8431 100644 --- a/k8s/crds/agents.x-k8s.io_sandboxes.yaml +++ b/k8s/crds/agents.x-k8s.io_sandboxes.yaml @@ -3803,6 +3803,21 @@ spec: - containers type: object type: object + ttl: + properties: + seconds: + format: int32 + type: integer + shutdownAt: + type: string + startPolicy: + enum: + - onCreate + - onReady + - onEnable + - disable + type: string + type: object required: - podTemplate type: object @@ -3844,10 +3859,16 @@ spec: - type type: object type: array + firstReadyTime: + format: date-time + type: string service: type: string serviceFQDN: type: string + ttlShutdownAt: + format: date-time + type: string type: object required: - spec