-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathcommit.go
More file actions
256 lines (227 loc) · 7.63 KB
/
Copy pathcommit.go
File metadata and controls
256 lines (227 loc) · 7.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
// SPDX-License-Identifier: Apache-2.0
package git
import (
"bytes"
"fmt"
"regexp"
"strings"
"text/template"
"time"
"unicode"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/ConfigButler/gitops-reverser/internal/types"
)
func renderEventCommitMessage(event Event, config CommitConfig) (string, error) {
return renderCommitTemplate(
"event",
config.Message.EventTemplate,
CommitMessageData{
Operation: event.Operation,
Group: event.Identifier.Group,
Version: event.Identifier.Version,
Resource: event.Identifier.Resource,
Namespace: event.Identifier.Namespace,
Name: event.Identifier.Name,
APIVersion: buildAPIVersion(event.Identifier.Group, event.Identifier.Version),
Username: event.UserInfo.Username,
GitTarget: event.GitTargetName,
},
)
}
// renderReconcileCommitMessageFromEvents renders the reconcile commit message for the
// events-based atomic path from the provider's ReconcileTemplate. It carries no single
// type or revision, so those template fields stay empty (the default guards them). An
// explicit override (a literal CommitRequest message) is used verbatim.
func renderReconcileCommitMessageFromEvents(
events []Event,
override string,
gitTarget string,
config CommitConfig,
) (string, error) {
if strings.TrimSpace(override) != "" {
return override, nil
}
return renderCommitTemplate(
"reconcile",
config.Message.ReconcileTemplate,
ReconcileCommitMessageData{
Count: len(events),
GitTarget: gitTarget,
},
)
}
// renderReconcileCommitMessage renders the reconcile commit message for a resync from the
// provider's ReconcileTemplate, so a resync honours a custom reconcile template. count is
// the number of resources the reconcile changed; scopeGVR names the synced type for a
// per-type splice (the M12/R2 per-type reconcile) and a nil scopeGVR (whole-target
// reconcile) leaves the type fields empty; revision is the cluster resourceVersion the
// desired set was pinned to (empty for a pure sweep). The default template guards the
// type and revision fields so it still renders cleanly when either is absent.
func renderReconcileCommitMessage(
count int,
gitTarget string,
scopeGVR *schema.GroupVersionResource,
revision string,
config CommitConfig,
) (string, error) {
data := ReconcileCommitMessageData{
Count: count,
GitTarget: gitTarget,
Revision: revision,
}
if scopeGVR != nil {
data.Group = scopeGVR.Group
data.Version = scopeGVR.Version
data.Resource = scopeGVR.Resource
data.APIVersion = buildAPIVersion(scopeGVR.Group, scopeGVR.Version)
}
return renderCommitTemplate("reconcile", config.Message.ReconcileTemplate, data)
}
func renderGroupCommitMessage(pendingWrite PendingWrite, config CommitConfig) (string, error) {
return renderCommitTemplate(
"group",
config.Message.GroupTemplate,
buildGroupedCommitMessageData(pendingWrite.Author(), pendingWrite.Target().Name, pendingWrite.Events),
)
}
func renderCommitTemplate(name, text string, data any) (string, error) {
tmpl, err := template.New(name).Option("missingkey=error").Parse(text)
if err != nil {
return "", fmt.Errorf("parse %s commit template: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("execute %s commit template: %w", name, err)
}
return buf.String(), nil
}
func buildAPIVersion(group, version string) string {
if group == "" {
return version
}
return group + "/" + version
}
// ValidateCommitConfig checks that commit templates are syntactically valid.
func ValidateCommitConfig(config CommitConfig) error {
sampleEvent := Event{
Operation: "CREATE",
Identifier: types.ResourceIdentifier{
Group: "apps",
Version: "v1",
Resource: "deployments",
Namespace: "default",
Name: "example",
},
UserInfo: UserInfo{Username: "template-validator"},
GitTargetName: "example-target",
}
if _, err := renderEventCommitMessage(sampleEvent, config); err != nil {
return err
}
if _, err := renderReconcileCommitMessageFromEvents(
[]Event{sampleEvent},
"",
"example-target",
config,
); err != nil {
return err
}
// Validate the per-type splice reconcile path with the type and revision fields populated,
// so a custom reconcile template that names its synced type ({{.Resource}} / {{.APIVersion}})
// or pins the {{.Revision}} is exercised at admission exactly as a per-type reconcile renders it.
sampleScope := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
if _, err := renderReconcileCommitMessage(1, "example-target", &sampleScope, "12345", config); err != nil {
return err
}
if _, err := renderGroupCommitMessage(PendingWrite{
Kind: PendingWriteCommit,
Events: []Event{sampleEvent},
}, config); err != nil {
return err
}
return nil
}
func operatorSignature(config CommitConfig, when time.Time) *object.Signature {
return &object.Signature{
Name: config.Committer.Name,
Email: config.Committer.Email,
When: when,
}
}
// commitOptionsFor builds the CommitOptions for a pending write. The committer is always the operator.
func commitOptionsFor(
pendingWrite PendingWrite,
config CommitConfig,
signer git.Signer,
when time.Time,
) *git.CommitOptions {
committer := operatorSignature(config, when)
author := pendingWrite.AuthorUserInfo()
if author.Username == "" {
return &git.CommitOptions{
Author: committer,
Committer: committer,
Signer: signer,
}
}
return &git.CommitOptions{
Author: &object.Signature{
Name: authorName(author),
Email: authorEmail(author),
When: when,
},
Committer: committer,
Signer: signer,
}
}
// validEmailRegex matches a syntactically valid email address. It recognises a
// username that is already an email and validates an OIDC-supplied email claim
// before trusting it in a signature header.
var validEmailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// authorName returns the git author Name for a user: the OIDC display name
// when present and safe to place in a signature header, otherwise the
// Kubernetes username.
func authorName(user UserInfo) string {
if name := strings.TrimSpace(user.DisplayName); name != "" && isSafeSignatureField(name) {
return name
}
return user.Username
}
// authorEmail returns the git author Email for a user: the OIDC email claim
// when present and a valid address, otherwise a safe address constructed from
// the username.
func authorEmail(user UserInfo) string {
if email := strings.TrimSpace(user.Email); validEmailRegex.MatchString(email) {
return email
}
return ConstructSafeEmail(user.Username, "cluster.local")
}
// isSafeSignatureField reports whether s can be placed verbatim into a git
// signature header field. Control characters (notably newlines) and the angle
// brackets that delimit the email would corrupt the commit object.
func isSafeSignatureField(s string) bool {
for _, r := range s {
if r == '<' || r == '>' || unicode.IsControl(r) {
return false
}
}
return true
}
// ConstructSafeEmail takes a raw username and a domain and creates a valid
// git-compliant email address.
func ConstructSafeEmail(username string, domain string) string {
// Check if username is already a valid email address.
if validEmailRegex.MatchString(username) {
return username
}
// Remove unsupported characters so we can safely use the username in a Git signature header.
clean := strings.ToLower(username)
reg := regexp.MustCompile(`[^a-z0-9\.\-]`)
clean = reg.ReplaceAllString(clean, "")
if clean == "" {
clean = "unknown-user"
}
return fmt.Sprintf("%s@noreply.%s", clean, domain)
}