diff --git a/documentation/docs/api/crds/scan.md b/documentation/docs/api/crds/scan.md index a0574b0e53..b6a23bdf0c 100644 --- a/documentation/docs/api/crds/scan.md +++ b/documentation/docs/api/crds/scan.md @@ -370,6 +370,24 @@ ttlSecondsAfterFinished: 30 #deletes the scan after 30 seconds after completion ttlSecondsAfterFinished can also be set for the scan (as part of the [jobTemplate](https://www.securecodebox.io/docs/api/crds/scan-type#jobtemplate-required)), [parser](https://www.securecodebox.io/docs/api/crds/parse-definition) and [hook](https://www.securecodebox.io/docs/api/crds/scan-completion-hook#ttlsecondsafterfinished-optional) jobs individually. Setting these will only delete the jobs, not the entire scan. ::: +### Suspend (Optional) + +`suspend` specifies whether the Scan should be suspended. When a Scan is suspended, the reconciler will not process it, effectively pausing all operations until it is resumed. This behaves similar to the suspend field in Kubernetes Jobs. + +When set to `true`, the scan will not progress through its lifecycle states (Init, Scanning, Parsing, etc.). However, TTL-based cleanup still works on suspended scans that are in Done or Errored states to prevent accumulation of completed suspended scans. + +Defaults to `false` if not set. + +```yaml +suspend: true # Suspends the scan, preventing any operations +``` + +To resume a suspended scan, you can patch the scan resource: + +```bash +kubectl patch scan my-scan --type merge -p '{"spec":{"suspend":false}}' +``` + ## Metadata Metadata is a standard field on Kubernetes resources. It contains multiple relevant fields, e.g. the name of the resource, its namespace and a `creationTimestamp` of the resource. See more on the [Kubernetes Docs](https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/) and the [Kubernetes API Reference](https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/object-meta/). @@ -443,4 +461,5 @@ spec: cpu: 4 memory: 4Gi ttlSecondsAfterFinished: 300 + suspend: false ``` diff --git a/documentation/docs/api/crds/scheduled-scan.md b/documentation/docs/api/crds/scheduled-scan.md index 20bf95c960..3a6a6b72cb 100644 --- a/documentation/docs/api/crds/scheduled-scan.md +++ b/documentation/docs/api/crds/scheduled-scan.md @@ -62,6 +62,24 @@ When `retriggerOnScanTypeChange` is enabled, it will automatically trigger a new Defaults to `false` if not set. +### Suspend (Optional) + +`suspend` specifies whether the ScheduledScan should be suspended. When a ScheduledScan is suspended, no new Scans will be created according to the schedule. This behaves similar to the suspend field in Kubernetes CronJobs. + +When set to `true`, the ScheduledScan will continue to reconcile (updating status and tracking the schedule), but it will skip creating new Scan resources. Any scans that are already running will continue to completion. + +Defaults to `false` if not set. + +```yaml +suspend: true # Suspends the scheduled scan, preventing new scan creation +``` + +To resume a suspended scheduled scan: + +```bash +kubectl patch scheduledscan my-scheduled-scan --type merge -p '{"spec":{"suspend":false}}' +``` + ## Example with an Interval ```yaml @@ -81,6 +99,7 @@ spec: failedJobsHistoryLimit: 5 concurrencyPolicy: "Allow" retriggerOnScanTypeChange: false + suspend: false ``` ## Example with a Cron Schedule @@ -102,4 +121,5 @@ spec: failedJobsHistoryLimit: 5 concurrencyPolicy: "Forbid" retriggerOnScanTypeChange: true + suspend: false ``` diff --git a/operator/apis/execution/v1/scan_types.go b/operator/apis/execution/v1/scan_types.go index ff1869675d..467428a640 100644 --- a/operator/apis/execution/v1/scan_types.go +++ b/operator/apis/execution/v1/scan_types.go @@ -148,6 +148,10 @@ type ScanSpec struct { Resources corev1.ResourceRequirements `json:"resources,omitempty"` // ttlSecondsAfterFinished limits the lifetime of a Scan that has finished execution (either Done or Errored). If this field is set ttlSecondsAfterFinished after the Scan finishes, it is eligible to be automatically deleted. When the Scan is being deleted, its lifecycle guarantees (e.g. finalizers) will be honored. If this field is unset, the Scan won't be automatically deleted. If this is set to zero, the Scan becomes eligible to be deleted immediately after it finishes. TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` + // Suspend specifies whether the Scan should be suspended. When a Scan is suspended, the reconciler will not process it, effectively pausing all operations until it is resumed. This behaves similar to the suspend field in Kubernetes Jobs. TTL-based cleanup still works on suspended scans that are Done or Errored. + // +kubebuilder:validation:Optional + // +kubebuilder:default=false + Suspend *bool `json:"suspend,omitempty"` } type ScanState string diff --git a/operator/apis/execution/v1/scheduledscan_types.go b/operator/apis/execution/v1/scheduledscan_types.go index 052d2048aa..a8cada6a84 100644 --- a/operator/apis/execution/v1/scheduledscan_types.go +++ b/operator/apis/execution/v1/scheduledscan_types.go @@ -50,6 +50,11 @@ type ScheduledScanSpec struct { // +kubebuilder:validation:Optional // +kubebuilder:default=false RetriggerOnScanTypeChange bool `json:"retriggerOnScanTypeChange,omitempty"` + + // Suspend specifies whether the ScheduledScan should be suspended. When a ScheduledScan is suspended, no new Scans will be created according to the schedule. This behaves similar to the suspend field in Kubernetes CronJobs. + // +kubebuilder:validation:Optional + // +kubebuilder:default=false + Suspend *bool `json:"suspend,omitempty"` } // ConcurrencyPolicy describes how the job will be handled. diff --git a/operator/apis/execution/v1/zz_generated.deepcopy.go b/operator/apis/execution/v1/zz_generated.deepcopy.go index 7669a340c6..f8b3ab1cb4 100644 --- a/operator/apis/execution/v1/zz_generated.deepcopy.go +++ b/operator/apis/execution/v1/zz_generated.deepcopy.go @@ -716,6 +716,11 @@ func (in *ScanSpec) DeepCopyInto(out *ScanSpec) { *out = new(int32) **out = **in } + if in.Suspend != nil { + in, out := &in.Suspend, &out.Suspend + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScanSpec. @@ -939,6 +944,11 @@ func (in *ScheduledScanSpec) DeepCopyInto(out *ScheduledScanSpec) { *out = new(ScanSpec) (*in).DeepCopyInto(*out) } + if in.Suspend != nil { + in, out := &in.Suspend, &out.Suspend + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledScanSpec. diff --git a/operator/controllers/execution/scans/scan_controller.go b/operator/controllers/execution/scans/scan_controller.go index 5a0f202113..b116c49363 100644 --- a/operator/controllers/execution/scans/scan_controller.go +++ b/operator/controllers/execution/scans/scan_controller.go @@ -83,6 +83,16 @@ func (r *ScanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. log.V(5).Info("Scan Found", "Type", scan.Spec.ScanType, "State", scan.Status.State) + // Check if the scan is suspended. If so, skip reconciliation unless the scan is in a terminal state + // where TTL-based cleanup should still work. + if scan.Spec.Suspend != nil && *scan.Spec.Suspend { + if scan.Status.State != executionv1.ScanStateDone && scan.Status.State != executionv1.ScanStateErrored { + log.V(7).Info("Scan is suspended, skipping reconciliation") + return ctrl.Result{}, nil + } + // For Done/Errored scans, continue to allow TTL cleanup + } + // Handle Finalizer if the scan is getting deleted if !scan.ObjectMeta.DeletionTimestamp.IsZero() { // Check if this Scan has not yet been converted to new CRD diff --git a/operator/controllers/execution/scans/scan_reconciler_test.go b/operator/controllers/execution/scans/scan_reconciler_test.go index b730eb5ca2..f97179e885 100644 --- a/operator/controllers/execution/scans/scan_reconciler_test.go +++ b/operator/controllers/execution/scans/scan_reconciler_test.go @@ -232,4 +232,120 @@ var _ = Describe("ScanControllers", func() { }) }) + + Context("Suspend Functionality", func() { + It("should return true for TTL cleanup on suspended Done scan", func() { + finishTime := time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) + var timeout int32 = 30 + suspend := true + var scan = &executionv1.Scan{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "nmap", + }, + Spec: executionv1.ScanSpec{ + ScanType: "nmap", + Parameters: []string{"scanme.nmap.org"}, + TTLSecondsAfterFinished: &timeout, + Suspend: &suspend, + }, + Status: executionv1.ScanStatus{ + State: executionv1.ScanStateDone, + FinishedAt: &metav1.Time{Time: finishTime}, + }, + } + // TTL cleanup should still work even when suspended + Expect(reconciler.checkIfTTLSecondsAfterFinishedIsCompleted(scan)).To(BeTrue()) + }) + + It("should return true for TTL cleanup on suspended Errored scan", func() { + finishTime := time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) + var timeout int32 = 30 + suspend := true + var scan = &executionv1.Scan{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "nmap", + }, + Spec: executionv1.ScanSpec{ + ScanType: "nmap", + Parameters: []string{"scanme.nmap.org"}, + TTLSecondsAfterFinished: &timeout, + Suspend: &suspend, + }, + Status: executionv1.ScanStatus{ + State: executionv1.ScanStateErrored, + FinishedAt: &metav1.Time{Time: finishTime}, + }, + } + // TTL cleanup should still work even when suspended + Expect(reconciler.checkIfTTLSecondsAfterFinishedIsCompleted(scan)).To(BeTrue()) + }) + + It("should identify a suspended scan correctly", func() { + suspend := true + var scan = &executionv1.Scan{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "nmap", + }, + Spec: executionv1.ScanSpec{ + ScanType: "nmap", + Parameters: []string{"scanme.nmap.org"}, + Suspend: &suspend, + }, + Status: executionv1.ScanStatus{ + State: executionv1.ScanStateInit, + }, + } + // Verify the suspend flag is properly set + Expect(scan.Spec.Suspend).NotTo(BeNil()) + Expect(*scan.Spec.Suspend).To(BeTrue()) + }) + + It("should identify a non-suspended scan correctly when Suspend is false", func() { + suspend := false + var scan = &executionv1.Scan{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "nmap", + }, + Spec: executionv1.ScanSpec{ + ScanType: "nmap", + Parameters: []string{"scanme.nmap.org"}, + Suspend: &suspend, + }, + Status: executionv1.ScanStatus{ + State: executionv1.ScanStateInit, + }, + } + // Verify the suspend flag is properly set to false + Expect(scan.Spec.Suspend).NotTo(BeNil()) + Expect(*scan.Spec.Suspend).To(BeFalse()) + }) + + It("should handle nil Suspend field as not suspended", func() { + var scan = &executionv1.Scan{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "nmap", + }, + Spec: executionv1.ScanSpec{ + ScanType: "nmap", + Parameters: []string{"scanme.nmap.org"}, + Suspend: nil, // Not set, should default to false + }, + Status: executionv1.ScanStatus{ + State: executionv1.ScanStateInit, + }, + } + // When Suspend is nil, the scan should not be considered suspended + if scan.Spec.Suspend != nil { + Expect(*scan.Spec.Suspend).To(BeFalse()) + } else { + // nil is treated as not suspended + Expect(scan.Spec.Suspend).To(BeNil()) + } + }) + }) }) diff --git a/operator/controllers/execution/scheduledscan_controller.go b/operator/controllers/execution/scheduledscan_controller.go index 8206d97a6c..3746d7f1c8 100644 --- a/operator/controllers/execution/scheduledscan_controller.go +++ b/operator/controllers/execution/scheduledscan_controller.go @@ -124,6 +124,12 @@ func (r *ScheduledScanReconciler) Reconcile(ctx context.Context, req ctrl.Reques InProgressScans := getScansInProgress(childScans.Items) + // Check if the ScheduledScan is suspended. If so, skip creating new scans. + if scheduledScan.Spec.Suspend != nil && *scheduledScan.Spec.Suspend { + log.V(7).Info("ScheduledScan is suspended, skipping scan creation") + return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil + } + // check if it is time to start the next Scan if !time.Now().Before(nextSchedule) { // check concurrency policy diff --git a/operator/controllers/execution/scheduledscan_controller_test.go b/operator/controllers/execution/scheduledscan_controller_test.go index 62d9cefde2..a620f71da6 100644 --- a/operator/controllers/execution/scheduledscan_controller_test.go +++ b/operator/controllers/execution/scheduledscan_controller_test.go @@ -177,4 +177,72 @@ var _ = Describe("ScheduledScan controller", func() { Expect(firstScanName).ShouldNot(Equal(secondScanName)) }) }) + + Context("Suspend Functionality", func() { + It("should identify a suspended ScheduledScan correctly", func() { + suspend := true + scheduledScan := &executionv1.ScheduledScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-scan", + Namespace: "test-namespace", + }, + Spec: executionv1.ScheduledScanSpec{ + Interval: metav1.Duration{Duration: 1 * time.Hour}, + ScanSpec: &executionv1.ScanSpec{ + ScanType: "nmap", + Parameters: []string{"scanme.nmap.org"}, + }, + Suspend: &suspend, + }, + } + // Verify the suspend flag is properly set + Expect(scheduledScan.Spec.Suspend).NotTo(BeNil()) + Expect(*scheduledScan.Spec.Suspend).To(BeTrue()) + }) + + It("should identify a non-suspended ScheduledScan correctly when Suspend is false", func() { + suspend := false + scheduledScan := &executionv1.ScheduledScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-scan", + Namespace: "test-namespace", + }, + Spec: executionv1.ScheduledScanSpec{ + Interval: metav1.Duration{Duration: 1 * time.Hour}, + ScanSpec: &executionv1.ScanSpec{ + ScanType: "nmap", + Parameters: []string{"scanme.nmap.org"}, + }, + Suspend: &suspend, + }, + } + // Verify the suspend flag is properly set to false + Expect(scheduledScan.Spec.Suspend).NotTo(BeNil()) + Expect(*scheduledScan.Spec.Suspend).To(BeFalse()) + }) + + It("should handle nil Suspend field as not suspended", func() { + scheduledScan := &executionv1.ScheduledScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-scan", + Namespace: "test-namespace", + }, + Spec: executionv1.ScheduledScanSpec{ + Interval: metav1.Duration{Duration: 1 * time.Hour}, + ScanSpec: &executionv1.ScanSpec{ + ScanType: "nmap", + Parameters: []string{"scanme.nmap.org"}, + }, + Suspend: nil, // Not set, should default to false + }, + } + // When Suspend is nil, the scan should not be considered suspended + if scheduledScan.Spec.Suspend != nil { + Expect(*scheduledScan.Spec.Suspend).To(BeFalse()) + } else { + // nil is treated as not suspended + Expect(scheduledScan.Spec.Suspend).To(BeNil()) + } + }) + }) }) diff --git a/operator/crds/cascading.securecodebox.io_cascadingrules.yaml b/operator/crds/cascading.securecodebox.io_cascadingrules.yaml index c5de223579..a12a58c1a3 100644 --- a/operator/crds/cascading.securecodebox.io_cascadingrules.yaml +++ b/operator/crds/cascading.securecodebox.io_cascadingrules.yaml @@ -2767,6 +2767,13 @@ spec: scanType: description: The name of the scanType which should be started. type: string + suspend: + default: false + description: Suspend specifies whether the Scan should be suspended. + When a Scan is suspended, the reconciler will not process it, + effectively pausing all operations until it is resumed. This + behaves similar to the suspend field in Kubernetes Jobs. + type: boolean tolerations: description: Tolerations are a different way to control on which nodes your scan is executed. See https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/ diff --git a/operator/crds/execution.securecodebox.io_scans.yaml b/operator/crds/execution.securecodebox.io_scans.yaml index bf626f1e7e..4030618a34 100644 --- a/operator/crds/execution.securecodebox.io_scans.yaml +++ b/operator/crds/execution.securecodebox.io_scans.yaml @@ -2710,6 +2710,13 @@ spec: scanType: description: The name of the scanType which should be started. type: string + suspend: + default: false + description: Suspend specifies whether the Scan should be suspended. + When a Scan is suspended, the reconciler will not process it, effectively + pausing all operations until it is resumed. This behaves similar + to the suspend field in Kubernetes Jobs. + type: boolean tolerations: description: Tolerations are a different way to control on which nodes your scan is executed. See https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/ diff --git a/operator/crds/execution.securecodebox.io_scheduledscans.yaml b/operator/crds/execution.securecodebox.io_scheduledscans.yaml index dc0d85c7d4..d60101ac54 100644 --- a/operator/crds/execution.securecodebox.io_scheduledscans.yaml +++ b/operator/crds/execution.securecodebox.io_scheduledscans.yaml @@ -2761,6 +2761,13 @@ spec: scanType: description: The name of the scanType which should be started. type: string + suspend: + default: false + description: Suspend specifies whether the Scan should be suspended. + When a Scan is suspended, the reconciler will not process it, + effectively pausing all operations until it is resumed. This + behaves similar to the suspend field in Kubernetes Jobs. + type: boolean tolerations: description: Tolerations are a different way to control on which nodes your scan is executed. See https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/ @@ -4540,6 +4547,13 @@ spec: format: int32 minimum: 0 type: integer + suspend: + default: false + description: Suspend specifies whether the ScheduledScan should be + suspended. When a ScheduledScan is suspended, no new Scans will + be created according to the schedule. This behaves similar to the + suspend field in Kubernetes CronJobs. + type: boolean required: - scanSpec type: object