Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5924932
Add Job primitive: builder, resource, handlers, flavors, mutator tests
sourcehawk Mar 22, 2026
0cf92c9
Add JobSpecEditor for typed Job spec mutations
sourcehawk Mar 22, 2026
a17d2b9
Add Job mutator with plan-and-apply pattern
sourcehawk Mar 22, 2026
f5b042b
Add job-primitive example with mutations, flavors, and status handlers
sourcehawk Mar 22, 2026
d59c276
Add Job primitive documentation
sourcehawk Mar 22, 2026
2d257fd
Fix staticcheck lint: use tagged switch in status handler
sourcehawk Mar 22, 2026
0fdd811
Address Copilot review comments on Job primitive
sourcehawk Mar 22, 2026
1534d48
Address Copilot review: failure reason fallback, doc comments, resour…
sourcehawk Mar 22, 2026
a46382a
Preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
1522f24
Add cross-mutation selector snapshot test for Job primitive
sourcehawk Mar 23, 2026
4733a38
Fix job mutator constructor to not call beginFeature
sourcehawk Mar 23, 2026
8f166b9
Export BeginFeature and preserve status in DefaultFieldApplicator
sourcehawk Mar 23, 2026
5f71355
Merge remote-tracking branch 'origin/main' into feature/job-primitive
sourcehawk Mar 24, 2026
12070ac
Format markdown files with prettier
sourcehawk Mar 24, 2026
15df44a
Address PR review: check Build() errors in tests and update Mutate() doc
sourcehawk Mar 24, 2026
27a4dec
Address Copilot review: add job to primitives index and fix API name …
sourcehawk Mar 24, 2026
46f8082
Merge remote-tracking branch 'origin/main' into feature/job-primitive
sourcehawk Mar 24, 2026
ebb22c4
Align job mutator construction with deployment/configmap primitives
sourcehawk Mar 24, 2026
9918500
Merge remote-tracking branch 'origin/main' into feature/job-primitive
sourcehawk Mar 25, 2026
7f0e9bf
Remove field applicators and flavors from job primitive for SSA migra…
sourcehawk Mar 25, 2026
5736a4b
Remove flavors.go and update example references for SSA migration
sourcehawk Mar 25, 2026
639d12a
Fix ConvergingStatus doc comment to use actual CompletionStatus const…
sourcehawk Mar 25, 2026
cb763d3
Merge origin/main into feature/job-primitive
sourcehawk Mar 25, 2026
6059324
Merge branch 'main' into feature/job-primitive
sourcehawk Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ have been applied. This means a single mutation can safely add a container and t
| `pkg/primitives/statefulset` | Workload | [statefulset.md](primitives/statefulset.md) |
| `pkg/primitives/replicaset` | Workload | [replicaset.md](primitives/replicaset.md) |
| `pkg/primitives/daemonset` | Workload | [daemonset.md](primitives/daemonset.md) |
| `pkg/primitives/job` | Task | [job.md](primitives/job.md) |
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

PR description checklist says this PR “does not modify shared files”, but this PR updates shared docs (this file) and also changes shared editor tests (pkg/mutation/editors/jobspec_test.go). Please either update the checklist/description to reflect these shared changes, or revert them if they were unintended.

Copilot uses AI. Check for mistakes.
| `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) |
| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) |
| `pkg/primitives/pdb` | Static | [pdb.md](primitives/pdb.md) |
Expand Down
256 changes: 256 additions & 0 deletions docs/primitives/job.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Job Primitive

The `job` primitive is the framework's built-in task abstraction for managing Kubernetes `Job` resources. It integrates
fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata —
following the same pod-template mutation pattern as the Deployment primitive.

## Capabilities

| Capability | Detail |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| **Completion tracking** | Monitors Job conditions and reports `Completed`, `TaskRunning`, `TaskPending`, or `TaskFailing` |
| **Suspension** | Sets `spec.suspend=true` or deletes the Job (default); reports `Suspending` / `Suspended` |
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The capabilities table says suspension “sets spec.suspend=true or deletes the Job (default)”, but in the framework suspension flow Suspend() is still called even when DeleteOnSuspend() is true, and deletion only happens after the resource reaches Suspended. Consider rewording this to avoid implying these are mutually exclusive (e.g., suspend/stop creation, then delete by default once suspended).

Suggested change
| **Suspension** | Sets `spec.suspend=true` or deletes the Job (default); reports `Suspending` / `Suspended` |
| **Suspension** | Suspends/stops the Job (for example via `spec.suspend=true`) and, by default, deletes it once `Suspended`; reports `Suspending` / `Suspended` |

Copilot uses AI. Check for mistakes.
| **Mutation pipeline** | Typed editors for metadata, job spec, pod spec, and containers |

## Building a Job Primitive

```go
import "github.com/sourcehawk/operator-component-framework/pkg/primitives/job"

base := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "db-migration",
Namespace: owner.Namespace,
},
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyOnFailure,
Containers: []corev1.Container{
{Name: "migrate", Image: "my-app-migration:latest"},
},
},
},
},
}

resource, err := job.NewBuilder(base).
WithMutation(MyFeatureMutation(owner.Spec.Version)).
Build()
```

## Mutations

Mutations are the primary mechanism for modifying a `Job` beyond its baseline. Each mutation is a named function that
receives a `*Mutator` and records edit intent through typed editors.

The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
with no version constraints and no `When()` conditions is also always enabled:

```go
func MyFeatureMutation(version string) job.Mutation {
return job.Mutation{
Name: "my-feature",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *job.Mutator) error {
// record edits here
return nil
},
}
}
```

Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
another, register the dependency first.

### Boolean-gated mutations

Use `When(bool)` to gate a mutation on a runtime condition:

```go
func TracingMutation(version string, enabled bool) job.Mutation {
return job.Mutation{
Name: "tracing",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *job.Mutator) error {
m.EnsureContainerEnvVar(corev1.EnvVar{
Name: "OTEL_EXPORTER_OTLP_ENDPOINT",
Value: "http://otel-collector:4317",
})
return nil
},
}
}
```

### Version-gated mutations

Pass a `[]feature.VersionConstraint` to gate on a semver range:

```go
var legacyConstraint = mustSemverConstraint("< 2.0.0")

func LegacyMigrationMutation(version string) job.Mutation {
return job.Mutation{
Name: "legacy-migration-format",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{legacyConstraint},
),
Mutate: func(m *job.Mutator) error {
m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "MIGRATION_FORMAT", Value: "v1"})
return nil
})
return nil
},
}
}
```

All version constraints and `When()` conditions must be satisfied for a mutation to apply.

## Internal Mutation Ordering

Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
order they are recorded. This ensures structural consistency across mutations.

| Step | Category | What it affects |
| ---- | --------------------------- | ----------------------------------------------------------------------- |
| 1 | Job metadata edits | Labels and annotations on the `Job` object |
| 2 | JobSpec edits | Completions, parallelism, backoff limit, deadline, etc. |
| 3 | Pod template metadata edits | Labels and annotations on the pod template |
| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context |
| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` |
| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) |
| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` |
| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) |

Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
This means a single mutation can add a container and then configure it without selector resolution issues.

## Editors

### JobSpecEditor

Controls job-level settings via `m.EditJobSpec`.

Available methods: `SetCompletions`, `SetParallelism`, `SetBackoffLimit`, `SetActiveDeadlineSeconds`,
`SetTTLSecondsAfterFinished`, `SetCompletionMode`, `Raw`.

```go
m.EditJobSpec(func(e *editors.JobSpecEditor) error {
e.SetBackoffLimit(3)
e.SetActiveDeadlineSeconds(600)
return nil
})
```

For fields not covered by the typed API, use `Raw()`:

```go
m.EditJobSpec(func(e *editors.JobSpecEditor) error {
e.Raw().Suspend = ptr.To(true)
return nil
})
```

### PodSpecEditor

Manages pod-level configuration via `m.EditPodSpec`.

Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`,
`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`,
`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`.

```go
m.EditPodSpec(func(e *editors.PodSpecEditor) error {
e.SetServiceAccountName("migration-sa")
e.EnsureVolume(corev1.Volume{
Name: "config",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: "migration-config"},
},
},
})
return nil
})
```

### ContainerEditor

Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
[selector](../primitives.md#container-selectors).

Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.

```go
m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "postgres:5432"})
e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m"))
return nil
})
```

### ObjectMetaEditor

Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Job` object itself, or
`m.EditPodTemplateMetadata` to target the pod template.

Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.

```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
return nil
})
```

## Convenience Methods

The `Mutator` exposes convenience wrappers that target all containers at once:

| Method | Equivalent to |
| ----------------------------- | ------------------------------------------------------------- |
| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` |

## Suspension

Jobs use the Task lifecycle for suspension, which differs from Workloads:

- **Default behavior**: `DefaultDeleteOnSuspendHandler` returns `true`, meaning the Job is deleted from the cluster
during suspension.
- **Suspend mutation**: `DefaultSuspendMutationHandler` sets `spec.suspend=true`, which prevents the Job controller from
creating new pods while allowing existing pods to complete.
- **Suspension status**: `DefaultSuspensionStatusHandler` checks if `spec.suspend=true` and `status.active=0`.

Override any of these via the Builder:

```go
resource, err := job.NewBuilder(base).
WithCustomSuspendDeletionDecision(func(j *batchv1.Job) bool {
return false // Keep the Job in the cluster when suspended
}).
Build()
```

## Guidance

**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for
boolean conditions.

**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
The internal ordering within each mutation handles intra-mutation dependencies automatically.

**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
the same mutation resolve correctly and reconciliation remains idempotent.

**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if init containers or sidecar containers are present.

**Jobs are deleted on suspend by default.** Unlike Deployments which scale to zero, Jobs are deleted during suspension.
Override `WithCustomSuspendDeletionDecision` if you need to keep the Job resource in the cluster.
36 changes: 36 additions & 0 deletions examples/job-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Job Primitive Example

This example demonstrates the usage of the `job` primitive within the operator component framework. It shows how to
manage a Kubernetes Job as a component of a larger application, utilizing features like:

- **Base Construction**: Initializing a Job with basic metadata, spec, and restart policy.
- **Feature Mutations**: Applying version-gated or conditional changes (env vars, image version, retry policies) using
the `Mutator`.
- **Custom Status Handlers**: Overriding the default `ConvergingStatus` interface using the `WithCustomConvergeStatus`
builder option.
- **Suspension**: Demonstrating how Jobs are suspended (deleted by default) when the component is suspended.
- **Data Extraction**: Harvesting information from the reconciled resource.

## Directory Structure

- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework.
- `features/`: Contains modular feature definitions:
- `mutations.go`: tracing env vars, retry policies, and version-based image updates.
- `status.go`: implementation of a custom handler for completion status.
- `resources/`: Contains the central `NewJobResource` factory that assembles all features using the `job.Builder`.
- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client.

## Running the Example

You can run this example directly using `go run`:

```bash
go run examples/job-primitive/main.go
```

This will:

1. Initialize a fake Kubernetes client.
2. Create an `ExampleApp` owner object.
3. Reconcile the `ExampleApp` components through multiple spec changes.
4. Print the resulting status conditions after each reconciliation step.
54 changes: 54 additions & 0 deletions examples/job-primitive/app/controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Package app provides a sample controller using the job primitive.
package app

import (
"context"

"github.com/sourcehawk/operator-component-framework/pkg/component"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// ExampleController reconciles an ExampleApp object using the component framework.
type ExampleController struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
Metrics component.Recorder

// NewJobResource is a factory function to create the job resource.
// This allows us to inject the resource construction logic.
NewJobResource func(*ExampleApp) (component.Resource, error)
}

// Reconcile performs the reconciliation for a single ExampleApp.
func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error {
// 1. Build the job resource for this owner.
jobResource, err := r.NewJobResource(owner)
if err != nil {
return err
}

// 2. Build the component that manages the job.
comp, err := component.NewComponentBuilder().
WithName("example-migration").
WithConditionType("MigrationReady").
WithResource(jobResource, component.ResourceOptions{}).
Suspend(owner.Spec.Suspended).
Build()
if err != nil {
return err
}

// 3. Execute the component reconciliation.
resCtx := component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
}

return comp.Reconcile(ctx, resCtx)
}
20 changes: 20 additions & 0 deletions examples/job-primitive/app/owner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app

import (
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
)

// ExampleApp re-exports the shared CRD type so callers in this package need no import alias.
type ExampleApp = sharedapp.ExampleApp

// ExampleAppSpec re-exports the shared spec type.
type ExampleAppSpec = sharedapp.ExampleAppSpec

// ExampleAppStatus re-exports the shared status type.
type ExampleAppStatus = sharedapp.ExampleAppStatus

// ExampleAppList re-exports the shared list type.
type ExampleAppList = sharedapp.ExampleAppList

// AddToScheme registers the ExampleApp types with the given scheme.
var AddToScheme = sharedapp.AddToScheme
Loading
Loading