Skip to content

Commit 0ba8bb6

Browse files
Move deletion validation out of the webhook and into delete request validations (#536)
* Remove packagerev deletion webhook * fix rpkg-lifecycle e2e cli test * Re-add resources to packagerevision_test.go --------- Co-authored-by: Liam Fallon <35595825+liamfallon@users.noreply.github.com>
1 parent c6e9dd4 commit 0ba8bb6

5 files changed

Lines changed: 21 additions & 181 deletions

File tree

pkg/apiserver/webhooks.go

Lines changed: 9 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import (
2727
"fmt"
2828
"io"
2929
"math/big"
30-
"net"
3130
"net/http"
3231
"os"
3332
"path/filepath"
@@ -38,7 +37,6 @@ import (
3837
"github.com/fsnotify/fsnotify"
3938
"go.opentelemetry.io/otel"
4039

41-
porchapi "github.com/nephio-project/porch/api/porch/v1alpha1"
4240
configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1"
4341
"github.com/nephio-project/porch/pkg/util"
4442
admissionv1 "k8s.io/api/admission/v1"
@@ -57,7 +55,6 @@ type WebhookType string
5755
const (
5856
WebhookTypeService WebhookType = "service"
5957
WebhookTypeUrl WebhookType = "url"
60-
serverEndpoint = "/validate-deletion"
6158
repositoryValidationEndpoint = "/validate-repository"
6259
)
6360

@@ -66,15 +63,14 @@ var (
6663
certModTime time.Time
6764
)
6865

69-
var tracer = otel.Tracer("deletion-webhook")
66+
var tracer = otel.Tracer("repository-webhook")
7067

71-
// WebhookConfig defines the configuration for the PackageRevision deletion webhook
68+
// WebhookConfig defines the configuration for the Repository validation webhook
7269
type WebhookConfig struct {
7370
Type WebhookType
7471
ServiceName string // only used if Type == WebhookTypeService
7572
ServiceNamespace string // only used if Type == WebhookTypeService
7673
Host string // only used if Type == WebhookTypeUrl
77-
Path string
7874
Port int32
7975
RepositoryPath string
8076
RepoServiceName string
@@ -102,7 +98,6 @@ func newWebhookConfig(ctx context.Context) *WebhookConfig {
10298
cfg.Type = WebhookTypeUrl
10399
cfg.Host = getEnv("WEBHOOK_HOST", "localhost")
104100
}
105-
cfg.Path = serverEndpoint
106101
// Always use the WebhookTypeService for repository webhook validation
107102
cfg.RepositoryPath = repositoryValidationEndpoint
108103
cfg.RepoServiceName, cfg.RepoServiceNamespace = webhookServiceName(ctx)
@@ -168,7 +163,7 @@ func webhookServiceName(ctx context.Context) (serviceName, serviceNamespace stri
168163
func setupWebhooks(ctx context.Context, clientReader client.Reader) error {
169164
cfg := newWebhookConfig(ctx)
170165
// TODO: Refactor webhook setup to support optional webhooks and better separation of concerns.
171-
// Currently webhooks are always enabled and required for Repository/PackageRevision validation.
166+
// Currently webhooks are always enabled and required for Repository validation.
172167
// Consider: 1) Making webhooks optional via explicit flag, 2) Separating cert management from webhook lifecycle,
173168
// 3) Supporting webhook-less mode for development/testing.
174169
if !cfg.CertManWebhook {
@@ -310,39 +305,11 @@ func createValidatingWebhook(ctx context.Context, cfg *WebhookConfig, caCert []b
310305
// Set max timeout value for ValidatingWebhooks
311306
cfg.timeout = 30
312307
var (
313-
validationCfgName = "packagerev-deletion-validating-webhook"
314308
repositoryCfgName = "repository-validating-webhook"
315309
fail = admissionregistrationv1.Fail
316310
none = admissionregistrationv1.SideEffectClassNone
317311
)
318312

319-
// Webhook for PackageRevision deletion
320-
validateConfig := &admissionregistrationv1.ValidatingWebhookConfiguration{
321-
ObjectMeta: metav1.ObjectMeta{
322-
Name: validationCfgName,
323-
},
324-
Webhooks: []admissionregistrationv1.ValidatingWebhook{{
325-
Name: "packagerevdeletion.google.com",
326-
ClientConfig: admissionregistrationv1.WebhookClientConfig{
327-
CABundle: caCert,
328-
},
329-
Rules: []admissionregistrationv1.RuleWithOperations{{
330-
Operations: []admissionregistrationv1.OperationType{
331-
admissionregistrationv1.Delete,
332-
},
333-
Rule: admissionregistrationv1.Rule{
334-
APIGroups: []string{porchapi.SchemeGroupVersion.Group},
335-
APIVersions: []string{porchapi.SchemeGroupVersion.Version},
336-
Resources: []string{porchapi.PackageRevisionGVR.Resource},
337-
},
338-
}},
339-
AdmissionReviewVersions: []string{"v1"},
340-
SideEffects: &none,
341-
FailurePolicy: &fail,
342-
TimeoutSeconds: &cfg.timeout,
343-
}},
344-
}
345-
346313
// Webhook for Repository validation
347314
repositoryWebhook := admissionregistrationv1.ValidatingWebhookConfiguration{
348315
ObjectMeta: metav1.ObjectMeta{
@@ -371,37 +338,17 @@ func createValidatingWebhook(ctx context.Context, cfg *WebhookConfig, caCert []b
371338
}},
372339
}
373340

374-
// Set service or URL for both webhooks
375-
switch cfg.Type {
376-
case WebhookTypeService:
377-
validateConfig.Webhooks[0].ClientConfig.Service = &admissionregistrationv1.ServiceReference{
378-
Name: cfg.ServiceName,
379-
Namespace: cfg.ServiceNamespace,
380-
Path: &cfg.Path,
381-
Port: &cfg.Port,
382-
}
383-
case WebhookTypeUrl:
384-
url := fmt.Sprintf("https://%s%s", net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)), cfg.Path)
385-
validateConfig.Webhooks[0].ClientConfig.URL = &url
386-
default:
387-
return fmt.Errorf("invalid webhook type: %s", cfg.Type)
388-
}
389-
341+
// Set service for repository webhook
390342
repositoryWebhook.Webhooks[0].ClientConfig.Service = &admissionregistrationv1.ServiceReference{
391343
Name: cfg.RepoServiceName,
392344
Namespace: cfg.RepoServiceNamespace,
393345
Path: &cfg.RepositoryPath,
394346
Port: &cfg.Port,
395347
}
396348

397-
// Delete and recreate both webhook to allow updates in webhook configurations
398-
_ = kubeClient.AdmissionregistrationV1().ValidatingWebhookConfigurations().Delete(ctx, validationCfgName, metav1.DeleteOptions{})
349+
// Delete and recreate repository webhook to allow updates in webhook configurations
399350
_ = kubeClient.AdmissionregistrationV1().ValidatingWebhookConfigurations().Delete(ctx, repositoryCfgName, metav1.DeleteOptions{})
400351

401-
if _, err := kubeClient.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(ctx, validateConfig, metav1.CreateOptions{}); err != nil {
402-
return fmt.Errorf("failed to create package revision webhook: %w", err)
403-
}
404-
405352
if _, err := kubeClient.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(ctx, &repositoryWebhook, metav1.CreateOptions{}); err != nil {
406353
return fmt.Errorf("failed to create repository validation webhook: %w", err)
407354
}
@@ -489,9 +436,6 @@ func runWebhookServer(ctx context.Context, cfg *WebhookConfig, clientReader clie
489436
go watchCertificates(ctx, cfg.CertStorageDir, certFile, keyFile)
490437
}
491438
klog.Infoln("Starting webhook server")
492-
http.HandleFunc(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
493-
validateDeletion(w, r, clientReader)
494-
})
495439
http.HandleFunc(cfg.RepositoryPath, func(w http.ResponseWriter, r *http.Request) {
496440
validateRepository(w, r, clientReader)
497441
})
@@ -513,66 +457,6 @@ func runWebhookServer(ctx context.Context, cfg *WebhookConfig, clientReader clie
513457

514458
}
515459

516-
func validateDeletion(w http.ResponseWriter, r *http.Request, clientReader client.Reader) {
517-
ctx, span := tracer.Start(r.Context(), "validateDeletion")
518-
defer span.End()
519-
klog.Infoln("received request to validate deletion")
520-
521-
admissionReviewRequest, err := decodeAdmissionReview(r)
522-
if err != nil {
523-
errMsg := fmt.Sprintf("error getting admission review from request: %v", err)
524-
writeErr(errMsg, &w)
525-
return
526-
}
527-
528-
// Verify that we have a PackageRevision resource
529-
if admissionReviewRequest.Request.Resource != util.SchemaToMetaGVR(porchapi.PackageRevisionGVR) {
530-
errMsg := fmt.Sprintf("did not receive PackageRevision, got %s", admissionReviewRequest.Request.Resource.Resource)
531-
writeErr(errMsg, &w)
532-
return
533-
}
534-
535-
// Get the package revision using the name and namespace from the request.
536-
pr := porchapi.PackageRevision{}
537-
if err := clientReader.Get(ctx, client.ObjectKey{
538-
Namespace: admissionReviewRequest.Request.Namespace,
539-
Name: admissionReviewRequest.Request.Name,
540-
}, &pr); err != nil {
541-
klog.Errorf("could not get package revision: %s", err.Error())
542-
}
543-
544-
admissionResponse := &admissionv1.AdmissionResponse{}
545-
if pr.Spec.Lifecycle == porchapi.PackageRevisionLifecyclePublished {
546-
admissionResponse.Allowed = false
547-
admissionResponse.Result = &metav1.Status{
548-
Status: "Failure",
549-
Message: fmt.Sprintf("failed to delete package revision %q: published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion", pr.Name),
550-
Reason: "Published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion.",
551-
}
552-
} else {
553-
admissionResponse.Allowed = true
554-
admissionResponse.Result = &metav1.Status{
555-
Status: "Success",
556-
Message: fmt.Sprintf("Successfully deleted package revision %q", pr.Name),
557-
}
558-
}
559-
560-
resp, err := constructResponse(admissionResponse, admissionReviewRequest)
561-
if err != nil {
562-
errMsg := fmt.Sprintf("error constructing response: %v", err)
563-
writeErr(errMsg, &w)
564-
return
565-
}
566-
567-
w.Header().Set("Content-Type", "application/json")
568-
_, err = w.Write(resp) // #nosec G705
569-
if err != nil {
570-
errMsg := fmt.Sprintf("error writing response: %v", err)
571-
writeErr(errMsg, &w)
572-
return
573-
}
574-
}
575-
576460
func decodeAdmissionReview(r *http.Request) (*admissionv1.AdmissionReview, error) {
577461
if r.Header.Get("Content-Type") != "application/json" {
578462
return nil, fmt.Errorf("expected Content-Type 'application/json'")
@@ -656,6 +540,9 @@ func getEnvInt32(key string, defaultValue int32) int32 {
656540
}
657541

658542
func validateRepository(w http.ResponseWriter, r *http.Request, clientReader client.Reader) {
543+
ctx, span := tracer.Start(r.Context(), "validateRepository")
544+
defer span.End()
545+
659546
admissionReviewRequest, err := decodeAdmissionReview(r)
660547
if err != nil {
661548
writeErr(fmt.Sprintf("error decoding admission review: %v", err), &w)
@@ -683,7 +570,7 @@ func validateRepository(w http.ResponseWriter, r *http.Request, clientReader cli
683570

684571
// Check for conflicts with existing repositories
685572
var repoList configapi.RepositoryList
686-
if err := clientReader.List(context.Background(), &repoList); err != nil {
573+
if err := clientReader.List(ctx, &repoList); err != nil {
687574
klog.Errorf("failed to list repositories: %v", err)
688575
writeErr(fmt.Sprintf("could not list repositories: %v", err), &w)
689576
return

pkg/apiserver/webhooks_test.go

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -319,64 +319,6 @@ func TestWatchCertificatesGracefulTermination(t *testing.T) {
319319
require.NoError(t, err)
320320
}
321321

322-
func TestValidateDeletion(t *testing.T) {
323-
scheme := runtime.NewScheme()
324-
configapi.AddToScheme(scheme)
325-
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
326-
t.Run("invalid content-type", func(t *testing.T) {
327-
request, err := http.NewRequest(http.MethodPost, serverEndpoint, nil)
328-
require.NoError(t, err)
329-
request.Header.Set("Content-Type", "foo")
330-
response := httptest.NewRecorder()
331-
332-
validateDeletion(response, request, fakeClient)
333-
require.Equal(t,
334-
"error getting admission review from request: expected Content-Type 'application/json'",
335-
response.Body.String())
336-
})
337-
t.Run("valid content-type, but no body", func(t *testing.T) {
338-
request, err := http.NewRequest(http.MethodPost, serverEndpoint, nil)
339-
require.NoError(t, err)
340-
request.Header.Set("Content-Type", "application/json")
341-
response := httptest.NewRecorder()
342-
343-
validateDeletion(response, request, fakeClient)
344-
require.Equal(t,
345-
"error getting admission review from request: admission review request is empty",
346-
response.Body.String())
347-
})
348-
t.Run("wrong GVK in request", func(t *testing.T) {
349-
request, err := http.NewRequest(http.MethodPost, serverEndpoint, nil)
350-
require.NoError(t, err)
351-
352-
request.Header.Set("Content-Type", "application/json")
353-
response := httptest.NewRecorder()
354-
355-
admissionReviewRequest := admissionv1.AdmissionReview{
356-
TypeMeta: v1.TypeMeta{
357-
Kind: "AdmissionReview",
358-
APIVersion: "admission.k8s.io/v1",
359-
},
360-
Request: &admissionv1.AdmissionRequest{
361-
Resource: v1.GroupVersionResource{
362-
Group: "porch.kpt.dev",
363-
Version: "v1alpha1",
364-
Resource: "not-a-package-revision",
365-
},
366-
},
367-
}
368-
369-
body, err := json.Marshal(admissionReviewRequest)
370-
require.NoError(t, err)
371-
372-
request.Body = io.NopCloser(bytes.NewReader(body))
373-
validateDeletion(response, request, fakeClient)
374-
require.Equal(t,
375-
"did not receive PackageRevision, got not-a-package-revision",
376-
response.Body.String())
377-
})
378-
}
379-
380322
func TestValidateRepository(t *testing.T) {
381323
scheme := runtime.NewScheme()
382324
configapi.AddToScheme(scheme)

pkg/registry/porch/packagecommon.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,16 @@ func getLifecycleTransition(oldPkgRev, newPkgRev *porchapi.PackageRevision) stri
390390
}
391391

392392
func (r *packageCommon) validateDelete(ctx context.Context, deleteValidation rest.ValidateObjectFunc, obj runtime.Object, name, namespace string) (*configapi.Repository, error) {
393+
// Check if the PackageRevision is Published and not in DeletionProposed state
394+
if pkgRev, ok := obj.(*porchapi.PackageRevision); ok {
395+
if pkgRev.Spec.Lifecycle == porchapi.PackageRevisionLifecyclePublished {
396+
return nil, apierrors.NewForbidden(
397+
porchapi.Resource("packagerevisions"),
398+
name,
399+
fmt.Errorf("published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion"))
400+
}
401+
}
402+
393403
if deleteValidation != nil {
394404
err := deleteValidation(ctx, obj)
395405
if err != nil {

pkg/registry/porch/packagerevision_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ info:
403403
assert.IsType(t, &porchapi.PackageRevision{}, result)
404404

405405
//=========================================================================================
406+
406407
// Missing namespace
407408
result, deleted, err = packagerevisions.Delete(context.TODO(), pkgRevName, nil, &metav1.DeleteOptions{})
408409
assert.Error(t, err)

test/e2e/cli/testdata/rpkg-lifecycle/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ commands:
110110
- delete
111111
- git.lifecycle-package.lifecycle
112112
- --namespace=rpkg-lifecycle
113-
stderr: "git.lifecycle-package.lifecycle failed (admission webhook \"packagerevdeletion.google.com\" denied the request: failed to delete package revision \"git.lifecycle-package.lifecycle\": published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion)\nError: errors:\n admission webhook \"packagerevdeletion.google.com\" denied the request: failed to delete package revision \"git.lifecycle-package.lifecycle\": published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion \n"
113+
stderr: "git.lifecycle-package.lifecycle failed (packagerevisions.porch.kpt.dev \"git.lifecycle-package.lifecycle\" is forbidden: published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion)\nError: errors:\n packagerevisions.porch.kpt.dev \"git.lifecycle-package.lifecycle\" is forbidden: published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion \n"
114114
exitCode: 1
115115
- args:
116116
- porchctl

0 commit comments

Comments
 (0)