Skip to content

Commit

Permalink
Allow Templates deletion by the controller only
Browse files Browse the repository at this point in the history
In case the TemplateManagement object exists on the environment
the admission controller will block the removal of any Template
managed by HMC.
  • Loading branch information
eromanova committed Sep 19, 2024
1 parent 3ba4085 commit 176cb64
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 8 deletions.
3 changes: 1 addition & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,13 @@ import (
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.

_ "k8s.io/client-go/plugin/pkg/client/auth"

hcv2 "github.com/fluxcd/helm-controller/api/v2"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/dynamic"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
_ "k8s.io/client-go/plugin/pkg/client/auth"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
Expand Down
68 changes: 64 additions & 4 deletions internal/webhook/template_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"context"
"errors"
"fmt"
"os"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/fields"
Expand All @@ -32,9 +33,15 @@ import (

type ClusterTemplateValidator struct {
client.Client

ServiceAccount string
}

const TemplateKey = ".spec.template"
const (
serviceAccountEnvName = "SERVICE_ACCOUNT"

TemplateKey = ".spec.template"
)

var (
ErrTemplateDeletionForbidden = errors.New("template deletion is forbidden")
Expand Down Expand Up @@ -70,13 +77,20 @@ func (v *ClusterTemplateValidator) ValidateDelete(ctx context.Context, obj runti
if !ok {
return admission.Warnings{"Wrong object"}, apierrors.NewBadRequest(fmt.Sprintf("expected ClusterTemplate but got a %T", obj))
}
deletionAllowed, err := isDeletionAllowed(ctx, v.Client, template.Labels)
if err != nil {
return nil, fmt.Errorf("failed to check if the ClusterTemplate %s/%s is allowed to be deleted", template.Namespace, template.Name)
}
if !deletionAllowed {
return nil, ErrTemplateDeletionForbidden
}

managedClusters := &v1alpha1.ManagedClusterList{}
listOptions := client.ListOptions{
FieldSelector: fields.SelectorFromSet(fields.Set{TemplateKey: template.Name}),
Limit: 1,
}
err := v.Client.List(ctx, managedClusters, &listOptions)
err = v.Client.List(ctx, managedClusters, &listOptions)
if err != nil {
return nil, err
}
Expand All @@ -95,6 +109,8 @@ func (*ClusterTemplateValidator) Default(_ context.Context, _ runtime.Object) er

type ServiceTemplateValidator struct {
client.Client

ServiceAccount string
}

func (in *ServiceTemplateValidator) SetupWebhookWithManager(mgr ctrl.Manager) error {
Expand Down Expand Up @@ -122,7 +138,18 @@ func (*ServiceTemplateValidator) ValidateUpdate(_ context.Context, _ runtime.Obj
}

// ValidateDelete implements webhook.Validator so a webhook will be registered for the type.
func (*ServiceTemplateValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
func (v *ServiceTemplateValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
template, ok := obj.(*v1alpha1.ServiceTemplate)
if !ok {
return admission.Warnings{"Wrong object"}, apierrors.NewBadRequest(fmt.Sprintf("expected ServiceTemplate but got a %T", obj))
}
deletionAllowed, err := isDeletionAllowed(ctx, v.Client, template.Labels)
if err != nil {
return nil, fmt.Errorf("failed to check if the ServiceTemplate %s/%s is allowed to be deleted", template.Namespace, template.Name)
}
if !deletionAllowed {
return nil, ErrTemplateDeletionForbidden
}
return nil, nil
}

Expand All @@ -133,6 +160,8 @@ func (*ServiceTemplateValidator) Default(_ context.Context, _ runtime.Object) er

type ProviderTemplateValidator struct {
client.Client

ServiceAccount string
}

func (in *ProviderTemplateValidator) SetupWebhookWithManager(mgr ctrl.Manager) error {
Expand Down Expand Up @@ -160,7 +189,18 @@ func (*ProviderTemplateValidator) ValidateUpdate(_ context.Context, _ runtime.Ob
}

// ValidateDelete implements webhook.Validator so a webhook will be registered for the type.
func (*ProviderTemplateValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
func (v *ProviderTemplateValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
template, ok := obj.(*v1alpha1.ServiceTemplate)
if !ok {
return admission.Warnings{"Wrong object"}, apierrors.NewBadRequest(fmt.Sprintf("expected ProviderTemplate but got a %T", obj))
}
deletionAllowed, err := isDeletionAllowed(ctx, v.Client, template.Labels)
if err != nil {
return nil, fmt.Errorf("failed to check if the ProviderTemplate %s is allowed to be deleted", template.Name)
}
if !deletionAllowed {
return nil, ErrTemplateDeletionForbidden
}
return nil, nil
}

Expand Down Expand Up @@ -188,3 +228,23 @@ func SetupTemplateIndex(ctx context.Context, mgr ctrl.Manager) error {

return nil
}

func isDeletionAllowed(ctx context.Context, cl client.Client, labels map[string]string) (bool, error) {
if labels == nil || labels[v1alpha1.HMCManagedLabelKey] != v1alpha1.HMCManagedLabelValue {
return true, nil
}
tmList := &v1alpha1.TemplateManagementList{}
err := cl.List(ctx, tmList)
if err != nil {
return false, err
}
if len(tmList.Items) == 0 {
return true, nil
}
req, err := admission.RequestFromContext(ctx)
if err != nil {
return false, err
}
reqUserInfo := newUserInfo(req)
return reqUserInfo.ServiceAccountIsEqual(os.Getenv(serviceAccountEnvName)), nil
}
49 changes: 47 additions & 2 deletions internal/webhook/template_webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ package webhook

import (
"context"
"fmt"
"os"
"testing"

. "github.com/onsi/gomega"
admissionv1 "k8s.io/api/admission/v1"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
Expand All @@ -35,10 +40,13 @@ func TestClusterTemplateValidateDelete(t *testing.T) {
tpl := template.NewClusterTemplate(template.WithName("testTemplateFail"))
tplTest := template.NewClusterTemplate(template.WithName("testTemplate"))

hmcServiceAccountName := "hmc-controller-manager"

tests := []struct {
name string
template *v1alpha1.ClusterTemplate
existingObjects []runtime.Object
userInfo authenticationv1.UserInfo
err string
warnings admission.Warnings
}{
Expand All @@ -49,6 +57,30 @@ func TestClusterTemplateValidateDelete(t *testing.T) {
warnings: admission.Warnings{"The ClusterTemplate object can't be removed if ManagedCluster objects referencing it still exist"},
err: "template deletion is forbidden",
},
{
name: "should fail if the template is managed by HMC and the user triggered the deletion",
template: template.NewClusterTemplate(template.ManagedByHMC()),
// TODO: use test/templatemanagement
existingObjects: []runtime.Object{&v1alpha1.TemplateManagement{ObjectMeta: metav1.ObjectMeta{Name: ""}}},
err: "template deletion is forbidden",
},
{
name: "should succeed if the template is managed by HMC and the controller triggered the deletion",
template: template.NewClusterTemplate(template.ManagedByHMC()),
userInfo: authenticationv1.UserInfo{Username: fmt.Sprintf("system:serviceaccount:hmc-system:%s", hmcServiceAccountName)},
// TODO: use test/templatemanagement
existingObjects: []runtime.Object{&v1alpha1.TemplateManagement{ObjectMeta: metav1.ObjectMeta{Name: ""}}},
},
{
name: "should succeed if the template is not managed by HMC",
template: tpl,
// TODO: use test/templatemanagement,
existingObjects: []runtime.Object{&v1alpha1.TemplateManagement{ObjectMeta: metav1.ObjectMeta{Name: ""}}},
},
{
name: "should succeed if the template is managed by HMC but no TemplateManagement object found",
template: template.NewClusterTemplate(template.ManagedByHMC()),
},
{
name: "should be OK because of a different cluster",
template: tpl,
Expand All @@ -66,10 +98,23 @@ func TestClusterTemplateValidateDelete(t *testing.T) {
c := fake.NewClientBuilder().
WithScheme(scheme.Scheme).
WithRuntimeObjects(tt.existingObjects...).
WithIndex(tt.existingObjects[0], TemplateKey, ExtractTemplateName).
WithIndex(&v1alpha1.ManagedCluster{}, TemplateKey, ExtractTemplateName).
Build()
validator := &ClusterTemplateValidator{Client: c}
warn, err := validator.ValidateDelete(ctx, tt.template)

err := os.Setenv(serviceAccountEnvName, hmcServiceAccountName)
g.Expect(err).To(Succeed())
defer func() {
err = os.Unsetenv(serviceAccountEnvName)
g.Expect(err).To(Succeed())
}()

req := admission.Request{
AdmissionRequest: admissionv1.AdmissionRequest{
UserInfo: tt.userInfo,
},
}
warn, err := validator.ValidateDelete(admission.NewContextWithRequest(ctx, req), tt.template)
if tt.err != "" {
g.Expect(err).To(HaveOccurred())
if err.Error() != tt.err {
Expand Down
36 changes: 36 additions & 0 deletions internal/webhook/userinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2024
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package webhook

import (
"strings"

"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

type userInfo struct {
Username string
}

func newUserInfo(req admission.Request) userInfo {
return userInfo{
Username: req.UserInfo.Username,
}
}

func (i *userInfo) ServiceAccountIsEqual(serviceAccount string) bool {
return strings.HasPrefix(i.Username, "system:serviceaccount:") &&
strings.HasSuffix(i.Username, ":"+serviceAccount)
}
2 changes: 2 additions & 0 deletions templates/provider/hmc/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ spec:
env:
- name: KUBERNETES_CLUSTER_DOMAIN
value: {{ quote .Values.kubernetesClusterDomain }}
- name: SERVICE_ACCOUNT
value: {{ include "hmc.fullname" . }}-controller-manager
image: {{ .Values.image.repository }}:{{ .Values.image.tag
| default .Chart.AppVersion }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
Expand Down
9 changes: 9 additions & 0 deletions test/objects/template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ func WithNamespace(namespace string) Opt {
}
}

func ManagedByHMC() Opt {
return func(t *Template) {
if t.Labels == nil {
t.Labels = make(map[string]string)
}
t.Labels[v1alpha1.HMCManagedLabelKey] = v1alpha1.HMCManagedLabelValue
}
}

func WithHelmSpec(helmSpec v1alpha1.HelmSpec) Opt {
return func(t *Template) {
t.Spec.Helm = helmSpec
Expand Down

0 comments on commit 176cb64

Please sign in to comment.