-
Notifications
You must be signed in to change notification settings - Fork 45
/
update.go
382 lines (316 loc) · 11.2 KB
/
update.go
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
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
package checks
import (
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/dprotaso/go-yit"
version "github.com/wolfi-dev/wolfictl/pkg/versions"
"github.com/fatih/color"
"chainguard.dev/melange/pkg/build"
"chainguard.dev/melange/pkg/config"
"chainguard.dev/melange/pkg/util"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/wolfi-dev/wolfictl/pkg/lint"
"github.com/wolfi-dev/wolfictl/pkg/melange"
"github.com/wolfi-dev/wolfictl/pkg/update"
"github.com/wolfi-dev/wolfictl/pkg/update/deps"
"gopkg.in/yaml.v3"
)
type CheckUpdateOptions struct {
Dir string
OverrideVersion string
Logger *log.Logger
}
// SetupUpdate will create the options needed to call wolfictl update functions
func SetupUpdate(ctx context.Context) (*update.Options, lint.EvalRuleErrors) {
o := update.New(ctx)
o.GithubReleaseQuery = true
o.ReleaseMonitoringQuery = true
o.ErrorMessages = make(map[string]string)
o.Logger = log.New(log.Writer(), "wolfictl check update: ", log.LstdFlags|log.Lmsgprefix)
checkErrors := make(lint.EvalRuleErrors, 0)
return &o, checkErrors
}
// CheckUpdates will use the melange update config to get the latest versions and validate fetch and git-checkout pipelines
func (o CheckUpdateOptions) CheckUpdates(ctx context.Context, files []string) error {
updateOpts, checkErrors := SetupUpdate(ctx)
changedPackages := GetPackagesToUpdate(files)
validateUpdateConfig(ctx, changedPackages, &checkErrors)
latestVersions, err := updateOpts.GetLatestVersions(ctx, o.Dir, changedPackages)
if err != nil {
addCheckError(&checkErrors, err)
}
handleErrorMessages(updateOpts, &checkErrors)
if o.OverrideVersion == "" {
o.checkForLatestVersions(ctx, latestVersions, &checkErrors)
}
if len(checkErrors) == 0 {
err := o.processUpdates(ctx, latestVersions, &checkErrors)
if err != nil {
addCheckError(&checkErrors, err)
}
}
return checkErrors.WrapErrors()
}
const yamlExtension = ".yaml"
// validates update configuration
func validateUpdateConfig(ctx context.Context, files []string, checkErrors *lint.EvalRuleErrors) {
for _, file := range files {
// skip hidden files
if strings.HasPrefix(file, ".") {
continue
}
// first need to read raw bytes as unmarshalling a struct without a pointer means update will never be nil
if !strings.HasSuffix(file, yamlExtension) {
file += yamlExtension
}
yamlData, err := os.ReadFile(file)
if err != nil {
addCheckError(checkErrors, fmt.Errorf("failed to read %s: %w", file, err))
continue
}
var node yaml.Node
err = yaml.Unmarshal(yamlData, &node)
if err != nil {
addCheckError(checkErrors, fmt.Errorf("failed to unmarshal %s: %w", file, err))
continue
}
if node.Content == nil {
addCheckError(checkErrors, fmt.Errorf("config %s has no yaml content", file))
continue
}
// loop over content to ensure an update key exists
err = containsKey(node.Content[0], "update")
if err != nil {
addCheckError(checkErrors, fmt.Errorf("config %s does not have update config provided, see examples in this repository. Or use update.enabled=false, be aware maintainers may require enabled=true so the package does not become stale", file))
continue
}
// now make sure update config is configured
c, err := config.ParseConfiguration(ctx, file)
if err != nil {
addCheckError(checkErrors, fmt.Errorf("failed to parse %s: %w", file, err))
continue
}
// ensure a backend has been configured
if c.Update.Enabled {
if c.Update.ReleaseMonitor == nil && c.Update.GitHubMonitor == nil {
addCheckError(checkErrors, fmt.Errorf("config %s has update config enabled but no release-monitor or github backend monitor configured, see examples in this repository", file))
continue
}
}
}
}
func containsKey(parentNode *yaml.Node, key string) error {
it := yit.FromNode(parentNode).
ValuesForMap(yit.WithValue(key), yit.All)
if _, ok := it(); ok {
return nil
}
return fmt.Errorf("key '%s' not found in mapping", key)
}
func GetPackagesToUpdate(files []string) []string {
packagesToUpdate := []string{}
for _, f := range files {
packagesToUpdate = append(packagesToUpdate, strings.TrimSuffix(f, yamlExtension))
}
return packagesToUpdate
}
func addCheckError(checkErrors *lint.EvalRuleErrors, err error) {
*checkErrors = append(*checkErrors, lint.EvalRuleError{
Error: fmt.Errorf(err.Error()),
})
}
func handleErrorMessages(updateOpts *update.Options, checkErrors *lint.EvalRuleErrors) {
for _, message := range updateOpts.ErrorMessages {
addCheckError(checkErrors, errors.New(message))
}
}
// check if the current package.version is the latest according to the update config
func (o CheckUpdateOptions) checkForLatestVersions(ctx context.Context, latestVersions map[string]update.NewVersionResults, checkErrors *lint.EvalRuleErrors) {
for k, v := range latestVersions {
c, err := config.ParseConfiguration(ctx, filepath.Join(o.Dir, k+yamlExtension))
if err != nil {
addCheckError(checkErrors, err)
continue
}
currentVersion, err := version.NewVersion(c.Package.Version)
if err != nil {
addCheckError(checkErrors, err)
continue
}
latestVersion, err := version.NewVersion(v.Version)
if err != nil {
addCheckError(checkErrors, err)
continue
}
if currentVersion.LessThan(latestVersion) {
addCheckError(checkErrors, fmt.Errorf("package %s: update found newer version %s compared with package.version %s in melange config", k, v.Version, c.Package.Version))
}
}
}
// iterate over slice of packages, optionally override the package.version and verify fetch + git-checkout work with latest versions
func (o CheckUpdateOptions) processUpdates(ctx context.Context, latestVersions map[string]update.NewVersionResults, checkErrors *lint.EvalRuleErrors) error {
tempDir, err := os.MkdirTemp("", "wolfictl")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
for packageName, newVersion := range latestVersions {
srcConfigFile := filepath.Join(o.Dir, packageName+yamlExtension)
dryRunConfig, err := config.ParseConfiguration(ctx, srcConfigFile)
if err != nil {
return err
}
applyOverrides(&o, dryRunConfig)
data, err := yaml.Marshal(dryRunConfig)
if err != nil {
return err
}
tmpConfigFile := filepath.Join(tempDir, packageName+yamlExtension)
err = os.WriteFile(tmpConfigFile, data, os.FileMode(0o644))
if err != nil {
return err
}
// melange bump will modify the modified copy of the melange config
err = melange.Bump(ctx, tmpConfigFile, newVersion.Version, newVersion.Commit)
if err != nil {
addCheckError(checkErrors, fmt.Errorf("package %s: failed to validate update config, melange bump: %w", packageName, err))
continue
}
updated, err := config.ParseConfiguration(ctx, tmpConfigFile)
if err != nil {
return err
}
pctx := &build.PipelineBuild{
Build: &build.Build{
Configuration: *updated,
},
Package: &updated.Package,
}
// get a map of variable mutations we can substitute vars in URLs
mutations, err := build.MutateWith(pctx, map[string]string{})
if err != nil {
return err
}
// Skip any processing for definitions with a single pipeline
if len(updated.Pipeline) > 1 && deps.ContainsGoBumpPipeline(updated) {
if err := o.updateGoBumpDeps(updated, o.Dir, packageName, mutations); err != nil {
return fmt.Errorf("error cleaning up go/bump deps: %v", err)
}
}
// if manual update is expected then let's not try to validate pipelines
if updated.Update.Manual {
o.Logger.Println("manual update configured, skipping pipeline validation")
continue
}
// download or git clone sources into a temp folder to validate the update config
verifyPipelines(ctx, o, updated, mutations, checkErrors)
}
return nil
}
func applyOverrides(options *CheckUpdateOptions, dryRunConfig *config.Configuration) {
if options.OverrideVersion != "" {
dryRunConfig.Package.Version = options.OverrideVersion
}
}
func verifyPipelines(ctx context.Context, o CheckUpdateOptions, updated *config.Configuration, mutations map[string]string, checkErrors *lint.EvalRuleErrors) {
for i := range updated.Pipeline {
var err error
pipeline := updated.Pipeline[i]
if pipeline.Uses == "fetch" {
err = o.verifyFetch(ctx, &pipeline, mutations)
}
if pipeline.Uses == "git-checkout" {
err = o.verifyGitCheckout(&pipeline, mutations)
}
if err != nil {
addCheckError(checkErrors, err)
}
}
}
func (o CheckUpdateOptions) verifyFetch(ctx context.Context, p *config.Pipeline, m map[string]string) error {
uriValue := p.With["uri"]
if uriValue == "" {
return fmt.Errorf("no uri to fetch")
}
// evaluate var substitutions
evaluatedURI, err := util.MutateStringFromMap(m, uriValue)
if err != nil {
return err
}
o.Logger.Printf("downloading sources from %s into a temporary directory, this may take a while", evaluatedURI)
filename, err := util.DownloadFile(ctx, evaluatedURI)
if err != nil {
return fmt.Errorf("failed to verify fetch %s: %w", evaluatedURI, err)
}
o.Logger.Println(color.GreenString("fetch was successful"))
return os.RemoveAll(filename)
}
func (o *CheckUpdateOptions) updateGoBumpDeps(updated *config.Configuration, dir, packageName string, mutations map[string]string) error {
filename := fmt.Sprintf("%s.yaml", packageName)
yamlContent, err := os.ReadFile(filepath.Join(dir, filename))
if err != nil {
return err
}
var doc yaml.Node
err = yaml.Unmarshal(yamlContent, &doc)
if err != nil {
return fmt.Errorf("error unmarshalling YAML: %v", err)
}
// NOTE: By default, we set tidy to false because we don´t want to compile the go project during updates.
tidy := false
if err := deps.CleanupGoBumpDeps(&doc, updated, tidy, mutations); err != nil {
return err
}
modifiedYAML, err := yaml.Marshal(&doc)
if err != nil {
return fmt.Errorf("error marshaling YAML: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, filename), modifiedYAML, 0o600); err != nil {
return fmt.Errorf("failed to write configuration file: %v", err)
}
return nil
}
func (o CheckUpdateOptions) verifyGitCheckout(p *config.Pipeline, m map[string]string) error {
repoValue := p.With["repository"]
if repoValue == "" {
return fmt.Errorf("no repository to checkout")
}
tagValue := p.With["tag"]
if tagValue == "" {
return fmt.Errorf("no tag to checkout")
}
// evaluate var substitutions
evaluatedTag, err := util.MutateStringFromMap(m, tagValue)
if err != nil {
return err
}
tempDir, err := os.MkdirTemp("", "wolfictl")
if err != nil {
return err
}
cloneOpts := &git.CloneOptions{
URL: repoValue,
ReferenceName: plumbing.ReferenceName(fmt.Sprintf("refs/tags/%s", evaluatedTag)),
Progress: os.Stdout,
RecurseSubmodules: git.NoRecurseSubmodules,
ShallowSubmodules: true,
Depth: 1,
NoCheckout: true,
}
o.Logger.Printf("cloning sources from %s tag %s into a temporary directory, this may take a while", repoValue, evaluatedTag)
r, err := git.PlainClone(tempDir, false, cloneOpts)
if err != nil {
return fmt.Errorf("failed to clone %s ref %s: %w", repoValue, evaluatedTag, err)
}
if r == nil {
return fmt.Errorf("clone is empty %s ref %s", repoValue, evaluatedTag)
}
o.Logger.Println(color.GreenString("git-checkout was successful"))
return os.RemoveAll(tempDir)
}