Skip to content

feat: add optional durable workload restart windows - #1214

Open
echupriyanov wants to merge 6 commits into
stakater:masterfrom
echupriyanov:codex/restart-windows
Open

echupriyanov wants to merge 6 commits into
stakater:masterfrom
echupriyanov:codex/restart-windows

Conversation

@echupriyanov

@echupriyanov echupriyanov commented Sep 9, 2026 •

Copy link
Copy Markdown

Problem

Secret and ConfigMap updates can trigger workload rollouts during busy periods. For example, a renewed application-tls Secret may need to be picked up by sample-app during a nightly maintenance window, without losing the request if the controller restarts before that window.

Change

Add optional per-workload restart windows, disabled by default through --enable-restart-windows / Helm reloader.enableRestartWindows.

  • Use reloader.stakater.com/restart-window on workload metadata with an explicit timezone, five-field cron expressions, and durations.
  • Persist pending source references and hashes on the workload; recover them through workload informers and a delayed queue sharing the existing leader lifecycle.
  • Evaluate closed windows from informer state and schedule directly for the next opening. Re-read the workload and sources before mutation, revalidate policy and opt-in settings, and use UID/resourceVersion preconditions.
  • Coalesce pending sources and atomically update the pod-template hash, normal reload-strategy marker, applied hashes, and pending state. This avoids an additional rollout when the next source event arrives.
  • Isolate invalid or unsupported window policy to the affected workload. Other workloads and workload kinds continue processing, with deduplicated warning Events and rate-limited retries where repair is possible.
  • Record the existing reload/action success and failure metrics and send the configured reload alert when a deferred rollout executes.
  • Add Helm flag/schema/RBAC wiring, tests, and documentation including GitOps ownership paths, first-time enrollment behavior, and rollback behavior.

Initial support covers Deployments, unpartitioned RollingUpdate StatefulSets, and RollingUpdate DaemonSets. Windows govern rollout start, not completion or application health. Jobs, CronJobs, DeploymentConfigs, Argo Rollouts, CSI sources, and directly managed pods are outside this initial scope. No CRD is introduced.

Review follow-up

Commit cf7cc5f addresses the requested correctness and operability changes:

  • one malformed workload no longer aborts the rest of the event or later workload kinds;
  • closed-window scheduling uses informer state without minute-by-minute workload API reads;
  • execution updates both restart-window state and the configured normal reload marker in one patch;
  • deferred executions now use the existing metrics and alert paths;
  • the exported v1 CommandLineOptions compatibility snapshot is refreshed after Cobra parsing and no longer shadows itself;
  • integration-level handler tests cover two workloads and pending-state behavior when the feature is disabled;
  • company-specific source headers were removed. The contribution was authored for this repository and is donated under its existing Apache-2.0 license.

Validation

  • Focused race tests pass for restart-window policy/state, controller, handler, and common options.
  • Broader unit package tests, full golangci-lint, static binary build, git diff --check, and Helm lint pass.
  • The upstream leadership suite passes against a real Kubernetes API (go test -race -count=1 -timeout 180s ./internal/pkg/leadership).
  • Live tests in an isolated Kubernetes namespace with two controller replicas passed: two Secret changes deferred outside the window; active-leader deletion and takeover with pending state retained; exactly one combined rollout at the configured UTC boundary; healthy replacement pod; controller restart without a duplicate rollout; invalid policy, paused Deployment, and missing Secret held until repaired.
  • Kubernetes Events and ReplicaSet creation timestamps confirmed execution at the window boundary. Test resources were removed afterward, and examples use only fictional workloads and source data.
  • Argo CD end-to-end reconciliation and live StatefulSet/DaemonSet scenarios have not been exercised.

The maintainers have indicated that v1 is being sunset and new features should target v2. This PR remains the complete v1 implementation for users who still need that line; a separate v2-native implementation is the appropriate upstream path rather than retargeting this branch across the rewritten architecture.

@echupriyanov
echupriyanov marked this pull request as ready for review September 9, 2026 10:53
}
if !restartwindow.Supported(upgradeFuncs.ResourceType) {
return true, fmt.Errorf("restart windows do not support %s", upgradeFuncs.ResourceType)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When this gate returns an error, Reloader stops processing the whole event, not just this workload. The loop over the remaining workloads ends, the later workload types are never reached, and after 5 retries the event is dropped for good. So every other workload that uses the same ConfigMap or Secret silently misses its reload.

I tested this on a cluster with one ConfigMap shared by two Deployments and a StatefulSet:

  • Feature flag off, one Deployment annotated → nothing reloaded.
  • Feature flag on, annotation on a CronJob → Deployments reloaded, the StatefulSet never did, because CronJobs are processed before StatefulSets.
  • Flag off with only a leftover pending annotation and no policy → nothing reloaded.

The third case makes rollback awkward. Once a workload holds pending state, turning the flag off stops these reloads until someone turns it back on or removes the annotation. Nothing in the logs points at the cause.

The docs describe this as "fails closed", but it closes for other workloads too. Could this skip the workload instead: return no error, log the reason, and raise a warning event on that object?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cf7cc5f. Static window configuration errors now skip only the affected workload, emit a Warning Event and skipped metric, and allow the remaining workloads and later kinds to continue. Pending-only state is not treated as policy, so disabling the feature restores normal reload behavior after policy removal. TestPerformActionContinuesAfterInvalidWindowWorkload covers two workloads through PerformAction.

}
queue.Forget(target)
// Revisit closed windows at least once a minute for clock changes and policy edits.
if delay > time.Minute {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Each pending workload is re-checked every minute with a live API call, even when the window is hours away, and a blocked workload repeats its warning event each time. The controller already runs workload informers, so the cache could answer the "is the window open" check. The read just before the patch should stay live, because it carries the resourceVersion precondition

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cf7cc5f. The scheduler now evaluates pending state and the window from the workload informer cache, then uses AddAfter for the exact next opening. It performs the live workload/source reads and policy revalidation only when execution is due. Repairable failures use the rate-limited queue, and repeated blocked Warning Events are deduplicated per target/reason.

Comment thread internal/pkg/restartwindow/policy.go Outdated
@@ -0,0 +1,86 @@
// Copyright 2026 Doska. Licensed under the Apache License, Version 2.0.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This file, state.go and internal/pkg/controller/restart_window.go all carry

// Copyright 2026 Doska.. 

Could you confirm where this code came from?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This code was authored by the Doska team, the company behind birbir.uz, specifically for this contribution. It was not copied from another source and is donated to the Reloader project under the repository's Apache-2.0 license. I removed the company-specific source headers in cf7cc5f to follow the repository convention.

return time.Minute, err
}
if changed && recorder != nil {
recorder.Event(obj, corev1.EventTypeNormal, "WindowReloaded", "Applied pending configuration changes within restart window")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When the window opens and the rollout happens, only a Kubernetes event is written. reloader_reload_executed_total and the ALERT_ON_RELOAD webhook never fire. Anyone who watches a dashboard will see reloads drop to zero once they enable windows. Worth recording the same metrics and alerts as the normal path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cf7cc5f. A deferred execution now records the existing reload and action success/failure metrics, and sends the configured ALERT_ON_RELOAD notification after a successful rollout. Controller recovery tests assert both the success metric and exactly one alert.

Comment thread pkg/common/common.go Outdated
CommandLineOptions = &ReloaderOptions{}
}
// Return an independent snapshot: event workers and the window scheduler run concurrently.
CommandLineOptions := &ReloaderOptions{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This local variable has the same name as the exported CommandLineOptions, so it hides it. That global is now set once at startup, before the flags are parsed, and keeps default values from then on. Nothing in the repo reads it today, but it is in a public package, so anything outside could quietly get the wrong values. Better to remove it or keep it updated.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cf7cc5f while retaining the exported symbol for v1 compatibility. GetCommandLineOptions now returns an independent snapshot without shadowing the global, and startup calls RefreshCommandLineOptions after Cobra parsing and before workers start. A test verifies that parsed values are published.

token := ""
if changed {
sum := sha256.Sum256([]byte(encode(applied)))
token = hex.EncodeToString(sum[:])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The window path writes restart-hash but leaves the STAKATER_* env var at its old value, so the two paths disagree about the current hash. When a workload later leaves the window path, that stale value causes one restart that no config change asked for.

I measured this with a control. Two identical Deployments shared a ConfigMap. One went through a window rollout, then I removed its annotation. On the next Reloader restart with --reload-on-create --sync-after-restart, and with the ConfigMap untouched, the previously windowed Deployment restarted and the control did not.

The docs recommend those two flags for first-time enrollment, so operators are likely to run with them. Could the window path also refresh the env var when it writes its hash?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cf7cc5f. Window execution now selects the consuming container from the fresh workload and updates the configured normal strategy marker in the same strategic-merge patch as the restart token and pending state. The env-var strategy refreshes STAKATER_<NAME>_<TYPE>; the annotation strategy refreshes last-reloaded-from. Replay tests verify that the next identical event does not cause another rollout.

name string
window, enabled, wantError bool
}{
{"unannotated-default", false, false, false}, {"window-enabled", true, true, false}, {"window-disabled-blocks", true, false, true},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The window-disabled-blocks case expects an error, so it encodes the design above. The test also calls upgradeResource directly, so the effect on other workloads stays invisible. A test that goes through PerformAction with two workloads would catch it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in cf7cc5f. The handler suite now exercises PerformAction with an invalid window workload followed by a valid workload and verifies processing continues. It also covers pending state without policy after windows are disabled. State/controller tests cover marker synchronization, replay safety, metrics, alerting, and startup recovery.

@Felix-Stakater

Copy link
Copy Markdown
Contributor

Hi! Thank you for your contribution, it's a welcome change.

We are in the process of sun-setting the v1 of the Reloader in favor of the version 2 branch currently available in the repository.

This change is to modernize the codebase and it closes a lot of long-running bugs and issues.
We will make an official statement in the near future.

To reduce further scope-creep of the release of the v2, we have chosen to not accept any new features to the current v1 branch. You are welcome to point the PR towards the v2 branch, though keep in mind the feature will most likely need to be rewritten slightly to fit the updated codebase.

Apologies for any inconvenience.

@echupriyanov

echupriyanov commented Sep 22, 2026 •

Copy link
Copy Markdown
Author

Thank you for clarifying the v1 sunset and for pointing us to v2. I have completed the requested review fixes on this branch so the v1 implementation is internally consistent and remains useful to existing v1 users. We will hold off on opening or developing a v2 PR until this current PR has a clear resolution and we have incorporated any further maintainer feedback. After that, we intend to submit the feature as a separate v2-native contribution rather than retargeting this branch across the rewritten architecture, reusing the annotation contract and behavioral tests while integrating with v2's controller-runtime cache, workload abstraction, metrics, Events, and alerter.

@echupriyanov

Copy link
Copy Markdown
Author

Added replay hardening in da4e7c3 based on production validation of the restart-window state machine:

  • an event whose source hash is already recorded as applied is discarded at ingestion;
  • matching stale pending state is removed immediately;
  • reconciliation no longer backfills an environment marker for an already-applied hash, so a controller restart or startup replay cannot cause a rollout in the next window.

Regression tests cover both request-time suppression and reconciliation of stale pending state. go test -race ./internal/pkg/restartwindow, the controller/handler/common test suites, and a static Linux amd64 build pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants