feat(action): support field validation across release actions

Expose FieldValidationDirective on install and rollback in addition to upgrade.

Forward the configured directive through install create and update paths,
upgrade updates, and rollback updates.

Respect the directive for server-side apply and force-replace operations.

Add action-level tests covering configured and unset directives, and
kube-level tests verifying the fieldValidation query parameter.

Signed-off-by: Gaurish Chaddha <20123938+gaurishchaddha@users.noreply.github.com>
pull/32477/head
Gaurish Chaddha 2 months ago
parent a8ab76e86f
commit 30add93b12

@ -80,6 +80,9 @@ type Install struct {
// //
// This should be used with caution. // This should be used with caution.
ForceReplace bool ForceReplace bool
// FieldValidationDirective, when non-empty, overrides the Kubernetes client's
// default field validation directive for create and update operations.
FieldValidationDirective kube.FieldValidationDirective
// ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager") // ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager")
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts
ForceConflicts bool ForceConflicts bool
@ -516,18 +519,25 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
// do an update, but it's not clear whether we WANT to do an update if the reuse is set // do an update, but it's not clear whether we WANT to do an update if the reuse is set
// to true, since that is basically an upgrade operation. // to true, since that is basically an upgrade operation.
if len(toBeAdopted) == 0 && len(resources) > 0 { if len(toBeAdopted) == 0 && len(resources) > 0 {
_, err = i.cfg.KubeClient.Create( createOpts := []kube.ClientCreateOption{
resources, kube.ClientCreateOptionServerSideApply(i.ServerSideApply, false),
kube.ClientCreateOptionServerSideApply(i.ServerSideApply, false)) }
if i.FieldValidationDirective != "" {
createOpts = append(createOpts, kube.ClientCreateOptionFieldValidationDirective(i.FieldValidationDirective))
}
_, err = i.cfg.KubeClient.Create(resources, createOpts...)
} else if len(resources) > 0 { } else if len(resources) > 0 {
updateThreeWayMergeForUnstructured := i.TakeOwnership && !i.ServerSideApply // Use three-way merge when taking ownership (and not using server-side apply) updateThreeWayMergeForUnstructured := i.TakeOwnership && !i.ServerSideApply // Use three-way merge when taking ownership (and not using server-side apply)
_, err = i.cfg.KubeClient.Update( updateOpts := []kube.ClientUpdateOption{
toBeAdopted,
resources,
kube.ClientUpdateOptionForceReplace(i.ForceReplace), kube.ClientUpdateOptionForceReplace(i.ForceReplace),
kube.ClientUpdateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts), kube.ClientUpdateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts),
kube.ClientUpdateOptionThreeWayMergeForUnstructured(updateThreeWayMergeForUnstructured), kube.ClientUpdateOptionThreeWayMergeForUnstructured(updateThreeWayMergeForUnstructured),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(true)) kube.ClientUpdateOptionUpgradeClientSideFieldManager(true),
}
if i.FieldValidationDirective != "" {
updateOpts = append(updateOpts, kube.ClientUpdateOptionFieldValidationDirective(i.FieldValidationDirective))
}
_, err = i.cfg.KubeClient.Update(toBeAdopted, resources, updateOpts...)
} }
if err != nil { if err != nil {
return rel, err return rel, err

@ -177,6 +177,62 @@ func installAction(t *testing.T) *Install {
return instAction return instAction
} }
func TestInstallFieldValidationDirectiveOptions(t *testing.T) {
tests := []struct {
name string
directive kube.FieldValidationDirective
toBeAdopted kube.ResourceList
expectedCreateOpts int
expectedUpdateOpts int
}{
{
name: "create with empty directive",
expectedCreateOpts: 1,
},
{
name: "create with warn directive",
directive: kube.FieldValidationDirectiveWarn,
expectedCreateOpts: 2,
},
{
name: "update with empty directive",
toBeAdopted: createDummyResourceList(true),
expectedUpdateOpts: 4,
},
{
name: "update with warn directive",
directive: kube.FieldValidationDirectiveWarn,
toBeAdopted: createDummyResourceList(true),
expectedUpdateOpts: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := actionConfigFixture(t)
client := NewInstall(config)
client.DisableHooks = true
client.FieldValidationDirective = tt.directive
resources := createDummyResourceList(true)
rel := releaseStub()
rel.Hooks = nil
_, err := client.performInstall(rel, tt.toBeAdopted, resources)
require.NoError(t, err)
failer := config.KubeClient.(*kubefake.FailingKubeClient)
assert.Len(t, failer.RecordedCreateOptions, tt.expectedCreateOpts,
"unexpected create option count for directive %q", tt.directive)
assert.Len(t, failer.RecordedUpdateOptions, tt.expectedUpdateOpts,
"unexpected update option count for directive %q", tt.directive)
// Only one path should execute for each test case.
assert.NotEqual(t, tt.expectedCreateOpts > 0, tt.expectedUpdateOpts > 0)
})
}
}
func TestInstallRelease(t *testing.T) { func TestInstallRelease(t *testing.T) {
is := assert.New(t) is := assert.New(t)
req := require.New(t) req := require.New(t)

@ -49,6 +49,9 @@ type Rollback struct {
// //
// This should be used with caution. // This should be used with caution.
ForceReplace bool ForceReplace bool
// FieldValidationDirective, when non-empty, overrides the Kubernetes client's
// default field validation directive for update operations.
FieldValidationDirective kube.FieldValidationDirective
// ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager") // ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager")
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts
ForceConflicts bool ForceConflicts bool
@ -225,13 +228,17 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
if err != nil { if err != nil {
return targetRelease, fmt.Errorf("unable to set metadata visitor from target release: %w", err) return targetRelease, fmt.Errorf("unable to set metadata visitor from target release: %w", err)
} }
results, err := r.cfg.KubeClient.Update( updateOpts := []kube.ClientUpdateOption{
current,
target,
kube.ClientUpdateOptionForceReplace(r.ForceReplace), kube.ClientUpdateOptionForceReplace(r.ForceReplace),
kube.ClientUpdateOptionServerSideApply(serverSideApply, r.ForceConflicts), kube.ClientUpdateOptionServerSideApply(serverSideApply, r.ForceConflicts),
kube.ClientUpdateOptionThreeWayMergeForUnstructured(false), kube.ClientUpdateOptionThreeWayMergeForUnstructured(false),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(true)) kube.ClientUpdateOptionUpgradeClientSideFieldManager(true),
}
if r.FieldValidationDirective != "" {
updateOpts = append(updateOpts, kube.ClientUpdateOptionFieldValidationDirective(r.FieldValidationDirective))
}
results, err := r.cfg.KubeClient.Update(current, target, updateOpts...)
if err != nil { if err != nil {
msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err) msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err)
r.cfg.Logger().Warn(msg) r.cfg.Logger().Warn(msg)

@ -47,6 +47,48 @@ func TestRollbackRun_UnreachableKubeClient(t *testing.T) {
assert.Error(t, client.Run("")) assert.Error(t, client.Run(""))
} }
func TestRollbackFieldValidationDirectiveOptionPassed(t *testing.T) {
tests := []struct {
name string
directive kube.FieldValidationDirective
wantOpts int
}{
{name: "empty directive", wantOpts: 4},
{name: "warn directive", directive: kube.FieldValidationDirectiveWarn, wantOpts: 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := actionConfigFixture(t)
rel1 := releaseStub()
rel1.Name = "field-validation-rollback"
rel1.Version = 1
rel1.Info.Status = "superseded"
rel1.ApplyMethod = "csa"
require.NoError(t, config.Releases.Create(rel1))
rel2 := releaseStub()
rel2.Name = rel1.Name
rel2.Version = 2
rel2.Info.Status = "deployed"
rel2.ApplyMethod = "csa"
require.NoError(t, config.Releases.Create(rel2))
client := NewRollback(config)
client.Version = 1
client.ServerSideApply = "auto"
client.FieldValidationDirective = tt.directive
require.NoError(t, client.Run(rel1.Name))
failer := config.KubeClient.(*kubefake.FailingKubeClient)
assert.Len(t, failer.RecordedUpdateOptions, tt.wantOpts,
"unexpected update option count for directive %q", tt.directive)
})
}
}
func TestRollback_WaitOptionsPassedDownstream(t *testing.T) { func TestRollback_WaitOptionsPassedDownstream(t *testing.T) {
is := assert.New(t) is := assert.New(t)
req := require.New(t) req := require.New(t)

@ -135,6 +135,9 @@ type Upgrade struct {
EnableDNS bool EnableDNS bool
// TakeOwnership will skip the check for helm annotations and adopt all existing resources. // TakeOwnership will skip the check for helm annotations and adopt all existing resources.
TakeOwnership bool TakeOwnership bool
// FieldValidationDirective, when non-empty, overrides the Kubernetes client's
// default field validation directive for update operations.
FieldValidationDirective kube.FieldValidationDirective
} }
type resultMessage struct { type resultMessage struct {
@ -466,12 +469,19 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
} }
upgradeClientSideFieldManager := isReleaseApplyMethodClientSideApply(originalRelease.ApplyMethod) && serverSideApply // Update client-side field manager if transitioning from client-side to server-side apply upgradeClientSideFieldManager := isReleaseApplyMethodClientSideApply(originalRelease.ApplyMethod) && serverSideApply // Update client-side field manager if transitioning from client-side to server-side apply
updateOpts := []kube.ClientUpdateOption{
kube.ClientUpdateOptionForceReplace(u.ForceReplace),
kube.ClientUpdateOptionServerSideApply(serverSideApply, u.ForceConflicts),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManager),
}
if u.FieldValidationDirective != "" {
updateOpts = append(updateOpts, kube.ClientUpdateOptionFieldValidationDirective(u.FieldValidationDirective))
}
results, err := u.cfg.KubeClient.Update( results, err := u.cfg.KubeClient.Update(
current, current,
target, target,
kube.ClientUpdateOptionForceReplace(u.ForceReplace), updateOpts...)
kube.ClientUpdateOptionServerSideApply(serverSideApply, u.ForceConflicts),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManager))
if err != nil { if err != nil {
u.cfg.recordRelease(originalRelease) u.cfg.recordRelease(originalRelease)
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)

@ -49,6 +49,37 @@ func upgradeAction(t *testing.T) *Upgrade {
return upAction return upAction
} }
func TestUpgradeFieldValidationDirectiveOptionPassed(t *testing.T) {
tests := []struct {
name string
directive kube.FieldValidationDirective
wantOpts int
}{
{name: "empty directive", wantOpts: 3},
{name: "warn directive", directive: kube.FieldValidationDirectiveWarn, wantOpts: 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upAction := upgradeAction(t)
upAction.ForceReplace = false
upAction.FieldValidationDirective = tt.directive
rel := releaseStub()
rel.Name = "field-validation-upgrade"
rel.Info.Status = common.StatusDeployed
require.NoError(t, upAction.cfg.Releases.Create(rel))
_, err := upAction.Run(rel.Name, buildChart(), map[string]any{})
require.NoError(t, err)
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
assert.Len(t, failer.RecordedUpdateOptions, tt.wantOpts,
"unexpected update option count for directive %q", tt.directive)
})
}
}
func TestUpgradeRelease_Success(t *testing.T) { func TestUpgradeRelease_Success(t *testing.T) {
is := assert.New(t) is := assert.New(t)
req := require.New(t) req := require.New(t)

@ -2272,3 +2272,99 @@ func createManifest(t *testing.T, manifest string,
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, fakeClient.Tracker().Create(mapping.Resource, obj, obj.GetNamespace())) require.NoError(t, fakeClient.Tracker().Create(mapping.Resource, obj, obj.GetNamespace()))
} }
func TestCreateFieldValidationDirective(t *testing.T) {
c := newTestClient(t)
pods := newPodList("whale")
seenRequest := false
client := NewRequestResponseLogClient(t, func(_ []RequestResponseAction, req *http.Request) (*http.Response, error) {
assert.Equal(t, http.MethodPatch, req.Method)
assert.Equal(t, string(FieldValidationDirectiveWarn), req.URL.Query().Get("fieldValidation"))
seenRequest = true
return newResponse(http.StatusOK, &pods.Items[0])
})
c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: unstructuredSerializer,
Client: fake.CreateHTTPClient(client.Do),
}
resources, err := c.Build(objBody(&pods), false)
require.NoError(t, err)
result, err := c.Create(
resources,
ClientCreateOptionServerSideApply(true, false),
ClientCreateOptionFieldValidationDirective(FieldValidationDirectiveWarn),
)
require.NoError(t, err)
assert.Len(t, result.Created, 1)
assert.True(t, seenRequest, "expected a server-side apply request")
}
func TestUpdateFieldValidationDirective(t *testing.T) {
tests := []struct {
name string
serverSideApply bool
forceReplace bool
expectedMethod string
}{
{
name: "server-side apply",
serverSideApply: true,
expectedMethod: http.MethodPatch,
},
{
name: "force replace",
forceReplace: true,
expectedMethod: http.MethodPut,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := newTestClient(t)
originalPods := newPodList("starfish")
targetPods := newPodList("starfish")
targetPods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
seenUpdateRequest := false
client := NewRequestResponseLogClient(t, func(_ []RequestResponseAction, req *http.Request) (*http.Response, error) {
switch req.Method {
case http.MethodGet:
return newResponse(http.StatusOK, &originalPods.Items[0])
case tt.expectedMethod:
assert.Equal(t, string(FieldValidationDirectiveWarn), req.URL.Query().Get("fieldValidation"))
seenUpdateRequest = true
return newResponse(http.StatusOK, &targetPods.Items[0])
default:
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}
})
c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: unstructuredSerializer,
Client: fake.CreateHTTPClient(client.Do),
}
originals, err := c.Build(objBody(&originalPods), false)
require.NoError(t, err)
targets, err := c.Build(objBody(&targetPods), false)
require.NoError(t, err)
result, err := c.Update(
originals,
targets,
ClientUpdateOptionForceReplace(tt.forceReplace),
ClientUpdateOptionServerSideApply(tt.serverSideApply, false),
ClientUpdateOptionUpgradeClientSideFieldManager(false),
ClientUpdateOptionFieldValidationDirective(FieldValidationDirectiveWarn),
)
require.NoError(t, err)
assert.Len(t, result.Updated, 1)
assert.True(t, seenUpdateRequest, "expected a %s update request", tt.expectedMethod)
})
}
}

@ -48,7 +48,11 @@ type FailingKubeClient struct {
WaitForDeleteError error WaitForDeleteError error
WatchUntilReadyError error WatchUntilReadyError error
WaitDuration time.Duration WaitDuration time.Duration
// RecordedWaitOptions stores the WaitOptions passed to GetWaiter for testing // RecordedCreateOptions stores the ClientCreateOptions passed to Create for testing.
RecordedCreateOptions []kube.ClientCreateOption
// RecordedUpdateOptions stores the ClientUpdateOptions passed to Update for testing.
RecordedUpdateOptions []kube.ClientUpdateOption
// RecordedWaitOptions stores the WaitOptions passed to GetWaiter for testing.
RecordedWaitOptions []kube.WaitOption RecordedWaitOptions []kube.WaitOption
mu sync.Mutex mu sync.Mutex
} }
@ -67,6 +71,9 @@ type FailingKubeWaiter struct {
// Create returns the configured error if set or prints // Create returns the configured error if set or prints
func (f *FailingKubeClient) Create(resources kube.ResourceList, options ...kube.ClientCreateOption) (*kube.Result, error) { func (f *FailingKubeClient) Create(resources kube.ResourceList, options ...kube.ClientCreateOption) (*kube.Result, error) {
f.mu.Lock()
f.RecordedCreateOptions = append(f.RecordedCreateOptions, options...)
f.mu.Unlock()
if f.CreateError != nil { if f.CreateError != nil {
return nil, f.CreateError return nil, f.CreateError
} }
@ -125,6 +132,9 @@ func (f *FailingKubeWaiter) WatchUntilReady(resources kube.ResourceList, d time.
// Update returns the configured error if set or prints // Update returns the configured error if set or prints
func (f *FailingKubeClient) Update(r, modified kube.ResourceList, options ...kube.ClientUpdateOption) (*kube.Result, error) { func (f *FailingKubeClient) Update(r, modified kube.ResourceList, options ...kube.ClientUpdateOption) (*kube.Result, error) {
f.mu.Lock()
f.RecordedUpdateOptions = append(f.RecordedUpdateOptions, options...)
f.mu.Unlock()
if f.UpdateError != nil { if f.UpdateError != nil {
return &kube.Result{}, f.UpdateError return &kube.Result{}, f.UpdateError
} }

Loading…
Cancel
Save