Skip to content

Commit

Permalink
Add generic crd upgrade safety preflight check (#901)
Browse files Browse the repository at this point in the history
and some initial validations for handling scope changes
and removal of existing stored versions

Signed-off-by: everettraven <everettraven@gmail.com>
  • Loading branch information
everettraven committed Mar 28, 2024
1 parent f249ba3 commit a89576c
Show file tree
Hide file tree
Showing 37 changed files with 17,140 additions and 6 deletions.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ require (
golang.org/x/net v0.22.0
gopkg.in/yaml.v2 v2.4.0
k8s.io/api v0.29.3
k8s.io/apiextensions-apiserver v0.29.3
k8s.io/apimachinery v0.29.3
k8s.io/client-go v0.29.3
k8s.io/component-helpers v0.29.1
k8s.io/component-helpers v0.29.3
sigs.k8s.io/yaml v1.4.0
)

Expand Down
10 changes: 6 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQL
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
Expand Down Expand Up @@ -510,12 +510,14 @@ honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw=
k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80=
k8s.io/apiextensions-apiserver v0.29.3 h1:9HF+EtZaVpFjStakF4yVufnXGPRppWFEQ87qnO91YeI=
k8s.io/apiextensions-apiserver v0.29.3/go.mod h1:po0XiY5scnpJfFizNGo6puNU6Fq6D70UJY2Cb2KwAVc=
k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU=
k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU=
k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg=
k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0=
k8s.io/component-helpers v0.29.1 h1:54MMEDu6xeJmMtAKztsPwu0kJKr4+jCUzaEIn2UXRoc=
k8s.io/component-helpers v0.29.1/go.mod h1:+I7xz4kfUgxWAPJIVKrqe4ml4rb9UGpazlOmhXYo+cY=
k8s.io/component-helpers v0.29.3 h1:1dqZswuZgT2ZMixYeORyCUOAApXxgsvjVSgfoUT+P4o=
k8s.io/component-helpers v0.29.3/go.mod h1:yiDqbRQrnQY+sPju/bL7EkwDJb6LVOots53uZNMZBos=
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=
Expand Down
2 changes: 2 additions & 0 deletions pkg/kapp/cmd/kapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
cmdcore "github.com/vmware-tanzu/carvel-kapp/pkg/kapp/cmd/core"
cmdsa "github.com/vmware-tanzu/carvel-kapp/pkg/kapp/cmd/serviceaccount"
cmdtools "github.com/vmware-tanzu/carvel-kapp/pkg/kapp/cmd/tools"
"github.com/vmware-tanzu/carvel-kapp/pkg/kapp/crdupgradesafety"
"github.com/vmware-tanzu/carvel-kapp/pkg/kapp/logger"
"github.com/vmware-tanzu/carvel-kapp/pkg/kapp/permissions"
"github.com/vmware-tanzu/carvel-kapp/pkg/kapp/preflight"
Expand Down Expand Up @@ -57,6 +58,7 @@ func NewDefaultKappCmd(ui *ui.ConfUI) *cobra.Command {
func defaultKappPreflightRegistry(depsFactory cmdcore.DepsFactory) *preflight.Registry {
registry := preflight.NewRegistry(map[string]preflight.Check{
"PermissionValidation": permissions.NewPreflight(depsFactory, false),
"CRDUpgradeSafety": crdupgradesafety.NewPreflight(depsFactory, false),
})

return registry
Expand Down
113 changes: 113 additions & 0 deletions pkg/kapp/crdupgradesafety/preflight.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2024 The Carvel Authors.
// SPDX-License-Identifier: Apache-2.0

package crdupgradesafety

import (
"context"
"errors"
"fmt"

cmdcore "github.com/vmware-tanzu/carvel-kapp/pkg/kapp/cmd/core"
ctldgraph "github.com/vmware-tanzu/carvel-kapp/pkg/kapp/diffgraph"
"github.com/vmware-tanzu/carvel-kapp/pkg/kapp/preflight"
v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)

var _ preflight.Check = (*Preflight)(nil)

// Preflight is an implementation of preflight.Check
// to make it easier to add crd upgrade validation
// as a preflight check
type Preflight struct {
depsFactory cmdcore.DepsFactory
enabled bool
validator *Validator
}

func NewPreflight(df cmdcore.DepsFactory, enabled bool) *Preflight {
return &Preflight{
depsFactory: df,
enabled: enabled,
validator: &Validator{
Validations: []Validation{
NewValidationFunc("NoScopeChange", NoScopeChange),
NewValidationFunc("NoStoredVersionRemoved", NoStoredVersionRemoved),
},
},
}
}

func (p *Preflight) Enabled() bool {
return p.enabled
}

func (p *Preflight) SetEnabled(enabled bool) {
p.enabled = enabled
}

func (p *Preflight) SetConfig(_ preflight.CheckConfig) error {
return nil
}

func (p *Preflight) Run(ctx context.Context, changeGraph *ctldgraph.ChangeGraph) error {
dCli, err := p.depsFactory.DynamicClient(cmdcore.DynamicClientOpts{})
if err != nil {
return fmt.Errorf("getting dynamic client: %w", err)
}
crdCli := dCli.Resource(v1.SchemeGroupVersion.WithResource("customresourcedefinitions"))

validateErrs := []error{}
for _, change := range changeGraph.All() {
// Loop through all the changes looking for "upsert" operations on
// a CRD. "upsert" is used for create + update operations
if change.Change.Op() != ctldgraph.ActualChangeOpUpsert {
continue
}
res := change.Change.Resource()
if res.GroupVersion().WithKind(res.Kind()) != v1.SchemeGroupVersion.WithKind("CustomResourceDefinition") {
continue
}

// to properly determine if this is an update operation, attempt to fetch
// the "old" CRD from the cluster
uOldCRD, err := crdCli.Get(ctx, res.Name(), metav1.GetOptions{})
if err != nil {
// if the resource is not found, this "upsert" operation
// translates to a "create" request being made. Skip this change
if apierrors.IsNotFound(err) {
continue
}

return fmt.Errorf("checking for existing CRD resource: %w", err)
}

oldCRD := &v1.CustomResourceDefinition{}
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
return fmt.Errorf("adding apiextension apis to scheme: %w", err)
}
if err := s.Convert(uOldCRD, oldCRD, nil); err != nil {
return fmt.Errorf("couldn't convert old CRD resource to a CRD object: %w", err)
}

newCRD := &v1.CustomResourceDefinition{}
if err := res.AsUncheckedTypedObj(newCRD); err != nil {
return fmt.Errorf("couldn't convert new CRD resource to a CRD object: %w", err)
}

if err = p.validator.Validate(*oldCRD, *newCRD); err != nil {
validateErrs = append(validateErrs, err)
}
}

if len(validateErrs) > 0 {
baseErr := errors.New("validation for safe CRD upgrades failed")
return errors.Join(append([]error{baseErr}, validateErrs...)...)
}

return nil
}
93 changes: 93 additions & 0 deletions pkg/kapp/crdupgradesafety/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2024 The Carvel Authors.
// SPDX-License-Identifier: Apache-2.0

package crdupgradesafety

import (
"errors"
"fmt"

v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/util/sets"
)

// Validation is a representation of a validation to run
// against a CRD being upgraded
type Validation interface {
// Validate contains the actual validation logic. An error being
// returned means validation has failed
Validate(old, new v1.CustomResourceDefinition) error
// Name returns a human-readable name for the validation
Name() string
}

// ValidateFunc is a function to validate a CustomResourceDefinition
// for safe upgrades. It accepts the old and new CRDs and returns an
// error if performing an upgrade from old -> new is unsafe.
type ValidateFunc func(old, new v1.CustomResourceDefinition) error

// ValidationFunc is a helper to wrap a ValidateFunc
// as an implementation of the Validation interface
type ValidationFunc struct {
name string
validateFunc ValidateFunc
}

func NewValidationFunc(name string, vfunc ValidateFunc) Validation {
return &ValidationFunc{
name: name,
validateFunc: vfunc,
}
}

func (vf *ValidationFunc) Name() string {
return vf.name
}

func (vf *ValidationFunc) Validate(old, new v1.CustomResourceDefinition) error {
return vf.validateFunc(old, new)
}

type Validator struct {
Validations []Validation
}

func (v *Validator) Validate(old, new v1.CustomResourceDefinition) error {
validateErrs := []error{}
for _, validation := range v.Validations {
if err := validation.Validate(old, new); err != nil {
formattedErr := fmt.Errorf("CustomResourceDefinition %s failed upgrade safety validation. %q validation failed: %w",
new.Name, validation.Name(), err)

validateErrs = append(validateErrs, formattedErr)
}
}
if len(validateErrs) > 0 {
return errors.Join(validateErrs...)
}
return nil
}

func NoScopeChange(old, new v1.CustomResourceDefinition) error {
if old.Spec.Scope != new.Spec.Scope {
return fmt.Errorf("scope changed from %q to %q", old.Spec.Scope, new.Spec.Scope)
}
return nil
}

func NoStoredVersionRemoved(old, new v1.CustomResourceDefinition) error {
newVersions := sets.New[string]()
for _, version := range new.Spec.Versions {
if !newVersions.Has(version.Name) {
newVersions.Insert(version.Name)
}
}

for _, storedVersion := range old.Status.StoredVersions {
if !newVersions.Has(storedVersion) {
return fmt.Errorf("stored version %q removed", storedVersion)
}
}

return nil
}

0 comments on commit a89576c

Please sign in to comment.