Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 8 additions & 3 deletions api/v1alpha3/gitprovider_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ type CommitMessageSpec struct {
// LiveTemplate formats every live window, including one resource and a 0s window.
// Fields: Author (raw username, possibly empty), GitTarget, Count, Operations, Resources.
// Count is retained input entries after coalescing, before comparison with Git.
// Resources exposes Operation, Group, Version, Resource, Namespace, Name, and APIVersion.
// Resources exposes Operation, Group, Version, Resource, Namespace, Name, APIVersion,
// ResourceVersion — the version of the state this commit wrote, which lags the object's
// current one and is empty for a producer that observed none — and Generation, the desired
// state's counter, which is 0 for a kind without a spec. Guard both with {{with}}.
// A literal CommitRequest message overrides this template without changing authorship.
// +optional
LiveTemplate string `json:"liveTemplate,omitempty"`
Expand Down Expand Up @@ -207,9 +210,11 @@ type CommitMessageSpec struct {
RequestTemplate string `json:"requestTemplate,omitempty"`

// ReconcileTemplate formats atomic snapshots and resyncs.
// Fields: Count, GitTarget, Group, Version, Resource, APIVersion, Namespace, Revision.
// Type and Namespace fields are empty for whole-target snapshots. Revision can be empty.
// Fields: Count, GitTarget, Group, Version, Resource, APIVersion, Namespace, ResourceVersion.
// ResourceVersion is the snapshot LIST's version, not any one object's. Type and Namespace
// fields are empty for whole-target snapshots, and ResourceVersion can be empty.
// Guard optional fields so the message remains meaningful for every snapshot scope.
// Revision was renamed to ResourceVersion in v0.48.0; a template still naming it is rejected.
// +optional
ReconcileTemplate string `json:"reconcileTemplate,omitempty"`

Expand Down
11 changes: 8 additions & 3 deletions config/crd/bases/configbutler.ai_gittargets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,20 @@ spec:
LiveTemplate formats every live window, including one resource and a 0s window.
Fields: Author (raw username, possibly empty), GitTarget, Count, Operations, Resources.
Count is retained input entries after coalescing, before comparison with Git.
Resources exposes Operation, Group, Version, Resource, Namespace, Name, and APIVersion.
Resources exposes Operation, Group, Version, Resource, Namespace, Name, APIVersion,
ResourceVersion — the version of the state this commit wrote, which lags the object's
current one and is empty for a producer that observed none — and Generation, the desired
state's counter, which is 0 for a kind without a spec. Guard both with {{with}}.
A literal CommitRequest message overrides this template without changing authorship.
type: string
reconcileTemplate:
description: |-
ReconcileTemplate formats atomic snapshots and resyncs.
Fields: Count, GitTarget, Group, Version, Resource, APIVersion, Namespace, Revision.
Type and Namespace fields are empty for whole-target snapshots. Revision can be empty.
Fields: Count, GitTarget, Group, Version, Resource, APIVersion, Namespace, ResourceVersion.
ResourceVersion is the snapshot LIST's version, not any one object's. Type and Namespace
fields are empty for whole-target snapshots, and ResourceVersion can be empty.
Guard optional fields so the message remains meaningful for every snapshot scope.
Revision was renamed to ResourceVersion in v0.48.0; a template still naming it is rejected.
type: string
requestTemplate:
description: |-
Expand Down
49 changes: 49 additions & 0 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,55 @@ guidance that the changelog's breaking-change entries link to.
We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with
`bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it.

## `reconcileTemplate`'s `Revision` is now `ResourceVersion`

**Breaking, and only for a custom `reconcileTemplate`.** The reconcile commit-message field
`{{.Revision}}` is renamed to `{{.ResourceVersion}}`. A template still naming the old spelling is
**rejected**, not quietly pruned: the `GitTarget` reports `Validated=False` with reason
`InvalidConfig` and a message naming both spellings, and it stops gating its rules until the
template is fixed.

Nothing changes if you never set `reconcileTemplate`. The default template is renamed with the
field and its output is byte-identical — still
`chore: reconcile 4 configmaps in team-a (last resourceVersion: 1331)`.

### Why

`Revision` meant three different things in this project: a Git commit (`LayoutReport.Revision`, the
`GitTarget`'s `resolvedAtRevision`), an internal watch-plan counter, and — only in this one
user-facing place — a Kubernetes `resourceVersion`. A commit message about Git is the last place
"revision" should mean something other than a commit.

The rename lands in the same release as the new per-resource
[`Resources[i].ResourceVersion`](configuration.md#naming-the-state-a-commit-wrote) on
`liveTemplate`, so the vocabulary changes once: **`Revision` is a Git commit, `ResourceVersion` is a
Kubernetes version.**

### What to change

Rename the field in any `reconcileTemplate` that uses it:

```yaml
spec:
commit:
message:
# before
reconcileTemplate: "chore: reconcile {{.Count}}{{if .Revision}} at {{.Revision}}{{end}}"
# after
reconcileTemplate: "chore: reconcile {{.Count}}{{with .ResourceVersion}} at {{.}}{{end}}"
```

Find them before upgrading:

```bash
kubectl get gittargets -A \
-o jsonpath='{range .items[?(@.spec.commit.message.reconcileTemplate)]}{.metadata.namespace}{"/"}{.metadata.name}{"\t"}{.spec.commit.message.reconcileTemplate}{"\n"}{end}' \
| grep -F '.Revision'
```

The meaning is unchanged: it is the snapshot `LIST`'s `resourceVersion`, one value for the whole
reconcile, and empty for a pure sweep.

## `CommitRequest.spec.closeDelaySeconds` defaults to 2 seconds

**Breaking.** A `CommitRequest` that omits `closeDelaySeconds` now sets its finalize deadline two
Expand Down
82 changes: 73 additions & 9 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,15 +598,15 @@ spec:
{{range .Resources -}}
- [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{if .Namespace}}{{.Namespace}}/{{end}}{{.Name}}
{{end -}}
reconcileTemplate: "chore: reconcile {{.Count}} {{if .Resource}}{{.Resource}}{{else}}resources{{end}}{{if .Namespace}} in {{.Namespace}}{{end}}{{if .Revision}} (last resourceVersion: {{.Revision}}){{end}}"
reconcileTemplate: "chore: reconcile {{.Count}} {{if .Resource}}{{.Resource}}{{else}}resources{{end}}{{if .Namespace}} in {{.Namespace}}{{end}}{{if .ResourceVersion}} (last resourceVersion: {{.ResourceVersion}}){{end}}"
```

| Template | Fields |
|---|---|
| `liveTemplate` | `Author`, `GitTarget`, `Count`, `Operations`, `Resources`, and the `LabelValues` / `LabelValue` accessors |
| `requestTemplate` | the same fields, plus `RequestMessage` |
| Each `Resources` entry | `Operation`, `Group`, `Version`, `Resource`, `Kind`, `Namespace`, `Name`, `APIVersion`, `Labels`, and the `Label` accessor |
| `reconcileTemplate` | `Count`, `GitTarget`, `Group`, `Version`, `Resource`, `APIVersion`, `Namespace`, `Revision` |
| Each `Resources` entry | `Operation`, `Group`, `Version`, `Resource`, `Kind`, `Namespace`, `Name`, `APIVersion`, `ResourceVersion`, `Generation`, `Labels`, and the `Label` accessor |
| `reconcileTemplate` | `Count`, `GitTarget`, `Group`, `Version`, `Resource`, `APIVersion`, `Namespace`, `ResourceVersion` |

Live `Count` counts retained entries after window coalescing, before the writer compares them with
Git. Repeated edits to one resource collapse to one entry, with the last operation retained.
Expand Down Expand Up @@ -699,12 +699,65 @@ Three things to know:
nothing, so a commit holding one labeled and one unlabeled resource is named after neither.
- **A `DELETE` carries no object**, because the resource is already gone from the cluster, so
`Kind` and `Labels` are empty for one. The identity fields (`Name`, `Namespace`, `Resource`, …)
are unaffected. A commit containing a `DELETE` therefore has no agreed `LabelValue`: what the
deleted resource was labeled is not something the window still knows.
are unaffected, and so are `ResourceVersion` and `Generation`: the watch delivers the final
object, so the last state that existed is still knowable even though the object is not. A commit containing a
`DELETE` therefore has no agreed `LabelValue`: what the deleted resource was labeled is not
something the window still knows.

##### Naming the state a commit wrote

Two counters describe the observed state each entry was at. Neither is committed to the file
(`resourceVersion` and `generation` are stripped from every manifest on purpose, because a counter
inside a manifest would make every observation a byte change); both travel beside the object, in
the message only. Both are off by default, and both are guarded with `{{with}}`:

```yaml
liveTemplate: |-
chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}

{{range .Resources -}}
- [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}{{with .ResourceVersion}}@{{.}}{{end}}{{with .Generation}} gen{{.}}{{end}}
{{end -}}
```

| | `{{.ResourceVersion}}` | `{{.Generation}}` |
|---|---|---|
| moves when | **anything** is written, `/status` included | only the **desired state** changes |
| present on | every resource the operator observed | only kinds with a spec: **not** a ConfigMap or a Secret |
| absent value | `""` | `0` |
| safe to compare | for **equality** only | yes: per object, starts at `1`, one step per spec write |

**Turn `ResourceVersion` on to make a commit joinable**: to an audit log entry, to a
`kubectl get -o yaml` taken at the time, to another operator's logs. Equal to the object's current
version means nothing is pending. Do not subtract two of them: `resourceVersion` is opaque by API
contract and is the cluster-wide store revision in practice, so two consecutive commits of one
ConfigMap can read `@1331` then `@8402` with nothing skipped, because every other object's writes
moved the same counter.

**Turn `Generation` on to see what a commit missed.** It is the counter a gap is meaningful in: a
commit at `gen3` following one at `gen6` means three spec changes that were not committed
separately. That is the question `ResourceVersion` cannot answer.

Read them together, because each is blind where the other sees:

- **`ResourceVersion` lags, deliberately.** An update whose committed content would be identical (a
`/status`-only write) is never routed, so the version in a commit can be older than the object's
current one. A resource re-edited inside one window contributes the last routed version, and an
entry that already matched Git still contributes one.
- **`Generation` misses metadata.** A label- or annotation-only edit changes what gets committed
without moving it, so an unchanged `Generation` across two commits does not mean an unchanged
commit. And it is `0` for every spec-less kind, which is much of what a typical target mirrors.
- **Neither counts skips.** To count what the operator deliberately did not route, read
`watch_events_total{outcome="unchanged"}`, which is exactly that census.

A producer that observed no object renders nothing for either: reconcile, resync, and bootstrap
writes all leave both empty.

`reconcileTemplate` gets none of these. It describes a *type* being reconciled rather than a list
of resources, so there are no labels to read, and its `Namespace` keeps the plain meaning it always
had: the namespace a namespace-scoped reconcile covered, empty for a whole-target or all-namespaces
of resources, so there are no labels to read, and its own `{{.ResourceVersion}}` is the snapshot
`LIST`'s version: one value for the whole run, not any single object's. Live names n resources, so
it names each one's version; reconcile names a type, so it names the snapshot's. Its `Namespace`
keeps the plain meaning it always had: the namespace a namespace-scoped reconcile covered, empty for a whole-target or all-namespaces
one. That emptiness means "every namespace", not "cluster-scoped", so the `_cluster` sentinel would
be a lie there rather than a convenience.

Expand All @@ -724,15 +777,26 @@ The nouns are the same in both, and only the spelling follows each host language
| name | `{name}` | `.Name` |
| API version | `{apiVersion}` | `.APIVersion` |
| one label | `{label:team}` | `.Label "team"` |
| the observed version | (none) | `.ResourceVersion` |
| the desired-state counter | (none) | `.Generation` |

The two counters are the first nouns that legitimately exist on only one side. A path keyed on a
counter would write a new file whenever it moved, which is the opposite of what placement is for: a
path has to be stable and statically checkable. A message describes one moment, so it can name
one.

The capitals are Go's, not a style choice: `text/template` can only reach exported struct fields,
which must begin with one. The braces are lower-case because a placement template reads like the
manifest it is filing (`metadata.namespace`). A label needs a method call rather than a field on
the commit side because a Go template field name cannot contain a `:` or a `/`.

Reconcile type fields name the synced type; `Namespace` names a namespace-scoped snapshot.
Whole-target snapshots leave those fields empty. `Revision` is the snapshot's resourceVersion and
can be absent, including a pure sweep. Guard optional values as in the example.
Whole-target snapshots leave those fields empty. `ResourceVersion` is the snapshot's resourceVersion
and can be absent, including a pure sweep. Guard optional values as in the example.

`ResourceVersion` was called `Revision` before `v0.48.0`. A `reconcileTemplate` still naming
`{{.Revision}}` is rejected, and the `GitTarget` says so on its `Validated` condition. See
[the upgrade note](UPGRADING.md#reconciletemplates-revision-is-now-resourceversion).

A reconcile runs per *cell* (a (type, namespace) pair) rather than per target, so a
namespace-scoped run covers exactly one namespace and the default subject names it:
Expand Down
13 changes: 13 additions & 0 deletions internal/controller/gittarget_commit_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ func TestValidateCommitConfig(t *testing.T) {
},
saysA: "spec.commit.message",
},
{
// The renamed reconcile field reaches an operator HERE, on the Validated condition,
// so the message has to name the new spelling. Reaching it through the controller
// (not just ValidateCommitConfig) is the point: this is the text someone upgrading
// into a stored template actually reads.
name: "a reconcile template still naming the retired Revision",
spec: &configbutleraiv1alpha3.GitTargetCommitSpec{
Message: &configbutleraiv1alpha3.CommitMessageSpec{
ReconcileTemplate: "chore: reconcile {{.Count}} at {{.Revision}}",
},
},
saysA: "{{.ResourceVersion}}",
},
} {
t.Run(tc.name, func(t *testing.T) {
target := &configbutleraiv1alpha3.GitTarget{}
Expand Down
2 changes: 1 addition & 1 deletion internal/git/branch_worker_split_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1140,7 +1140,7 @@ func TestResync_WorkerAppliesMarkAndSweepAndCommits(t *testing.T) {
// Resync: the cluster now has "keep" and no longer has "drop-me".
req := &ResyncRequest{
Desired: []manifestanalyzer.DesiredResource{desiredCM("keep", "blue")},
Revision: "42",
ResourceVersion: "42",
GitTargetName: "target-a",
GitTargetNamespace: "default",
Result: make(chan ResyncResult, 1),
Expand Down
Loading