Skip to content

feat: add cronWorkflowDefaults to controller config. Fixes #12627#16277

Open
alien2003 wants to merge 2 commits into
argoproj:mainfrom
alien2003:feat/cron-workflow-defaults
Open

feat: add cronWorkflowDefaults to controller config. Fixes #12627#16277
alien2003 wants to merge 2 commits into
argoproj:mainfrom
alien2003:feat/cron-workflow-defaults

Conversation

@alien2003

Copy link
Copy Markdown

Fixes #12627

Motivation

workflowDefaults lets an operator set controller-wide defaults for Workflows, but there is no equivalent for CronWorkflows. Fields like successfulJobsHistoryLimit, failedJobsHistoryLimit, concurrencyPolicy and timezone have to be repeated on every CronWorkflow, or enforced with an external policy engine. This adds a cronWorkflowDefaults to set them once.

Modifications

Add a cronWorkflowDefaults key to the workflow-controller-configmap (config.Config.CronWorkflowDefaults *wfv1.CronWorkflowSpec).

The defaults are merged into each CronWorkflow during reconciliation by a new util.MergeToCronWorkflowSpec, which mirrors util.MergeTo: a strategic merge that lets the CronWorkflow's own values win over the defaults. The merge runs on the controller's in-memory copy in newCronWfOperationCtx, before validation, scheduling and runtime-policy checks, so every read sees the defaulted spec.

The defaults are not written back to the resource. The cron reconcile loop only persists status and metadata, never spec, so this keeps defaults out of the stored object and avoids fighting GitOps controllers that own the CronWorkflow manifest (the concern @anarsen raised on the issue). The shared defaults are deep-copied per reconcile because the merge temporarily mutates its patch argument and workers run concurrently.

Verification

go test ./config/... ./workflow/util/... ./workflow/cron/...

  • TestMergeToCronWorkflowSpec: default fills unset field, target overrides default, nil patch is a no-op, the shared patch is not mutated.
  • TestApplyCronWorkflowDefaults: defaults fill unset fields on the in-memory CronWorkflow, an explicit CronWorkflow value is preserved, and the CronWorkflow's own schedules survive the merge.

Documentation

docs/default-workflow-specs.md gets a "Setting Default CronWorkflow Values" section; the example docs/workflow-controller-configmap.yaml gets the new key; docs/workflow-controller-configmap.md is regenerated. Feature file added under .features/pending/.

AI

AI-assisted (Claude Code). I scoped the design (in-memory application vs writing back to the resource, and the config type), reviewed and tested the implementation, and ran the builds, tests and linter in a pinned container. The decision to apply defaults in-memory rather than mutate the resource follows the GitOps concern raised by @anarsen on the issue.

…2627

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: alien2003 <alien2003@protonmail.ch>
@alien2003
alien2003 force-pushed the feat/cron-workflow-defaults branch from bc7dd6b to 8e50693 Compare June 16, 2026 20:29
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: alien2003 <alien2003@protonmail.ch>
@alien2003
alien2003 marked this pull request as ready for review June 16, 2026 22:00
@alien2003
alien2003 requested review from a team as code owners June 16, 2026 22:00
@Joibel

Joibel commented Jun 30, 2026

Copy link
Copy Markdown
Member

I'm not sure we should include workflowSpec in the merged result, I think it should be banned from the cronWorkflowDefaults. What do you think @isubasinghe?

My reasoning is that we've then got two ways to set workflow defaults for crons, and that's confusing and error prone. I don't want to support fixing that when it's surprising.

@Joibel Joibel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Code review

Re-posting as inline comments. Nice, well-scoped feature mirroring workflowDefaults. I verified several things are deliberately correct and not issues: the strategic-merge direction (CronWorkflow wins), the DeepCopy() for concurrency safety, no informer-cache corruption (both call sites build fresh objects from unstructured), the LabelsFrom deref is safely guarded just like MergeTo, and new(int64(60)) in tests is valid Go 1.26. No CLAUDE.md compliance issues.

14 findings inline below (confidence in parentheses; ~70+ are worth acting on, the rest are nits).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Comment thread workflow/cron/operator.go
wfClientset versioned.Interface
wfClient typed.WorkflowInterface
wfDefaults *v1alpha1.Workflow
cronWfDefaults *v1alpha1.CronWorkflowSpec

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Dead struct field (confidence 80). cronWfDefaults is stored here and assigned in newCronWfOperationCtx (line 87), but never read again — defaults are applied eagerly in place by applyCronWorkflowDefaults. Contrast wfDefaults, which is read later (at submit/validate). The constructor only needs the parameter; this field can be dropped.

Comment thread workflow/util/merge.go
// If the target defines a field, that value takes precedence over the patch. This mirrors MergeTo,
// including the manual handling of the embedded WorkflowSpec's hooks and labelsFrom, which strategic
// merge does not handle.
func MergeToCronWorkflowSpec(patch, target *wfv1.CronWorkflowSpec) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A non-zero default can't be overridden back to its zero value (confidence 75). The CronWorkflow is applied as the strategic-merge patch over the defaults, so omitempty value-typed fields can't be reset. Sharpest case: CronWorkflowSpec.Suspend is a plain bool, so cronWorkflowDefaults: {suspend: true} would force-suspend every CronWorkflow with no opt-out. The Workflow path explicitly compensates in JoinWorkflowSpec (and uses *bool); there's no equivalent here. Also affects when/timezone.

Comment thread workflow/cron/operator.go
Comment on lines +70 to +72
if err := util.MergeToCronWorkflowSpec(cronWfDefaults.DeepCopy(), &cronWf.Spec); err != nil {
logging.RequireLoggerFromContext(ctx).WithError(err).Error(ctx, "Failed to apply CronWorkflow defaults")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Merge error is swallowed, and the merge can wipe the spec (confidence 75). On error this only logs and continues. Worse, MergeToCronWorkflowSpec does *target = wfv1.CronWorkflowSpec{} before its final unmarshal (merge.go:231), so an unmarshal failure leaves cronWf.Spec zeroed and reconciliation proceeds on an empty spec. The analogous setWorkflowDefaults/MergeTo returns the error to the caller instead. Rare in practice, but weaker than the pattern it copies.

If a `CronWorkflow` sets a value that also has a default, the `CronWorkflow`'s value takes precedence.
See the [Field Reference](./fields.md#cronworkflowspec) for the full list of `CronWorkflowSpec` fields.

Defaults are applied to the controller's in-memory copy during reconciliation and are never written back to the `CronWorkflow`, so they do not conflict with GitOps tools that manage the resource.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This "never written back" claim isn't strictly true for timezone (confidence 72). A defaulted Spec.Timezone flows into SetSchedule(...GetScheduleWithTimezoneString()) and the resulting annotationKeyLatestSchedule annotation, which persistUpdate patches into metadata.annotations. So a defaulted value does reach the stored object (a controller-managed annotation, not the spec). Worth qualifying the claim.

defer runtimeutil.HandleCrashWithContext(ctx, runtimeutil.PanicHandlers...)

cronController := cron.NewCronController(ctx, wfc.wfclientset, wfc.dynamicInterface, wfc.namespace, wfc.GetManagedNamespace(), wfc.Config.InstanceID, wfc.metrics, wfc.eventRecorderManager, cronWorkflowWorkers, wfc.wftmplInformer, wfc.cwftmplInformer, wfc.Config.WorkflowDefaults)
cronController := cron.NewCronController(ctx, wfc.wfclientset, wfc.dynamicInterface, wfc.namespace, wfc.GetManagedNamespace(), wfc.Config.InstanceID, wfc.metrics, wfc.eventRecorderManager, cronWorkflowWorkers, wfc.wftmplInformer, wfc.cwftmplInformer, wfc.Config.WorkflowDefaults, wfc.Config.CronWorkflowDefaults)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Defaults are controller-only; not applied on the API-server / argo cron lint path (confidence 72). workflowDefaults is also wired into validate/submit on the server, but cronWorkflowDefaults is reconcile-only — so a CronWorkflow that only validates because of a default passes at the controller yet can't be linted with defaults via the API. Likely deliberate (GitOps-drift avoidance), but an undocumented behavioral asymmetry worth calling out.

Comment on lines +819 to +826
func TestApplyCronWorkflowDefaults(t *testing.T) {
ctx := logging.TestContext(t.Context())

newCronWf := func() *v1alpha1.CronWorkflow {
cronWf := &v1alpha1.CronWorkflow{}
v1alpha1.MustUnmarshal([]byte(scheduledWf), cronWf)
return cronWf
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The DeepCopy safety property is untested at this layer (confidence 60). applyCronWorkflowDefaults deep-copies precisely because the shared cronWfDefaults is mutated transiently during the merge. No case here asserts that the shared defaults object is left unmutated (especially when it contains Hooks/LabelsFrom). A future refactor dropping the DeepCopy() wouldn't be caught.

Comment on lines +70 to +82
For example, to keep at most four successful and four failed child Workflows for every `CronWorkflow`:

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: workflow-controller-configmap
data:
cronWorkflowDefaults: |
concurrencyPolicy: "Replace"
successfulJobsHistoryLimit: 4
failedJobsHistoryLimit: 4
timezone: "America/New_York"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The example sets more than the prose explains (confidence 70). The lead-in says only "to keep at most four successful and four failed child Workflows", but the YAML also sets concurrencyPolicy: "Replace" and timezone: "America/New_York". Either trim the example to match, or broaden the lead-in.

Comment thread config/config.go
WorkflowDefaults *wfv1.Workflow `json:"workflowDefaults,omitempty"`

// CronWorkflowDefaults are values that will apply to all CronWorkflows from this controller, unless overridden on the CronWorkflow-level
CronWorkflowDefaults *wfv1.CronWorkflowSpec `json:"cronWorkflowDefaults,omitempty"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Undocumented two-layer defaulting (confidence 55). Because the type is CronWorkflowSpec (which embeds WorkflowSpec), cronWorkflowDefaults can also set arbitrary workflow-spec fields (entrypoint, templates, etc.), which then re-merge with workflowDefaults on the spawned Workflow. The docs only advertise cron-level fields and don't explain this overlap or its precedence.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I've made a separate comment - but my feeling is we must disallow this.

Comment thread workflow/util/merge.go
Comment on lines +203 to +216
// Temporarily remove hooks and labelsFrom as they don't merge
patchHooks := patch.WorkflowSpec.Hooks
patch.WorkflowSpec.Hooks = nil
var patchLabelsFrom map[string]wfv1.LabelValueFrom
if patch.WorkflowSpec.WorkflowMetadata != nil {
patchLabelsFrom = patch.WorkflowSpec.WorkflowMetadata.LabelsFrom
patch.WorkflowSpec.WorkflowMetadata.LabelsFrom = nil
}

patchBytes, err := json.Marshal(patch)
patch.WorkflowSpec.Hooks = patchHooks
if len(patchLabelsFrom) != 0 {
patch.WorkflowSpec.WorkflowMetadata.LabelsFrom = patchLabelsFrom
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

DRY: this is a near-verbatim copy of MergeTo (confidence 50). The subtle hooks/labelsFrom "remove-before-marshal, restore-after, re-add-to-target" dance is duplicated; a fix to one copy won't propagate to the other, and nothing enforces parity. Consider a single generic helper parameterized over the embedded WorkflowSpec. Maintainability only.

> v4.1 and after

Default `CronWorkflow` spec values can be specified under the `cronWorkflowDefaults` key in the `workflow-controller-configmap`.
These apply to all `CronWorkflows` executed from the controller, such as `concurrencyPolicy`, `startingDeadlineSeconds`, `successfulJobsHistoryLimit`, `failedJobsHistoryLimit` and `timezone`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Dangling "such as" clause (confidence 50). "These apply to all CronWorkflows executed from the controller, such as concurrencyPolicy, ..." reads as if the CronWorkflows are "such as concurrencyPolicy". It should attach to the default fields, e.g. "Default values can be set for fields such as ...".

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.

Add cronWorkflowDefaults

2 participants