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
22 changes: 22 additions & 0 deletions api/v1alpha3/gitprovider_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,28 @@ type CommitMessageSpec struct {
// +optional
LiveTemplate string `json:"liveTemplate,omitempty"`

// Rationale, kept out of the doc comment (and so out of the CRD schema) by the blank line
// below: a CommitRequest is created by whoever holds create on it in a namespace, so its
// author supplies the message but must not set commit-message policy. Framing the message
// rather than parsing it keeps that split — the operator owns the wording, the requester owns
// the words, and nothing a requester writes is ever executed. Rejecting a template that never
// renders RequestMessage is what keeps the split honest in the other direction: silently
// discarding a requester's stated reason would be worse than having no template at all.

// RequestTemplate frames a CommitRequest's message instead of committing it verbatim. It
// renders only for a commit window whose attached request supplied a message; every other
// window uses liveTemplate or reconcileTemplate.
//
// It receives the same fields as liveTemplate, plus RequestMessage carrying the request's
// message unaltered. That message is never parsed as a template, so template syntax inside it
// stays literal.
//
// Omitted, a request's message is committed exactly as supplied. A template that never renders
// RequestMessage is rejected. One that fails to render at commit time commits the message
// verbatim instead, counted as message_source="commit_request_fallback".
// +optional
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.
Expand Down
14 changes: 14 additions & 0 deletions config/crd/bases/configbutler.ai_gittargets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,20 @@ spec:
Type and Namespace fields are empty for whole-target snapshots. Revision can be empty.
Guard optional fields so the message remains meaningful for every snapshot scope.
type: string
requestTemplate:
description: |-
RequestTemplate frames a CommitRequest's message instead of committing it verbatim. It
renders only for a commit window whose attached request supplied a message; every other
window uses liveTemplate or reconcileTemplate.

It receives the same fields as liveTemplate, plus RequestMessage carrying the request's
message unaltered. That message is never parsed as a template, so template syntax inside it
stays literal.

Omitted, a request's message is committed exactly as supplied. A template that never renders
RequestMessage is rejected. One that fails to render at commit time commits the message
verbatim instead, counted as message_source="commit_request_fallback".
type: string
type: object
x-kubernetes-validations:
- message: eventTemplate is retired; migrate to liveTemplate
Expand Down
29 changes: 29 additions & 0 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@ 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.

## Save messages can be framed by the GitTarget

**Not breaking.** `GitTarget.spec.commit.message.requestTemplate` is new and optional; omit it and
`CommitRequest.spec.message` is committed verbatim exactly as before.

Until now a save request's message REPLACED the commit template, so supplying one lost the resource
body `liveTemplate` would have produced. `requestTemplate` composes them:

```yaml
spec:
commit:
message:
requestTemplate: |-
{{.RequestMessage}}

{{range .Resources -}}
- [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}
{{end -}}
```

`CommitRequest.spec.message` stays literal and is still never parsed as a template — it arrives as
`.RequestMessage` and is committed unaltered. A `requestTemplate` that never renders it is rejected
with `Validated=False`.

`git_commits_total` gains two `message_source` values, `commit_request_framed` and
`commit_request_fallback`. The existing `commit_request` keeps its meaning (a verbatim message with
no `requestTemplate` configured), so dashboards reading it are unaffected. Alert on the fallback
rate: it is a successful commit, so it is the only signal that a template has stopped applying.

## Watch reconnects no longer report as failures

**Not breaking, but two observable surfaces move.** Neither needs a manifest change; both may need
Expand Down
54 changes: 52 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,10 +575,13 @@ write rather than stopping the mirror.
`reconcileTemplate` formats atomic snapshots and resyncs. Invalid templates report
`Validated=False` with reason `InvalidConfig`; validation exercises singleton and mixed-operation
windows, empty authors, and scoped and whole-target snapshots through the production renderer.
Sample execution cannot prove every possible conditional branch valid.
Sample execution cannot prove every possible conditional branch valid. That applies to
`requestTemplate`'s [message check](#framing-a-save-message) too: a template that drops the save
message only on some branch passes validation and falls back at commit time.

| Input, in precedence order | Message source |
|---|---|
| Attached [save request](#commitrequest) **and** `requestTemplate` set | `requestTemplate`, with the request's message as `.RequestMessage` |
| Non-empty literal override, including an attached [save request](#commitrequest) | Exact supplied text |
| Live window of any size | `liveTemplate` |
| Atomic snapshot or resync | `reconcileTemplate` |
Expand All @@ -601,6 +604,7 @@ spec:
| 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` |

Expand All @@ -611,10 +615,54 @@ first-seen order. An entry already matching Git still counts, and several entrie
The count can exceed the number of changed resources. A no-op creates no commit, even with a literal
message. Printing a resource entry directly keeps its `group/version/resource[/namespace]/name` form.

`RequestMessage` belongs to `requestTemplate`. It exists on the live context too, but is always
empty there: a window carrying a save request's message renders `requestTemplate` when one is
configured and the literal message when one is not, so `liveTemplate` only ever runs for windows
that have no request message. Do not reach for `{{if .RequestMessage}}` inside `liveTemplate`;
it never fires.

`Author` is the raw window username and is empty when no actor is named. It does not use an OIDC
display name or the `attribution-unresolved` Git author sentinel. The sentinel appears only in the
Git author header when attribution ran without resolving an actor. Messages never change authorship.

##### Framing a save message

By default a [save request](#commitrequest)'s message **replaces** the template, so supplying one
costs you the resource body `liveTemplate` would have produced: the commit says why, but no longer
says what. `requestTemplate` composes the two.

```yaml
spec:
commit:
message:
requestTemplate: |-
{{.RequestMessage}}

{{range .Resources -}}
- [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}
{{end -}}
```

It renders only for a window a save request attached to; every other window is unaffected. Omit it
and request messages are committed verbatim, exactly as before.

The request is **never** parsed as a template. Its message arrives as `.RequestMessage` and is
committed unaltered, so a save-button user supplies the content while the operator owns the
wording around it. Nothing a requester writes is ever executed, and braces inside a request message
stay literal in every case.

A `requestTemplate` that never renders `.RequestMessage` is **rejected** with `Validated=False`,
because dropping the requester's stated reason is the one thing this field must not do. Every
spelling that puts the message in the commit is accepted (`{{.RequestMessage}}`, a pipeline, or a
variable), because the check renders the template and looks for the message in the output rather
than scanning the template's text.

`requestTemplate` is validated against the same window shapes as `liveTemplate`, so a template
reading a label some resources do not carry fails at admission rather than at commit time. If one
does fail to render in production, the request's message is committed verbatim instead of the
window being lost, and the commit is counted under `message_source="commit_request_fallback"`.
Alert on that rate: the commit itself succeeds and no condition moves, so it is the only signal.

##### Kind, scope, and labels

`Kind` is the commit-message spelling of the `{kind}` [placement variable](#template-variables),
Expand Down Expand Up @@ -1614,7 +1662,9 @@ The entire spec is immutable. Create a new `CommitRequest` for each save attempt
A present message accepts 1–1024 Unicode characters, including newline. All other ASCII control
characters, including tab, carriage return, and DEL, are rejected, as is whitespace-only text.
Accepted surrounding spaces are preserved. Braces such as `{{.Author}}` remain literal; omission
uses [the live template](#commit-message-templates). A rejected request leaves automatic mirroring
uses [the live template](#commit-message-templates). To frame a save message with what was saved,
configure [`requestTemplate`](#framing-a-save-message) on the GitTarget. The request is still never
parsed as a template. A rejected request leaves automatic mirroring
available. The submitter chooses any semantic prefix; free-form messages are accepted.

A request attaches to at most one matching open window. Normal flush triggers may close it early;
Expand Down
17 changes: 17 additions & 0 deletions docs/interpreting-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,23 @@ serve several GitTargets sharing a provider+branch, coalescing their writes into
the worker is the honest attribution unit. `author_kind` is `user`, `serviceaccount`, `committer`, or `unresolved`;
reconcile/resync commits and configured-author mode use `committer`.

`message_source` is `live`, `reconcile`, or one of three request values: `commit_request` (a save
request's message committed verbatim, no `requestTemplate` configured), `commit_request_framed`
(`requestTemplate` rendered), and `commit_request_fallback` (`requestTemplate` failed to render, so
the message was committed verbatim instead).

The fallback is the one to alert on. It is a **successful** commit — nothing is refused and no
condition moves — so without this counter a `requestTemplate` that has quietly stopped applying
looks exactly like one that was never configured:

```promql
sum by (provider_namespace, provider_name) (
rate(gitopsreverser_git_commits_total{message_source="commit_request_fallback"}[15m])
) > 0
```

`framed / (framed + fallback)` is the health ratio for the feature.

**`unresolved` is the one to watch.** It means attribution RAN and did not name an actor, so
the commit carries the `unknown (attribution unresolved)` author instead of a person. It is
deliberately not folded into `user` (which would make a lost actor look like a named one, so a
Expand Down
9 changes: 7 additions & 2 deletions docs/spec/commitrequest-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,13 @@ at most one window and cannot rename a finalized commit, including one waiting f
provides no ordering guarantee; use a non-zero window for custom save messages. The delay does not
reserve a transaction. Competing requests keep the earliest-finalize-deadline selection policy.

`spec.message` is literal, including template-like text and surrounding spaces. Omission uses
`GitTarget.spec.commit.message.liveTemplate`. A present value accepts 1–1024 Unicode characters;
`spec.message` is literal, including template-like text and surrounding spaces. It is never parsed
as a template, so a request author cannot execute one. Omission uses
`GitTarget.spec.commit.message.liveTemplate`. A target may set
`GitTarget.spec.commit.message.requestTemplate` to frame the message with the window's resources;
the message still arrives unaltered, as `.RequestMessage`, and a template that does not render it
is rejected at admission, so the request's bytes always reach the commit. A `requestTemplate` that
fails to render commits the message verbatim rather than losing the window. A present value accepts 1–1024 Unicode characters;
newline is allowed, other ASCII controls and whitespace-only text are rejected. Validation never
truncates accepted text. A no-op still creates no commit. The message does not change Git identities.

Expand Down
111 changes: 101 additions & 10 deletions internal/git/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ package git

import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
Expand Down Expand Up @@ -68,11 +71,20 @@ func renderReconcileCommitMessage(
}

func renderLiveCommitMessage(pendingWrite PendingWrite, config CommitConfig) (string, error) {
return renderCommitTemplate(
"live",
config.Message.LiveTemplate,
buildLiveCommitMessageData(pendingWrite.Author(), pendingWrite.Target().Name, pendingWrite.Events),
)
return renderCommitTemplate("live", config.Message.LiveTemplate, pendingWrite.liveMessageData())
}

// renderRequestCommitMessage frames an attached CommitRequest's message with the target's
// requestTemplate. The literal rides in as .RequestMessage rather than being parsed, so nothing a
// requester wrote is ever executed.
func renderRequestCommitMessage(pendingWrite PendingWrite, config CommitConfig) (string, error) {
return renderCommitTemplate("request", config.Message.RequestTemplate, pendingWrite.liveMessageData())
}

// liveMessageData is the template context both live renders share. One builder, because a framed
// request commit is a live window that happens to carry a message — not a different kind of commit.
func (p PendingWrite) liveMessageData() LiveCommitMessageData {
return buildLiveCommitMessageData(p.Author(), p.Target().Name, p.CommitMessage, p.Events)
}

func renderCommitTemplate(name, text string, data any) (string, error) {
Expand Down Expand Up @@ -139,6 +151,30 @@ func ValidateCommitConfig(config CommitConfig) error {
// not carry "team", and failing at admission is the difference between a rejected GitTarget
// and a commit that dies mid-window months later. "{{.Label \"team\"}}" renders empty and
// passes both.
for _, events := range liveValidationSamples(sampleEvent) {
if _, err := renderLiveCommitMessage(PendingWrite{
Kind: PendingWriteCommit, Events: events,
}, config); err != nil {
return err
}
if err := validateRequestTemplate(config, events); err != nil {
return err
}
}

return nil
}

// liveValidationSamples is the set of window shapes both live-message templates are validated
// against: growing windows, mixed operations, an empty author, and — the important half — a
// resource that carries an object with labels beside ones that carry neither, because these
// templates run with missingkey=error and it is the render WITHOUT the label that fails.
//
// It is shared so liveTemplate and requestTemplate cannot drift into being checked against
// different worlds. They face identical windows at runtime; checking one more thoroughly than the
// other just moves which template fails months later instead of at admission.
func liveValidationSamples(sampleEvent Event) [][]Event {
var samples [][]Event
for _, author := range []string{"template-validator", ""} {
var events []Event
for _, operation := range []string{"CREATE", "UPDATE", "DELETE"} {
Expand All @@ -150,14 +186,69 @@ func ValidateCommitConfig(config CommitConfig) error {
event.Object = sampleLabeledObject()
}
events = append(events, event)
if _, err := renderLiveCommitMessage(PendingWrite{
Kind: PendingWriteCommit, Events: events,
}, config); err != nil {
return err
}
samples = append(samples, append([]Event(nil), events...))
}
}
return samples
}

// newRequestTemplateProbe mints the sentinel that requestTemplate validation renders as the
// request's message.
//
// Fresh per call, and unpredictable, because the check asks "did the message reach the commit?" by
// looking for this string in the rendered output. A FIXED sentinel answers a weaker question: a
// template that emits the constant itself would pass while never referencing .RequestMessage at
// all. Nobody would write that on purpose, but a check that can be satisfied without doing the
// thing it verifies is not a check.
func newRequestTemplateProbe() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("generate requestTemplate probe: %w", err)
}
return "gitops-reverser-request-probe-" + hex.EncodeToString(raw[:]), nil
}

// validateRequestTemplate checks that a configured requestTemplate renders, AND that it actually
// puts the request's message in the commit.
//
// The second half is the point. Without it the feature has a hole exactly as bad as the one it was
// designed to avoid: a target could set `requestTemplate: "chore: sync {{.Count}} resources"` and
// every save message would silently vanish — the requester writes a reason, the commit never
// carries it, and the commit is still counted as request-sourced. Framing the message is the whole
// purpose of the field, so a template that drops it is a mistake, not a configuration choice.
//
// It probes the RENDERED OUTPUT rather than scanning the template source. A scan for the literal
// "{{.RequestMessage}}" would reject `{{.RequestMessage | printf "%s"}}`, a template that assigns
// it to a variable first, and every other legitimate spelling — while the probe accepts all of them
// for the right reason: the message reached the commit.
//
// Sample execution cannot prove every branch: a template that drops the message only under, say,
// {{if eq .Count 1}} still passes. That is the same caveat docs/configuration.md already states for
// the other templates, not a new one.
func validateRequestTemplate(config CommitConfig, events []Event) error {
if config.Message.RequestTemplate == "" {
return nil
}

probe, err := newRequestTemplateProbe()
if err != nil {
return err
}
rendered, err := renderRequestCommitMessage(PendingWrite{
Kind: PendingWriteCommit,
CommitMessage: probe,
Events: events,
}, config)
if err != nil {
return err
}
// EVERY sample must carry the message through, not merely one: the contract is that a
// requester's reason reaches the commit whatever the window happened to contain.
if !strings.Contains(rendered, probe) {
return errors.New("requestTemplate must render {{.RequestMessage}}: as written it would " +
"drop the CommitRequest's message from the commit. Omit requestTemplate to commit that " +
"message verbatim")
}
return nil
}

Expand Down
Loading