-
Notifications
You must be signed in to change notification settings - Fork 55
Expand file tree
/
Copy pathcharttesting.go
More file actions
480 lines (411 loc) · 16 KB
/
charttesting.go
File metadata and controls
480 lines (411 loc) · 16 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
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
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
package checks
import (
"context"
"fmt"
"os"
"path"
"dario.cat/mergo"
"github.com/Masterminds/semver"
"github.com/helm/chart-testing/v3/pkg/chart"
"github.com/helm/chart-testing/v3/pkg/config"
"github.com/helm/chart-testing/v3/pkg/util"
"github.com/opdev/getocprange"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/cli"
"github.com/redhat-certification/chart-verifier/internal/chartverifier/utils"
"github.com/redhat-certification/chart-verifier/internal/tool"
)
const (
ReleaseConfigString string = "release"
)
// Versioner provides OpenShift version
type Versioner func(envSettings *cli.EnvSettings) (string, error)
func GetVersion(envSettings *cli.EnvSettings) (string, error) {
kubeConfig := tool.GetClientConfig(envSettings)
kubectl, err := tool.NewKubectl(kubeConfig)
if err != nil {
return "", err
}
serverVersion, err := kubectl.GetServerVersion()
if err != nil {
return "", err
}
// Relying on Kubernetes version can be replaced after fixing this issue:
// https://bugzilla.redhat.com/show_bug.cgi?id=1850656
kubeVersion := fmt.Sprintf("%s.%s", serverVersion.Major, serverVersion.Minor)
// We can safely assume that GetOCPRange is going to return a single version rather than a range,
// given that "kubeVersion" is itself a single version and not a range.
OCPVersion, err := getocprange.GetOCPRange(kubeVersion)
if err != nil {
return "", fmt.Errorf("Error translating kubeVersion %q to an OCP version: %v", kubeVersion, err)
}
return OCPVersion, nil
}
type OpenShiftVersionErr string
func (e OpenShiftVersionErr) Error() string {
return "Missing OpenShift version. " + string(e) + ". And the 'openshift-version' flag has not set."
}
type OpenShiftSemVerErr string
func (e OpenShiftSemVerErr) Error() string {
return "OpenShift version is not following SemVer spec. " + string(e)
}
// buildChartTestingConfiguration computes the chart testing related
// configuration from the given check options.
func buildChartTestingConfiguration(opts *CheckOptions) config.Configuration {
// cfg will be populated with options gathered from the input
// check options.
cfg := config.Configuration{
BuildID: opts.ViperConfig.GetString("buildId"),
Upgrade: opts.ViperConfig.GetBool("upgrade"),
SkipMissingValues: opts.ViperConfig.GetBool("skipMissingValues"),
ReleaseLabel: opts.ViperConfig.GetString("releaseLabel"),
Namespace: opts.ViperConfig.GetString("namespace"),
HelmExtraArgs: opts.ViperConfig.GetString("helmExtraArgs"),
}
if len(cfg.BuildID) == 0 {
cfg.BuildID = "build-" + util.RandomString(6)
}
if len(cfg.ReleaseLabel) == 0 {
cfg.ReleaseLabel = "app.kubernetes.io/instance"
}
if len(cfg.Namespace) == 0 {
// Namespace() returns "default" unless has been overridden
// through environment variables.
cfg.Namespace = opts.HelmEnvSettings.Namespace()
}
return cfg
}
// ChartTesting partially integrates the chart-testing project in chart-verifier.
//
// Unfortunately it wasn't easy as initially expect to integrate
// chart-testing as a lib in the project, including the main
// orchestration logic. The ChartTesting function is the
// interpretation the main logic chart-testing carries, and other
// functions used in this context were also ported from
// chart-verifier.
func ChartTesting(opts *CheckOptions) (Result, error) {
utils.LogInfo("Start chart install and test check")
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
defer cancel()
cfg := buildChartTestingConfiguration(opts)
helm, err := tool.NewHelm(opts.HelmEnvSettings, opts.Values, opts.HelmInstallTimeout)
if err != nil {
utils.LogError("End chart install and test check with NewHelm error")
return NewResult(false, err.Error()), nil
}
kubeConfig := tool.GetClientConfig(opts.HelmEnvSettings)
kubectl, err := tool.NewKubectl(kubeConfig)
if err != nil {
utils.LogError("End chart install and test check with NewKubectl error")
return NewResult(false, err.Error()), nil
}
_, path, err := LoadChartFromURI(opts)
if err != nil {
utils.LogError("End chart install and test check with LoadChartFromURI error")
return NewResult(false, err.Error()), nil
}
chrt, err := chart.NewChart(path)
if err != nil {
utils.LogError("End chart install and test check with NewChart error")
return NewResult(false, err.Error()), nil
}
configRelease := opts.ViperConfig.GetString(ReleaseConfigString)
if len(configRelease) > 0 {
utils.LogInfo(fmt.Sprintf("User specified release: %s", configRelease))
}
if cfg.Upgrade {
oldChrt, err := getChartPreviousVersion(chrt)
if err != nil {
utils.LogError("End chart install and test check with getChartPreviousVersion error")
return NewResult(
false,
fmt.Sprintf("skipping upgrade test of '%s' because no previous chart is available", chrt.Yaml().Name)),
nil
}
breakingChangeAllowed, err := util.BreakingChangeAllowed(oldChrt.Yaml().Version, chrt.Yaml().Version)
if !breakingChangeAllowed {
utils.LogError("End chart install and test check with BreakingChangeAllowed not allowed")
return NewResult(
false,
fmt.Sprintf("Skipping upgrade test of '%s' because breaking changes are not allowed for chart", chrt)),
nil
} else if err != nil {
utils.LogError(fmt.Sprintf("End chart install and test check with BreakingChangeAllowed error: %v", err))
return NewResult(false, err.Error()), nil
}
result := upgradeAndTestChart(ctx, cfg, oldChrt, chrt, helm, kubectl, configRelease, opts.SkipCleanup)
if result.Error != nil {
utils.LogError(fmt.Sprintf("End chart install and test check with upgradeAndTestChart error: %v", result.Error))
return NewResult(false, result.Error.Error()), nil
}
} else {
result := installAndTestChartRelease(ctx, cfg, chrt, helm, kubectl, opts.Values, configRelease, opts.SkipCleanup)
if result.Error != nil {
utils.LogError(fmt.Sprintf("End chart install and test check with installAndTestChartRelease error: %v", result.Error))
return NewResult(false, result.Error.Error()), nil
}
}
if versionError := setOCVersion(opts.AnnotationHolder, opts.HelmEnvSettings, GetVersion); versionError != nil {
if versionError != nil {
utils.LogWarning(fmt.Sprintf("End chart install and test check with version error: %v", versionError))
}
return NewResult(false, versionError.Error()), nil
}
utils.LogInfo("End chart install and test check")
return NewResult(true, ChartTestingSuccess), nil
}
// generateInstallConfig extracts required information to install a
// release and builds a clenup function to be used after tests are
// executed.
func generateInstallConfig(
cfg config.Configuration,
chrt *chart.Chart,
helm *tool.Helm,
kubectl *tool.Kubectl,
configRelease string,
skipCleanup bool,
) (namespace, release, releaseSelector string, cleanup func()) {
release = configRelease
if cfg.Namespace != "" {
namespace = cfg.Namespace
if len(release) == 0 {
release, _ = chrt.CreateInstallParams(cfg.BuildID)
}
releaseSelector = fmt.Sprintf("%s=%s", cfg.ReleaseLabel, release)
cleanup = func() {
//nolint:errcheck // TODO(komish) identify if this error needs to be
// handled nicely
if skipCleanup {
utils.LogInfo("Skipping resource cleanup")
} else {
helm.Uninstall(namespace, release)
}
}
} else {
if len(release) == 0 {
release, namespace = chrt.CreateInstallParams(cfg.BuildID)
} else {
_, namespace = chrt.CreateInstallParams(cfg.BuildID)
}
cleanup = func() {
//nolint:errcheck // TODO(komish) identify if this error needs to be
// handled nicely
helm.Uninstall(namespace, release)
//nolint:errcheck // TODO(komish) identify if this error needs to be
// handled nicely
kubectl.DeleteNamespace(context.TODO(), namespace)
}
}
return
}
// testRelease tests a release.
// nolint:unparam //TODO(komish) identify the value of cleanupHelmTests historically.
func testRelease(
ctx context.Context,
helm *tool.Helm,
kubectl *tool.Kubectl,
release, namespace, releaseSelector string,
cleanupHelmTests bool,
) error {
if err := kubectl.WaitForWorkloadResources(ctx, namespace, releaseSelector); err != nil {
return err
}
if err := helm.Test(ctx, namespace, release); err != nil {
return err
}
return nil
}
// getChartPreviousVersion attempts to retrieve the previous version
// of the given chart.
func getChartPreviousVersion(chrt *chart.Chart) (*chart.Chart, error) {
// TODO: decide which sources do we consider when searching for a
// previous version's candidate
return chrt, nil
}
// upgradeAndTestChart performs the installation of the given oldChrt,
// and attempts to perform an upgrade from that state.
func upgradeAndTestChart(
ctx context.Context,
cfg config.Configuration,
oldChrt, chrt *chart.Chart,
helm *tool.Helm,
kubectl *tool.Kubectl,
configRelease string,
skipCleanup bool,
) chart.TestResult {
// result contains the test result; please notice that each values
// file in the chart's 'ci' folder will be installed and tested
// and the first failure makes the test fail.
result := chart.TestResult{Chart: chrt}
valuesFiles := oldChrt.ValuesFilePathsForCI()
if len(valuesFiles) == 0 {
valuesFiles = append(valuesFiles, "")
}
for _, valuesFile := range valuesFiles {
if valuesFile != "" {
if cfg.SkipMissingValues && !chrt.HasCIValuesFile(valuesFile) {
// TODO: do not assume STDOUT here; instead a writer
// should be given to be written to.
utils.LogWarning(fmt.Sprintf("Upgrade testing for values file '%s' skipped because a corresponding values file was not found in %s/ci", valuesFile, chrt.Path()))
continue
}
}
// Use anonymous function. Otherwise deferred calls would pile up
// and be executed in reverse order after the loop.
fun := func() error {
namespace, release, releaseSelector, cleanup := generateInstallConfig(cfg, oldChrt, helm, kubectl, configRelease, skipCleanup)
defer cleanup()
// Install previous version of chart. If installation fails, ignore this release.
if err := helm.Install(ctx, namespace, oldChrt.Path(), release, valuesFile); err != nil {
return fmt.Errorf("upgrade testing for release '%s' skipped because of previous revision installation error: %w", release, err)
}
if err := testRelease(ctx, helm, kubectl, release, namespace, releaseSelector, true); err != nil {
return fmt.Errorf("upgrade testing for release '%s' skipped because of previous revision testing error", release)
}
if err := helm.Upgrade(ctx, namespace, oldChrt.Path(), release); err != nil {
return err
}
return testRelease(ctx, helm, kubectl, release, namespace, releaseSelector, false)
}
if err := fun(); err != nil {
result.Error = err
break
}
}
return result
}
// readObjectFromYamlFile unmarshals the given filename and returns an object with its contents.
func readObjectFromYamlFile(filename string) (map[string]interface{}, error) {
// #nosec G304
objBytes, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("reading values file: %w", err)
}
var obj map[string]interface{}
err = yaml.Unmarshal(objBytes, &obj)
if err != nil {
return nil, fmt.Errorf("unmarshalling values file contents: %w", err)
}
return obj, nil
}
// writeObjectToTempYamlFile writes the given obj into a temporary file and returns its location,
//
// It is responsibility of the caller to discard the file when finished using it.
func writeObjectToTempYamlFile(obj map[string]interface{}) (filename string, cleanupFunc func(), err error) {
objBytes, err := yaml.Marshal(obj)
if err != nil {
return "", nil, fmt.Errorf("marshalling values file new contents: %w", err)
}
tempDir, err := os.MkdirTemp(os.TempDir(), "chart-testing-*")
if err != nil {
return "", nil, fmt.Errorf("creating temporary directory: %w", err)
}
filename = path.Join(tempDir, "values.yaml")
err = os.WriteFile(filename, objBytes, 0o644)
if err != nil {
return "", nil, fmt.Errorf("writing values file new contents: %w", err)
}
cleanupFunc = func() {
os.RemoveAll(tempDir)
}
return filename, cleanupFunc, nil
}
// newTempValuesFileWithOverrides applies the extra values provided into the given filename (a YAML file) and materializes its
// contents in the file returned by the function.
//
// In the case the given filename is an empty string, it indicates that only the valueOverrides contents will be
// materialized into the temporary file to be merged by `helm` when processing the chart.
func newTempValuesFileWithOverrides(filename string, valuesOverrides map[string]interface{}) (string, func(), error) {
var obj map[string]interface{}
if filename != "" {
// in the case a filename is provided, read its contents and merge any available values override.
var err error
obj, err = readObjectFromYamlFile(filename)
if err != nil {
return "", nil, fmt.Errorf("reading values file: %w", err)
}
err = mergo.Merge(&obj, valuesOverrides, mergo.WithOverride)
if err != nil {
return "", nil, fmt.Errorf("merging extra values: %w", err)
}
} else {
obj = valuesOverrides
}
newValuesFile, clean, err := writeObjectToTempYamlFile(obj)
if err != nil {
return "", nil, fmt.Errorf("writing object to temporary location: %w", err)
}
return newValuesFile, clean, nil
}
// installAndTestChartRelease installs and tests a chart release.
func installAndTestChartRelease(
ctx context.Context,
cfg config.Configuration,
chrt *chart.Chart,
helm *tool.Helm,
kubectl *tool.Kubectl,
valuesOverrides map[string]interface{},
configRelease string,
skipCleanup bool,
) chart.TestResult {
// valuesFiles contains all the configurations that should be
// executed; in other words, it performs a test matrix between
// values files and tests.
valuesFiles := chrt.ValuesFilePathsForCI()
// Test with defaults if no values files are specified.
if len(valuesFiles) == 0 {
valuesFiles = append(valuesFiles, "")
}
result := chart.TestResult{Chart: chrt}
for _, valuesFile := range valuesFiles {
// Use anonymous function. Otherwise deferred calls would pile up
// and be executed in reverse order after the loop.
fun := func() error {
tmpValuesFile, tmpValuesFileCleanup, err := newTempValuesFileWithOverrides(valuesFile, valuesOverrides)
if err != nil {
// it is required this operation to succeed, otherwise there are no guarantees the values informed using
// `--chart-set` are propagated to the installation process, so the process breaks here.
return fmt.Errorf("creating temporary values file: %w", err)
}
defer tmpValuesFileCleanup()
namespace, release, releaseSelector, releaseCleanup := generateInstallConfig(cfg, chrt, helm, kubectl, configRelease, skipCleanup)
defer releaseCleanup()
if err := helm.Install(ctx, namespace, chrt.Path(), release, tmpValuesFile); err != nil {
return fmt.Errorf("chart Install failure: %v", err)
}
if err = testRelease(ctx, helm, kubectl, release, namespace, releaseSelector, false); err != nil {
return fmt.Errorf("chart test failure: %v", err)
}
return nil
}
if err := fun(); err != nil {
// fail fast approach; could be changed to best effort.
result.Error = err
break
}
}
return result
}
func setOCVersion(holder AnnotationHolder, envSettings *cli.EnvSettings, versioner Versioner) error {
// kubectl.GetVersion() returns an error both in case the kubectl command can't be executed and
// the value for the OpenShift version key not present.
osVersion, getVersionErr := versioner(envSettings)
// From this point on, an error is set and osVersion is empty.
if getVersionErr != nil && holder.GetCertifiedOpenShiftVersionFlag() != "" {
osVersion = holder.GetCertifiedOpenShiftVersionFlag()
}
// osVersion is empty only if an error happened and a default value
// informed by the user hasn't been informed.
if osVersion == "" {
holder.SetCertifiedOpenShiftVersion("N/A")
return OpenShiftVersionErr(getVersionErr.Error())
}
// osVersion is guaranteed to have a value, not yet validated as a
// semver value.
if _, err := semver.NewVersion(osVersion); err != nil {
holder.SetCertifiedOpenShiftVersion("N/A")
return OpenShiftSemVerErr(err.Error())
}
holder.SetCertifiedOpenShiftVersion(osVersion)
return nil
}