Browse Source

feat(externalsecret): add SyncWindows to gate periodic refresh (#6452)

* feat(externalsecret): add SyncWindows to gate periodic refresh

ExternalSecretSpec gains an optional syncWindows field. The block holds
a kind (allow or deny) and a list of schedule+duration windows evaluated
in UTC via standard 5-field cron expressions (github.com/robfig/cron/v3).

- kind=allow: periodic syncs are permitted only while at least one
  window is active; all other times are blocked.
- kind=deny: periodic syncs are blocked while any window is active;
  all other times proceed normally.

Windows with an unparseable Schedule are silently ignored so a typo
does not permanently block syncs. Only evaluated for Periodic refresh
policy (and the unset default); OnChange and CreatedOnce are unaffected.

Refs: external-secrets/external-secrets#4931
Signed-off-by: Alexander Chernov <alexander@chernov.it>

* test(externalsecret): add unit tests for SyncWindows window logic

TestIsWithinSyncWindow covers boundary conditions (open, close, inside,
outside, between occurrences, second occurrence). TestIsPeriodicRefreshAllowed
ByWindows covers nil/empty windows, allow/deny kinds, invalid schedule
handling, mixed invalid+valid entries, and the unknown-kind default.

Refs: external-secrets/external-secrets#4931
Signed-off-by: Alexander Chernov <alexander@chernov.it>

* chore: run make reviewable; fix lint issues

Regenerated deepcopy, CRD manifests, bundle, and API spec via
`make reviewable`. Fixed two lint findings:
- syncwindow_test.go: renamed `close` to `closeTime` (gocritic
  builtinShadow)
- providers/v1/crd/test/doc.go: aligned package comment with package
  name (revive package-comments)

Refs: external-secrets/external-secrets#4931
Signed-off-by: Alexander Chernov <alexander@chernov.it>

* fix(externalsecret): address code review feedback on SyncWindows

- Add validation pattern to Schedule covering 5-field cron, @-shorthands,
  and @every intervals; drop @reboot because robfig returns zero from
  Next() for it, making it silently inactive instead of failing validation
- Remove duplicate Enum marker on Kind; the type-level declaration is
  sufficient
- Promote github.com/robfig/cron/v3 to a direct dependency in go.mod
- Capture time.Now() once in shouldRefreshPeriodic so the future-time,
  interval-elapsed, and sync-window checks all see the same instant
- Emit a V(1) log when periodic refresh is blocked by a sync window
- Note in externalsecret.md that syncWindows only suppresses syncs; if
  refreshInterval > window.duration a window occurrence can be missed
  between requeues by design
- Regenerate CRDs, snapshots, and docs via make reviewable

Refs: external-secrets/external-secrets#4931
Signed-off-by: Alexander Chernov <alexander@chernov.it>

* chore(crds): regenerate CRDs to drop stale @reboot from schedule

The Schedule validation marker dropped @reboot in 681cf9a (robfig Next()
returns the zero time for it, making such a window silently inactive), but
the committed CRDs were not regenerated and still carried |reboot in the
pattern. Regenerating aligns the CRDs with the API marker and fixes the
failing check-diff job.

Refs: external-secrets/external-secrets#4931
Signed-off-by: Alexander Chernov <alexander@chernov.it>

* fix(externalsecret): log unparseable sync window schedules

isPeriodicRefreshAllowedByWindows silently skipped a window whose schedule
failed to parse. With kind=allow a typo would then suppress all periodic
refreshes with no operator signal. Add a V(1) debug log on parse failure,
mirroring the existing block log. The kubebuilder pattern marker remains the
primary guard at admission; this is defensive for any value that slips past it.

Refs: external-secrets/external-secrets#4931
Signed-off-by: Alexander Chernov <alexander@chernov.it>

---------

Signed-off-by: Alexander Chernov <alexander@chernov.it>
Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Alexander Chernov 4 hours ago
parent
commit
bbc9b97b9c

+ 50 - 0
apis/externalsecrets/v1/externalsecret_types.go

@@ -524,6 +524,51 @@ const (
 	RefreshPolicyOnChange ExternalSecretRefreshPolicy = "OnChange"
 )
 
+// ExternalSecretSyncWindowKind defines whether a SyncWindow permits or
+// blocks periodic refreshes.
+// +kubebuilder:validation:Enum=allow;deny
+type ExternalSecretSyncWindowKind string
+
+const (
+	// SyncWindowAllow allows periodic refreshes only while at least one window
+	// in the list is active. Refreshes are blocked at all other times.
+	SyncWindowAllow ExternalSecretSyncWindowKind = "allow"
+	// SyncWindowDeny blocks periodic refreshes while any window in the list is
+	// active. Refreshes proceed normally at all other times.
+	SyncWindowDeny ExternalSecretSyncWindowKind = "deny"
+)
+
+// ExternalSecretSyncWindowEntry defines a single cron-schedule + duration pair
+// within a SyncWindows block.
+type ExternalSecretSyncWindowEntry struct {
+	// Schedule is a standard 5-field cron expression evaluated in UTC, or a
+	// named shorthand such as @daily or @every 1h. It marks the start time of
+	// each window occurrence.
+	// Example: "0 22 * * 1-5" opens a window every weekday at 22:00 UTC.
+	// +kubebuilder:validation:MinLength=1
+	// +kubebuilder:validation:Pattern:=`^(@(annually|yearly|monthly|weekly|daily|midnight|hourly)|@every [^\s]+.*|[^\s]+( [^\s]+){4})$`
+	Schedule string `json:"schedule"`
+
+	// Duration specifies how long the window stays open after each Schedule
+	// firing. Example: "8h".
+	Duration metav1.Duration `json:"duration"`
+}
+
+// ExternalSecretSyncWindows optionally restricts when periodic syncs may occur.
+// All windows in the list share the same Kind.
+type ExternalSecretSyncWindows struct {
+	// Kind applies to every window in the list.
+	// "allow" -- syncs are permitted only while at least one window is active;
+	//            all other times are blocked.
+	// "deny"  -- syncs are blocked while any window is active;
+	//            all other times are permitted.
+	Kind ExternalSecretSyncWindowKind `json:"kind"`
+
+	// Windows is the list of schedule+duration pairs.
+	// +kubebuilder:validation:MinItems=1
+	Windows []ExternalSecretSyncWindowEntry `json:"windows"`
+}
+
 // ExternalSecretSpec defines the desired state of ExternalSecret.
 type ExternalSecretSpec struct {
 	// +optional
@@ -549,6 +594,11 @@ type ExternalSecretSpec struct {
 	// +kubebuilder:default="1h0m0s"
 	RefreshInterval *metav1.Duration `json:"refreshInterval,omitempty"`
 
+	// SyncWindows optionally restricts when periodic refreshes may occur.
+	// Evaluated in UTC, only for Periodic refresh policy (or when refreshPolicy is unset).
+	// +optional
+	SyncWindows *ExternalSecretSyncWindows `json:"syncWindows,omitempty"`
+
 	// Data defines the connection between the Kubernetes Secret keys and the Provider data
 	// +optional
 	Data []ExternalSecretData `json:"data,omitempty"`

+ 41 - 0
apis/externalsecrets/v1/zz_generated.deepcopy.go

@@ -1751,6 +1751,11 @@ func (in *ExternalSecretSpec) DeepCopyInto(out *ExternalSecretSpec) {
 		*out = new(metav1.Duration)
 		**out = **in
 	}
+	if in.SyncWindows != nil {
+		in, out := &in.SyncWindows, &out.SyncWindows
+		*out = new(ExternalSecretSyncWindows)
+		(*in).DeepCopyInto(*out)
+	}
 	if in.Data != nil {
 		in, out := &in.Data, &out.Data
 		*out = make([]ExternalSecretData, len(*in))
@@ -1817,6 +1822,42 @@ func (in *ExternalSecretStatusCondition) DeepCopy() *ExternalSecretStatusConditi
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExternalSecretSyncWindowEntry) DeepCopyInto(out *ExternalSecretSyncWindowEntry) {
+	*out = *in
+	out.Duration = in.Duration
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalSecretSyncWindowEntry.
+func (in *ExternalSecretSyncWindowEntry) DeepCopy() *ExternalSecretSyncWindowEntry {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalSecretSyncWindowEntry)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExternalSecretSyncWindows) DeepCopyInto(out *ExternalSecretSyncWindows) {
+	*out = *in
+	if in.Windows != nil {
+		in, out := &in.Windows, &out.Windows
+		*out = make([]ExternalSecretSyncWindowEntry, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalSecretSyncWindows.
+func (in *ExternalSecretSyncWindows) DeepCopy() *ExternalSecretSyncWindows {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalSecretSyncWindows)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ExternalSecretTarget) DeepCopyInto(out *ExternalSecretTarget) {
 	*out = *in

+ 48 - 0
config/crds/bases/external-secrets.io_clusterexternalsecrets.yaml

@@ -521,6 +521,54 @@ spec:
                         pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
                         type: string
                     type: object
+                  syncWindows:
+                    description: |-
+                      SyncWindows optionally restricts when periodic refreshes may occur.
+                      Evaluated in UTC, only for Periodic refresh policy (or when refreshPolicy is unset).
+                    properties:
+                      kind:
+                        description: |-
+                          Kind applies to every window in the list.
+                          "allow" -- syncs are permitted only while at least one window is active;
+                                     all other times are blocked.
+                          "deny"  -- syncs are blocked while any window is active;
+                                     all other times are permitted.
+                        enum:
+                        - allow
+                        - deny
+                        type: string
+                      windows:
+                        description: Windows is the list of schedule+duration pairs.
+                        items:
+                          description: |-
+                            ExternalSecretSyncWindowEntry defines a single cron-schedule + duration pair
+                            within a SyncWindows block.
+                          properties:
+                            duration:
+                              description: |-
+                                Duration specifies how long the window stays open after each Schedule
+                                firing. Example: "8h".
+                              type: string
+                            schedule:
+                              description: |-
+                                Schedule is a standard 5-field cron expression evaluated in UTC, or a
+                                named shorthand such as @daily or @every 1h. It marks the start time of
+                                each window occurrence.
+                                Example: "0 22 * * 1-5" opens a window every weekday at 22:00 UTC.
+                              minLength: 1
+                              pattern: ^(@(annually|yearly|monthly|weekly|daily|midnight|hourly)|@every
+                                [^\s]+.*|[^\s]+( [^\s]+){4})$
+                              type: string
+                          required:
+                          - duration
+                          - schedule
+                          type: object
+                        minItems: 1
+                        type: array
+                    required:
+                    - kind
+                    - windows
+                    type: object
                   target:
                     default:
                       creationPolicy: Owner

+ 48 - 0
config/crds/bases/external-secrets.io_externalsecrets.yaml

@@ -504,6 +504,54 @@ spec:
                     pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
                     type: string
                 type: object
+              syncWindows:
+                description: |-
+                  SyncWindows optionally restricts when periodic refreshes may occur.
+                  Evaluated in UTC, only for Periodic refresh policy (or when refreshPolicy is unset).
+                properties:
+                  kind:
+                    description: |-
+                      Kind applies to every window in the list.
+                      "allow" -- syncs are permitted only while at least one window is active;
+                                 all other times are blocked.
+                      "deny"  -- syncs are blocked while any window is active;
+                                 all other times are permitted.
+                    enum:
+                    - allow
+                    - deny
+                    type: string
+                  windows:
+                    description: Windows is the list of schedule+duration pairs.
+                    items:
+                      description: |-
+                        ExternalSecretSyncWindowEntry defines a single cron-schedule + duration pair
+                        within a SyncWindows block.
+                      properties:
+                        duration:
+                          description: |-
+                            Duration specifies how long the window stays open after each Schedule
+                            firing. Example: "8h".
+                          type: string
+                        schedule:
+                          description: |-
+                            Schedule is a standard 5-field cron expression evaluated in UTC, or a
+                            named shorthand such as @daily or @every 1h. It marks the start time of
+                            each window occurrence.
+                            Example: "0 22 * * 1-5" opens a window every weekday at 22:00 UTC.
+                          minLength: 1
+                          pattern: ^(@(annually|yearly|monthly|weekly|daily|midnight|hourly)|@every
+                            [^\s]+.*|[^\s]+( [^\s]+){4})$
+                          type: string
+                      required:
+                      - duration
+                      - schedule
+                      type: object
+                    minItems: 1
+                    type: array
+                required:
+                - kind
+                - windows
+                type: object
               target:
                 default:
                   creationPolicy: Owner

+ 94 - 0
deploy/crds/bundle.yaml

@@ -491,6 +491,53 @@ spec:
                           pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
                           type: string
                       type: object
+                    syncWindows:
+                      description: |-
+                        SyncWindows optionally restricts when periodic refreshes may occur.
+                        Evaluated in UTC, only for Periodic refresh policy (or when refreshPolicy is unset).
+                      properties:
+                        kind:
+                          description: |-
+                            Kind applies to every window in the list.
+                            "allow" -- syncs are permitted only while at least one window is active;
+                                       all other times are blocked.
+                            "deny"  -- syncs are blocked while any window is active;
+                                       all other times are permitted.
+                          enum:
+                            - allow
+                            - deny
+                          type: string
+                        windows:
+                          description: Windows is the list of schedule+duration pairs.
+                          items:
+                            description: |-
+                              ExternalSecretSyncWindowEntry defines a single cron-schedule + duration pair
+                              within a SyncWindows block.
+                            properties:
+                              duration:
+                                description: |-
+                                  Duration specifies how long the window stays open after each Schedule
+                                  firing. Example: "8h".
+                                type: string
+                              schedule:
+                                description: |-
+                                  Schedule is a standard 5-field cron expression evaluated in UTC, or a
+                                  named shorthand such as @daily or @every 1h. It marks the start time of
+                                  each window occurrence.
+                                  Example: "0 22 * * 1-5" opens a window every weekday at 22:00 UTC.
+                                minLength: 1
+                                pattern: ^(@(annually|yearly|monthly|weekly|daily|midnight|hourly)|@every [^\s]+.*|[^\s]+( [^\s]+){4})$
+                                type: string
+                            required:
+                              - duration
+                              - schedule
+                            type: object
+                          minItems: 1
+                          type: array
+                      required:
+                        - kind
+                        - windows
+                      type: object
                     target:
                       default:
                         creationPolicy: Owner
@@ -13418,6 +13465,53 @@ spec:
                       pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
                       type: string
                   type: object
+                syncWindows:
+                  description: |-
+                    SyncWindows optionally restricts when periodic refreshes may occur.
+                    Evaluated in UTC, only for Periodic refresh policy (or when refreshPolicy is unset).
+                  properties:
+                    kind:
+                      description: |-
+                        Kind applies to every window in the list.
+                        "allow" -- syncs are permitted only while at least one window is active;
+                                   all other times are blocked.
+                        "deny"  -- syncs are blocked while any window is active;
+                                   all other times are permitted.
+                      enum:
+                        - allow
+                        - deny
+                      type: string
+                    windows:
+                      description: Windows is the list of schedule+duration pairs.
+                      items:
+                        description: |-
+                          ExternalSecretSyncWindowEntry defines a single cron-schedule + duration pair
+                          within a SyncWindows block.
+                        properties:
+                          duration:
+                            description: |-
+                              Duration specifies how long the window stays open after each Schedule
+                              firing. Example: "8h".
+                            type: string
+                          schedule:
+                            description: |-
+                              Schedule is a standard 5-field cron expression evaluated in UTC, or a
+                              named shorthand such as @daily or @every 1h. It marks the start time of
+                              each window occurrence.
+                              Example: "0 22 * * 1-5" opens a window every weekday at 22:00 UTC.
+                            minLength: 1
+                            pattern: ^(@(annually|yearly|monthly|weekly|daily|midnight|hourly)|@every [^\s]+.*|[^\s]+( [^\s]+){4})$
+                            type: string
+                        required:
+                          - duration
+                          - schedule
+                        type: object
+                      minItems: 1
+                      type: array
+                  required:
+                    - kind
+                    - windows
+                  type: object
                 target:
                   default:
                     creationPolicy: Owner

+ 51 - 0
docs/api/externalsecret.md

@@ -84,6 +84,57 @@ If supported by the configured `refreshPolicy`, you can manually trigger a refre
 kubectl annotate es my-es force-sync=$(date +%s) --overwrite
 ```
 
+## SyncWindows
+
+`syncWindows` restricts **when** periodic refreshes may occur. It is evaluated in UTC and applies only to the `Periodic` refresh policy (or when `refreshPolicy` is unset). `OnChange` and `CreatedOnce` policies are unaffected.
+
+A sync-windows block carries a shared `kind` and a list of `schedule + duration` entries:
+
+- `kind: allow` -- periodic syncs are permitted **only** while at least one window is active; all other times are blocked.
+- `kind: deny` -- periodic syncs are **blocked** while any window is active; all other times proceed normally.
+
+Each entry in `windows` uses a standard 5-field cron `schedule` (UTC) and a `duration` string (e.g. `8h`, `30m`). The window stays open for `duration` after each schedule firing. A window entry with an unparseable `schedule` is silently ignored and treated as inactive, so a typo does not permanently block syncs.
+
+### Example: allow syncs only during business hours (Mon-Fri 09:00-17:00 UTC)
+
+```yaml
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: example
+spec:
+  refreshInterval: 1h
+  syncWindows:
+    kind: allow
+    windows:
+      - schedule: "0 9 * * 1-5"  # weekdays at 09:00 UTC
+        duration: 8h              # window open until 17:00 UTC
+```
+
+### Example: block syncs during a Saturday maintenance window (02:00-04:00 UTC)
+
+```yaml
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: example
+spec:
+  refreshInterval: 30m
+  syncWindows:
+    kind: deny
+    windows:
+      - schedule: "0 2 * * 6"  # Saturdays at 02:00 UTC
+        duration: 2h            # block until 04:00 UTC
+```
+
+### Multiple windows
+
+You can list several entries under `windows`. For `kind: allow`, the sync is permitted when **any** window is active. For `kind: deny`, the sync is blocked when **any** window is active.
+
+### Interaction with refreshInterval
+
+`syncWindows` only suppresses sync operations -- it does not change how often the controller checks. The controller still requeues at `refreshInterval` regardless of whether a sync was blocked. This means that if `refreshInterval` is longer than `window.duration`, a window could open and close entirely between two consecutive checks and the sync would be missed for that occurrence. This is by design: `refreshInterval` is the primary driver; `syncWindows` is a gate on top of it. To ensure no window occurrence is missed, set `refreshInterval` to a value shorter than the smallest `window.duration`.
+
 ## Features
 
 Individual features are described in the [Guides section](../guides/introduction.md):

+ 155 - 0
docs/api/spec.md

@@ -3988,6 +3988,21 @@ May be set to &ldquo;0s&rdquo; to fetch and create it once. Defaults to 1h0m0s.<
 </tr>
 <tr>
 <td>
+<code>syncWindows</code></br>
+<em>
+<a href="#external-secrets.io/v1.ExternalSecretSyncWindows">
+ExternalSecretSyncWindows
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>SyncWindows optionally restricts when periodic refreshes may occur.
+Evaluated in UTC, only for Periodic refresh policy (or when refreshPolicy is unset).</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>data</code></br>
 <em>
 <a href="#external-secrets.io/v1.ExternalSecretData">
@@ -5023,6 +5038,21 @@ May be set to &ldquo;0s&rdquo; to fetch and create it once. Defaults to 1h0m0s.<
 </tr>
 <tr>
 <td>
+<code>syncWindows</code></br>
+<em>
+<a href="#external-secrets.io/v1.ExternalSecretSyncWindows">
+ExternalSecretSyncWindows
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>SyncWindows optionally restricts when periodic refreshes may occur.
+Evaluated in UTC, only for Periodic refresh policy (or when refreshPolicy is unset).</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>data</code></br>
 <em>
 <a href="#external-secrets.io/v1.ExternalSecretData">
@@ -5200,6 +5230,131 @@ Kubernetes meta/v1.Time
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1.ExternalSecretSyncWindowEntry">ExternalSecretSyncWindowEntry
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.ExternalSecretSyncWindows">ExternalSecretSyncWindows</a>)
+</p>
+<p>
+<p>ExternalSecretSyncWindowEntry defines a single cron-schedule + duration pair
+within a SyncWindows block.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>schedule</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<p>Schedule is a standard 5-field cron expression evaluated in UTC, or a
+named shorthand such as @daily or @every 1h. It marks the start time of
+each window occurrence.
+Example: &ldquo;0 22 * * 1-5&rdquo; opens a window every weekday at 22:00 UTC.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>duration</code></br>
+<em>
+<a href="https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Duration">
+Kubernetes meta/v1.Duration
+</a>
+</em>
+</td>
+<td>
+<p>Duration specifies how long the window stays open after each Schedule
+firing. Example: &ldquo;8h&rdquo;.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1.ExternalSecretSyncWindowKind">ExternalSecretSyncWindowKind
+(<code>string</code> alias)</p></h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.ExternalSecretSyncWindows">ExternalSecretSyncWindows</a>)
+</p>
+<p>
+<p>ExternalSecretSyncWindowKind defines whether a SyncWindow permits or
+blocks periodic refreshes.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Value</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody><tr><td><p>&#34;allow&#34;</p></td>
+<td><p>SyncWindowAllow allows periodic refreshes only while at least one window
+in the list is active. Refreshes are blocked at all other times.</p>
+</td>
+</tr><tr><td><p>&#34;deny&#34;</p></td>
+<td><p>SyncWindowDeny blocks periodic refreshes while any window in the list is
+active. Refreshes proceed normally at all other times.</p>
+</td>
+</tr></tbody>
+</table>
+<h3 id="external-secrets.io/v1.ExternalSecretSyncWindows">ExternalSecretSyncWindows
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.ExternalSecretSpec">ExternalSecretSpec</a>)
+</p>
+<p>
+<p>ExternalSecretSyncWindows optionally restricts when periodic syncs may occur.
+All windows in the list share the same Kind.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>kind</code></br>
+<em>
+<a href="#external-secrets.io/v1.ExternalSecretSyncWindowKind">
+ExternalSecretSyncWindowKind
+</a>
+</em>
+</td>
+<td>
+<p>Kind applies to every window in the list.
+&ldquo;allow&rdquo; &ndash; syncs are permitted only while at least one window is active;
+all other times are blocked.
+&ldquo;deny&rdquo;  &ndash; syncs are blocked while any window is active;
+all other times are permitted.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>windows</code></br>
+<em>
+<a href="#external-secrets.io/v1.ExternalSecretSyncWindowEntry">
+[]ExternalSecretSyncWindowEntry
+</a>
+</em>
+</td>
+<td>
+<p>Windows is the list of schedule+duration pairs.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1.ExternalSecretTarget">ExternalSecretTarget
 </h3>
 <p>

+ 10 - 0
docs/snippets/full-external-secret.yaml

@@ -29,6 +29,16 @@ spec:
   # May be set to zero to fetch and create it once
   refreshInterval: "1h0m0s"
 
+  # SyncWindows optionally restricts when periodic refreshes may occur (UTC, Periodic policy only).
+  # kind: allow -- syncs are permitted only while at least one window is active.
+  # kind: deny  -- syncs are blocked while any window is active.
+  # Each window entry uses a standard 5-field cron schedule and a duration.
+  syncWindows:
+    kind: allow
+    windows:
+      - schedule: "0 9 * * 1-5"  # weekdays at 09:00 UTC
+        duration: 8h              # window open until 17:00 UTC
+
   # the target describes the secret that shall be created
   # there can only be one target per ExternalSecret
   target:

+ 1 - 0
go.mod

@@ -179,6 +179,7 @@ require (
 	github.com/external-secrets/external-secrets/providers/v1/yandex v0.0.0-00010101000000-000000000000
 	github.com/external-secrets/external-secrets/runtime v0.0.0
 	github.com/maxbrunsfeld/counterfeiter/v6 v6.12.0
+	github.com/robfig/cron/v3 v3.0.1
 	sigs.k8s.io/yaml v1.6.0
 )
 

+ 2 - 0
go.sum

@@ -934,6 +934,8 @@ github.com/r3labs/diff v0.0.0-20191120142937-b4ed99a31f5a h1:2v4Ipjxa3sh+xn6Gvtg
 github.com/r3labs/diff v0.0.0-20191120142937-b4ed99a31f5a/go.mod h1:ozniNEFS3j1qCwHKdvraMn1WJOsUxHd7lYfukEIS4cs=
 github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
 github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=

+ 73 - 2
pkg/controllers/externalsecret/externalsecret_controller.go

@@ -29,6 +29,7 @@ import (
 
 	"github.com/go-logr/logr"
 	"github.com/prometheus/client_golang/prometheus"
+	robfigcron "github.com/robfig/cron/v3"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/equality"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -1153,6 +1154,69 @@ func shouldSkipUnmanagedStore(ctx context.Context, namespace string, r *Reconcil
 	return false, nil
 }
 
+// isWithinSyncWindow reports whether 'at' falls inside the window that opened
+// at the most-recent firing of 'sched' before 'at'. robfig's Next() is strictly
+// exclusive, so we back up by (duration + 1s) to find that firing.
+func isWithinSyncWindow(sched robfigcron.Schedule, duration time.Duration, at time.Time) bool {
+	prev := sched.Next(at.Add(-duration - time.Second))
+	return !prev.IsZero() && !prev.After(at) && !at.After(prev.Add(duration))
+}
+
+// cronParser is the standard 5-field (no-seconds) parser shared across all
+// sync-window checks within this controller.
+var cronParser = robfigcron.NewParser(
+	robfigcron.Minute | robfigcron.Hour | robfigcron.Dom |
+		robfigcron.Month | robfigcron.Dow | robfigcron.Descriptor,
+)
+
+// isPeriodicRefreshAllowedByWindows returns true when the SyncWindows on 'es'
+// collectively permit a periodic refresh at time 'at'.
+//
+//   - No windows: always allow.
+//   - kind=deny: deny when any window is active; allow otherwise.
+//   - kind=allow: allow when at least one window is active; deny otherwise.
+//
+// Windows with an unparseable Schedule are silently ignored (treated as
+// inactive) so a typo does not permanently block syncs.
+func isPeriodicRefreshAllowedByWindows(es *esv1.ExternalSecret, at time.Time) bool {
+	sw := es.Spec.SyncWindows
+	if sw == nil || len(sw.Windows) == 0 {
+		return true
+	}
+	anyActive := false
+	for _, w := range sw.Windows {
+		sched, err := cronParser.Parse(w.Schedule)
+		if err != nil {
+			// A schedule that fails to parse is skipped rather than aborting the
+			// whole evaluation. The kubebuilder pattern marker rejects malformed
+			// schedules at admission, so this is a defensive log for any value
+			// that slips past validation (e.g. a parser/regex mismatch).
+			ctrl.Log.V(1).Info("ignoring unparseable sync window schedule",
+				"ExternalSecret", es.Namespace+"/"+es.Name,
+				"schedule", w.Schedule,
+				"error", err.Error())
+			continue
+		}
+		if isWithinSyncWindow(sched, w.Duration.Duration, at) {
+			anyActive = true
+			break
+		}
+	}
+	allowed := true
+	switch sw.Kind {
+	case esv1.SyncWindowDeny:
+		allowed = !anyActive
+	case esv1.SyncWindowAllow:
+		allowed = anyActive
+	}
+	if !allowed {
+		ctrl.Log.V(1).Info("periodic refresh blocked by SyncWindow",
+			"ExternalSecret", es.Namespace+"/"+es.Name,
+			"kind", sw.Kind)
+	}
+	return allowed
+}
+
 func shouldRefresh(es *esv1.ExternalSecret) bool {
 	switch es.Spec.RefreshPolicy {
 	case esv1.RefreshPolicyCreatedOnce:
@@ -1192,13 +1256,20 @@ func shouldRefreshPeriodic(es *esv1.ExternalSecret) bool {
 		return true
 	}
 
+	now := time.Now()
+
 	// if the last refresh time is in the future, we should refresh
-	if es.Status.RefreshTime.Time.After(time.Now()) {
+	if es.Status.RefreshTime.Time.After(now) {
 		return true
 	}
 
 	// if the last refresh time + refresh interval is before now, we should refresh
-	return es.Status.RefreshTime.Add(es.Spec.RefreshInterval.Duration).Before(time.Now())
+	if !es.Status.RefreshTime.Add(es.Spec.RefreshInterval.Duration).Before(now) {
+		return false
+	}
+
+	// check sync windows before triggering a refresh
+	return isPeriodicRefreshAllowedByWindows(es, now)
 }
 
 // isSecretValid checks if the secret exists, and it's data is consistent with the calculated hash.

+ 208 - 0
pkg/controllers/externalsecret/syncwindow_test.go

@@ -0,0 +1,208 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package externalsecret
+
+import (
+	"testing"
+	"time"
+
+	robfigcron "github.com/robfig/cron/v3"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+func mustParseCron(expr string) robfigcron.Schedule {
+	sched, err := cronParser.Parse(expr)
+	if err != nil {
+		panic(expr + ": " + err.Error())
+	}
+	return sched
+}
+
+// TestIsWithinSyncWindow exercises the half-open / closed window boundaries
+// using a daily schedule that fires at 22:00 UTC with a 2-hour duration
+// (window open 22:00-00:00 UTC).
+func TestIsWithinSyncWindow(t *testing.T) {
+	sched := mustParseCron("0 22 * * *")
+	dur := 2 * time.Hour
+
+	// Reference firing: 2026-06-01 22:00 UTC (Monday).
+	open := time.Date(2026, 6, 1, 22, 0, 0, 0, time.UTC)
+	closeTime := open.Add(dur) // 2026-06-02 00:00 UTC
+
+	tests := []struct {
+		name string
+		at   time.Time
+		want bool
+	}{
+		{
+			name: "at window open (inclusive)",
+			at:   open,
+			want: true,
+		},
+		{
+			name: "inside window",
+			at:   open.Add(30 * time.Minute),
+			want: true,
+		},
+		{
+			name: "at window close (inclusive)",
+			at:   closeTime,
+			want: true,
+		},
+		{
+			name: "one second past window close",
+			at:   closeTime.Add(time.Second),
+			want: false,
+		},
+		{
+			name: "one hour before window opens",
+			at:   open.Add(-1 * time.Hour),
+			want: false,
+		},
+		{
+			name: "between two consecutive occurrences",
+			// 03:00 UTC next day -- well past the 22:00+2h window.
+			at:   open.Add(5 * time.Hour),
+			want: false,
+		},
+		{
+			name: "inside the second occurrence (next day)",
+			at:   open.Add(24*time.Hour + 30*time.Minute),
+			want: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := isWithinSyncWindow(sched, dur, tt.at)
+			if got != tt.want {
+				t.Errorf("at=%v: got %v, want %v", tt.at.Format(time.RFC3339), got, tt.want)
+			}
+		})
+	}
+}
+
+// TestIsPeriodicRefreshAllowedByWindows covers the nil / empty / allow / deny /
+// invalid-schedule paths and the unknown-kind default.
+func TestIsPeriodicRefreshAllowedByWindows(t *testing.T) {
+	// inWindow: 2026-06-01 23:00 UTC -- inside the 22:00+2h window.
+	inWindow := time.Date(2026, 6, 1, 23, 0, 0, 0, time.UTC)
+	// outWindow: 2026-06-01 20:00 UTC -- outside any window.
+	outWindow := time.Date(2026, 6, 1, 20, 0, 0, 0, time.UTC)
+
+	// Shared window definitions.
+	validEntry := esv1.ExternalSecretSyncWindowEntry{
+		Schedule: "0 22 * * *",
+		Duration: metav1.Duration{Duration: 2 * time.Hour},
+	}
+	invalidEntry := esv1.ExternalSecretSyncWindowEntry{
+		Schedule: "not-a-cron",
+		Duration: metav1.Duration{Duration: 2 * time.Hour},
+	}
+
+	tests := []struct {
+		name string
+		sw   *esv1.ExternalSecretSyncWindows
+		at   time.Time
+		want bool
+	}{
+		{
+			name: "nil SyncWindows always permits",
+			sw:   nil,
+			at:   inWindow,
+			want: true,
+		},
+		{
+			name: "empty Windows list always permits",
+			sw:   &esv1.ExternalSecretSyncWindows{Kind: esv1.SyncWindowAllow},
+			at:   inWindow,
+			want: true,
+		},
+		// allow kind
+		{
+			name: "allow: at inside window -- permit",
+			sw:   &esv1.ExternalSecretSyncWindows{Kind: esv1.SyncWindowAllow, Windows: []esv1.ExternalSecretSyncWindowEntry{validEntry}},
+			at:   inWindow,
+			want: true,
+		},
+		{
+			name: "allow: at outside window -- block",
+			sw:   &esv1.ExternalSecretSyncWindows{Kind: esv1.SyncWindowAllow, Windows: []esv1.ExternalSecretSyncWindowEntry{validEntry}},
+			at:   outWindow,
+			want: false,
+		},
+		// deny kind
+		{
+			name: "deny: at inside window -- block",
+			sw:   &esv1.ExternalSecretSyncWindows{Kind: esv1.SyncWindowDeny, Windows: []esv1.ExternalSecretSyncWindowEntry{validEntry}},
+			at:   inWindow,
+			want: false,
+		},
+		{
+			name: "deny: at outside window -- permit",
+			sw:   &esv1.ExternalSecretSyncWindows{Kind: esv1.SyncWindowDeny, Windows: []esv1.ExternalSecretSyncWindowEntry{validEntry}},
+			at:   outWindow,
+			want: true,
+		},
+		// invalid schedule handling
+		{
+			name: "allow: only invalid schedule -- block (no window is active)",
+			sw:   &esv1.ExternalSecretSyncWindows{Kind: esv1.SyncWindowAllow, Windows: []esv1.ExternalSecretSyncWindowEntry{invalidEntry}},
+			at:   inWindow,
+			want: false,
+		},
+		{
+			name: "deny: only invalid schedule -- permit (no window is active)",
+			sw:   &esv1.ExternalSecretSyncWindows{Kind: esv1.SyncWindowDeny, Windows: []esv1.ExternalSecretSyncWindowEntry{invalidEntry}},
+			at:   inWindow,
+			want: true,
+		},
+		{
+			name: "allow: one invalid + one valid active window -- permit",
+			sw: &esv1.ExternalSecretSyncWindows{
+				Kind:    esv1.SyncWindowAllow,
+				Windows: []esv1.ExternalSecretSyncWindowEntry{invalidEntry, validEntry},
+			},
+			at:   inWindow,
+			want: true,
+		},
+		// unknown kind falls back to always-permit
+		{
+			name: "unknown kind -- always permit",
+			sw: &esv1.ExternalSecretSyncWindows{
+				Kind:    "unknown",
+				Windows: []esv1.ExternalSecretSyncWindowEntry{validEntry},
+			},
+			at:   inWindow,
+			want: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			es := &esv1.ExternalSecret{
+				Spec: esv1.ExternalSecretSpec{SyncWindows: tt.sw},
+			}
+			got := isPeriodicRefreshAllowedByWindows(es, tt.at)
+			if got != tt.want {
+				t.Errorf("got %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 5 - 0
tests/__snapshot__/clusterexternalsecret-v1.yaml

@@ -67,6 +67,11 @@ spec:
     secretStoreRef:
       kind: "SecretStore" # "SecretStore", "ClusterSecretStore"
       name: string
+    syncWindows:
+      kind: "allow" # "allow", "deny"
+      windows:
+      - duration: string
+        schedule: string
     target:
       creationPolicy: "Owner"
       deletionPolicy: "Retain"

+ 5 - 0
tests/__snapshot__/externalsecret-v1.yaml

@@ -62,6 +62,11 @@ spec:
   secretStoreRef:
     kind: "SecretStore" # "SecretStore", "ClusterSecretStore"
     name: string
+  syncWindows:
+    kind: "allow" # "allow", "deny"
+    windows:
+    - duration: string
+      schedule: string
   target:
     creationPolicy: "Owner"
     deletionPolicy: "Retain"