github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/webhook-server/helpers.go (about)

     1  /*
     2  Copyright 2022 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package main
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	cryptorand "crypto/rand"
    23  	"crypto/rsa"
    24  	"crypto/x509"
    25  	"crypto/x509/pkix"
    26  	"encoding/json"
    27  	"encoding/pem"
    28  	"fmt"
    29  	stdio "io"
    30  	"math/big"
    31  	"strings"
    32  	"time"
    33  
    34  	"github.com/sirupsen/logrus"
    35  	admregistration "k8s.io/api/admissionregistration/v1"
    36  	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    37  	"k8s.io/apimachinery/pkg/types"
    38  	ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
    39  	"sigs.k8s.io/prow/pkg/config"
    40  	"sigs.k8s.io/prow/pkg/io"
    41  	"sigs.k8s.io/prow/pkg/plank"
    42  )
    43  
    44  const (
    45  	org                          = "prow.k8s.io"
    46  	defaultNamespace             = "default"
    47  	prowjobAdmissionServiceName  = "prowjob-admission-webhook"
    48  	prowJobMutatingWebhookName   = "prow-job-mutating-webhook-config.prow.k8s.io"
    49  	prowJobValidatingWebhookName = "prow-job-validating-webhook-config.prow.k8s.io"
    50  	mutatePath                   = "/mutate"
    51  	validatePath                 = "/validate"
    52  )
    53  
    54  // for unit testing purposes
    55  var genCertFunc = genCert
    56  
    57  func genCert(expiry int, dnsNames []string) (string, string, string, error) {
    58  	//https://gist.github.com/velotiotech/2e0cfd15043513d253cad7c9126d2026#file-initcontainer_main-go
    59  	var caPEM, serverCertPEM, serverPrivKeyPEM *bytes.Buffer
    60  	// CA config
    61  	ca := &x509.Certificate{
    62  		SerialNumber: big.NewInt(2020), //unique identifier for cert
    63  		Subject: pkix.Name{
    64  			Organization: []string{org},
    65  		},
    66  		NotBefore:             time.Now(),
    67  		NotAfter:              time.Now().AddDate(expiry, 0, 0),
    68  		IsCA:                  true,
    69  		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
    70  		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
    71  		BasicConstraintsValid: true,
    72  	}
    73  
    74  	// CA private key
    75  	caPrivKey, err := rsa.GenerateKey(cryptorand.Reader, 4096)
    76  	if err != nil {
    77  		return "", "", "", fmt.Errorf("error generating ca private key: %v", err)
    78  	}
    79  
    80  	// Self signed CA certificate
    81  	caBytes, err := x509.CreateCertificate(cryptorand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey)
    82  	if err != nil {
    83  		return "", "", "", fmt.Errorf("error generating signed ca certificate: %v", err)
    84  	}
    85  
    86  	// PEM encode CA cert
    87  	caPEM = new(bytes.Buffer)
    88  	err = pem.Encode(caPEM, &pem.Block{
    89  		Type:  "CERTIFICATE",
    90  		Bytes: caBytes,
    91  	})
    92  	if err != nil {
    93  		return "", "", "", fmt.Errorf("error encoding ca certificate: %v", err)
    94  	}
    95  
    96  	// server cert config
    97  	cert := &x509.Certificate{
    98  		DNSNames:     dnsNames,
    99  		SerialNumber: big.NewInt(1658), //unique identifier for cert
   100  		Subject: pkix.Name{
   101  			CommonName:   "admission-webhook-service.default.svc", //this field doesn't affect the server cert config
   102  			Organization: []string{org},
   103  		},
   104  		NotBefore:    time.Now(),
   105  		NotAfter:     time.Now().AddDate(expiry, 0, 0),
   106  		SubjectKeyId: []byte{1, 2, 3, 4, 6}, //unique identifier for cert
   107  		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
   108  		KeyUsage:     x509.KeyUsageDigitalSignature,
   109  	}
   110  
   111  	// server private key
   112  	serverPrivKey, err := rsa.GenerateKey(cryptorand.Reader, 4096)
   113  	if err != nil {
   114  		return "", "", "", fmt.Errorf("error generating server private key: %v", err)
   115  	}
   116  
   117  	// sign the server cert
   118  	serverCertBytes, err := x509.CreateCertificate(cryptorand.Reader, cert, ca, &serverPrivKey.PublicKey, caPrivKey)
   119  	if err != nil {
   120  		return "", "", "", fmt.Errorf("error generating signed server certificate: %v", err)
   121  	}
   122  
   123  	// PEM encode the  server cert and key
   124  	serverCertPEM = new(bytes.Buffer)
   125  	err = pem.Encode(serverCertPEM, &pem.Block{
   126  		Type:  "CERTIFICATE",
   127  		Bytes: serverCertBytes,
   128  	})
   129  	if err != nil {
   130  		return "", "", "", fmt.Errorf("error encoding server certificate: %v", err)
   131  	}
   132  
   133  	serverPrivKeyPEM = new(bytes.Buffer)
   134  	err = pem.Encode(serverPrivKeyPEM, &pem.Block{
   135  		Type:  "RSA PRIVATE KEY",
   136  		Bytes: x509.MarshalPKCS1PrivateKey(serverPrivKey),
   137  	})
   138  	if err != nil {
   139  		return "", "", "", fmt.Errorf("error encoding server private key: %v", err)
   140  	}
   141  
   142  	return serverCertPEM.String(), serverPrivKeyPEM.String(), caPEM.String(), nil
   143  
   144  }
   145  
   146  func isCertValid(cert string) error {
   147  	block, _ := pem.Decode([]byte(cert))
   148  	certificate, err := x509.ParseCertificate(block.Bytes)
   149  	if err != nil {
   150  		return err
   151  	}
   152  	if time.Now().After(certificate.NotAfter) {
   153  		return fmt.Errorf("certificated expired at %v", certificate.NotAfter)
   154  	}
   155  	return nil
   156  }
   157  
   158  func createSecret(client ClientInterface, ctx context.Context, clientoptions clientOptions) (string, string, string, error) {
   159  	if err := client.CreateSecret(ctx, clientoptions.secretID); err != nil {
   160  		return "", "", "", fmt.Errorf("unable to create secret %v", err)
   161  	}
   162  
   163  	serverCertPerm, serverPrivKey, caPem, err := updateSecret(client, ctx, clientoptions)
   164  	if err != nil {
   165  		return "", "", "", fmt.Errorf("unable to write secret value %v", err)
   166  	}
   167  	return serverCertPerm, serverPrivKey, caPem, nil
   168  }
   169  
   170  func updateSecret(client ClientInterface, ctx context.Context, clientoptions clientOptions) (string, string, string, error) {
   171  	serverCertPerm, serverPrivKey, caPem, secretData, err := genSecretData(clientoptions.expiryInYears, clientoptions.dnsNames.Strings())
   172  	if err != nil {
   173  		return "", "", "", err
   174  	}
   175  
   176  	if err := client.AddSecretVersion(ctx, clientoptions.secretID, secretData); err != nil {
   177  		return "", "", "", fmt.Errorf("unable to add secret version %v", err)
   178  	}
   179  
   180  	return serverCertPerm, serverPrivKey, caPem, nil
   181  }
   182  
   183  func genSecretData(expiry int, dns []string) (string, string, string, []byte, error) {
   184  	serverCertPerm, serverPrivKey, caPem, err := genCertFunc(expiry, dns)
   185  	if err != nil {
   186  		return "", "", "", nil, fmt.Errorf("could not generate ca credentials")
   187  	}
   188  	caSecrets := map[string]string{
   189  		certFile:     serverCertPerm,
   190  		privKeyFile:  serverPrivKey,
   191  		caBundleFile: caPem,
   192  	}
   193  	secretData, err := json.Marshal(caSecrets)
   194  
   195  	if err != nil {
   196  		return "", "", "", nil, fmt.Errorf("error unmarshalling CA cert secret data: %v", err)
   197  	}
   198  
   199  	return serverCertPerm, serverPrivKey, caPem, secretData, nil
   200  }
   201  
   202  func ensureValidatingWebhookConfig(ctx context.Context, caPem string, client ctrlruntimeclient.Client) error {
   203  	operations := []admregistration.OperationType{"CREATE", "UPDATE"}
   204  	scope := admregistration.ScopeType("*")
   205  	path := validatePath
   206  	sideEffects := admregistration.SideEffectClass("None")
   207  
   208  	validatingWebhookConfig := &admregistration.ValidatingWebhookConfiguration{
   209  		TypeMeta: v1.TypeMeta{
   210  			Kind:       "ValidatingWebhookConfiguration",
   211  			APIVersion: "admissionregistration.k8s.io/v1",
   212  		},
   213  		ObjectMeta: v1.ObjectMeta{
   214  			Name: prowJobValidatingWebhookName,
   215  		},
   216  		Webhooks: []admregistration.ValidatingWebhook{
   217  			{
   218  				Name: prowJobValidatingWebhookName,
   219  				ObjectSelector: &v1.LabelSelector{
   220  					MatchLabels: map[string]string{
   221  						"admission-webhook": "enabled", // for now till there is more confidence, ensures only prowjobs with this label are affected
   222  					},
   223  				},
   224  				Rules: []admregistration.RuleWithOperations{
   225  					{
   226  						Operations: operations,
   227  						Rule: admregistration.Rule{
   228  							APIGroups:   []string{"prow.k8s.io"},
   229  							APIVersions: []string{"v1"},
   230  							Resources:   []string{"prowjobs"},
   231  							Scope:       &scope,
   232  						},
   233  					},
   234  				},
   235  				ClientConfig: admregistration.WebhookClientConfig{
   236  					Service: &admregistration.ServiceReference{
   237  						Namespace: defaultNamespace,
   238  						Name:      prowjobAdmissionServiceName,
   239  						Path:      &path,
   240  					},
   241  					CABundle: []byte(caPem),
   242  				},
   243  				SideEffects:             &sideEffects,
   244  				AdmissionReviewVersions: []string{"v1"},
   245  			},
   246  		},
   247  	}
   248  
   249  	createOptions := &ctrlruntimeclient.CreateOptions{
   250  		FieldManager: "webhook-server", // indicates the configuration was created by the webhook server
   251  	}
   252  
   253  	err := client.Create(ctx, validatingWebhookConfig, createOptions)
   254  	if err != nil && strings.Contains(err.Error(), configAlreadyExistsError) {
   255  		logrus.Info("ValidatingWebhookConfiguration already exists, proceeding to patch")
   256  		if err := patchValidatingWebhookConfig(ctx, caPem, client); err != nil {
   257  			return fmt.Errorf("failed to patch validating webhook config: %w", err)
   258  		}
   259  	} else if err != nil {
   260  		return fmt.Errorf("failed to create validating webhook config: %w", err)
   261  	}
   262  
   263  	return nil
   264  }
   265  
   266  func patchValidatingWebhookConfig(ctx context.Context, caPem string, client ctrlruntimeclient.Client) error {
   267  	key := types.NamespacedName{
   268  		Namespace: defaultNamespace,
   269  		Name:      prowJobValidatingWebhookName,
   270  	}
   271  
   272  	patchOptions := &ctrlruntimeclient.PatchOptions{
   273  		FieldManager: "webhook-server",
   274  	}
   275  	var validatingWebhookConfig admregistration.ValidatingWebhookConfiguration
   276  	if err := client.Get(ctx, key, &validatingWebhookConfig); err != nil {
   277  		return fmt.Errorf("failed to get validating webhook config: %w", err)
   278  	}
   279  	oldValidatingWebhook := validatingWebhookConfig.DeepCopy()
   280  	validatingWebhookConfig.Webhooks[0].ClientConfig.CABundle = []byte(caPem)
   281  	if err := client.Patch(ctx, &validatingWebhookConfig, ctrlruntimeclient.MergeFrom(oldValidatingWebhook), patchOptions); err != nil {
   282  		return fmt.Errorf("failed to patch validating webhook config: %w", err)
   283  	}
   284  	return nil
   285  }
   286  
   287  func ensureMutatingWebhookConfig(ctx context.Context, caPem string, client ctrlruntimeclient.Client) error {
   288  	operations := []admregistration.OperationType{"CREATE"}
   289  	scope := admregistration.ScopeType("*")
   290  	path := mutatePath
   291  	sideEffects := admregistration.SideEffectClass("None")
   292  
   293  	mutatingWebhookConfig := &admregistration.MutatingWebhookConfiguration{
   294  		TypeMeta: v1.TypeMeta{
   295  			Kind:       "MutatingWebhookConfiguration",
   296  			APIVersion: "admissionregistration.k8s.io/v1",
   297  		},
   298  		ObjectMeta: v1.ObjectMeta{
   299  			Name: prowJobMutatingWebhookName,
   300  		},
   301  		Webhooks: []admregistration.MutatingWebhook{
   302  			{
   303  				Name: prowJobMutatingWebhookName,
   304  				ObjectSelector: &v1.LabelSelector{
   305  					MatchLabels: map[string]string{
   306  						"admission-webhook": "enabled", //for now till there is more confidence, ensures only prowjobs with this label are affected
   307  						"default-me":        "enabled", //for now till there is more confidence, ensures only prowjobs with this label are affected
   308  					},
   309  				},
   310  				Rules: []admregistration.RuleWithOperations{
   311  					{
   312  						Operations: operations,
   313  						Rule: admregistration.Rule{
   314  							APIGroups:   []string{"prow.k8s.io"},
   315  							APIVersions: []string{"v1"},
   316  							Resources:   []string{"prowjobs"},
   317  							Scope:       &scope,
   318  						},
   319  					},
   320  				},
   321  				ClientConfig: admregistration.WebhookClientConfig{
   322  					Service: &admregistration.ServiceReference{
   323  						Namespace: defaultNamespace,
   324  						Name:      prowjobAdmissionServiceName,
   325  						Path:      &path,
   326  					},
   327  					CABundle: []byte(caPem),
   328  				},
   329  				SideEffects:             &sideEffects,
   330  				AdmissionReviewVersions: []string{"v1"},
   331  			},
   332  		},
   333  	}
   334  
   335  	createOptions := &ctrlruntimeclient.CreateOptions{
   336  		FieldManager: "webhook-server",
   337  	}
   338  
   339  	err := client.Create(ctx, mutatingWebhookConfig, createOptions)
   340  	if err != nil && strings.Contains(err.Error(), configAlreadyExistsError) {
   341  		logrus.Info("MutatingWebhookConfiguration already exists, proceeding to patch")
   342  		if err := patchMutatingWebhookConfig(ctx, caPem, client); err != nil {
   343  			return fmt.Errorf("failed to patch mutating webhook config: %w", err)
   344  		}
   345  	} else if err != nil {
   346  		return fmt.Errorf("failed to create mutating webhook config: %w", err)
   347  	}
   348  
   349  	return nil
   350  }
   351  
   352  func patchMutatingWebhookConfig(ctx context.Context, caPem string, client ctrlruntimeclient.Client) error {
   353  	key := types.NamespacedName{
   354  		Namespace: defaultNamespace,
   355  		Name:      prowJobMutatingWebhookName,
   356  	}
   357  
   358  	patchOptions := &ctrlruntimeclient.PatchOptions{
   359  		FieldManager: "webhook-server",
   360  	}
   361  	var mutatingWebhookConfig admregistration.MutatingWebhookConfiguration
   362  	if err := client.Get(ctx, key, &mutatingWebhookConfig); err != nil {
   363  		return fmt.Errorf("failed to get mutating webhook config: %w", err)
   364  	}
   365  	oldMutatingWebhook := mutatingWebhookConfig.DeepCopy()
   366  	mutatingWebhookConfig.Webhooks[0].ClientConfig.CABundle = []byte(caPem)
   367  	if err := client.Patch(ctx, &mutatingWebhookConfig, ctrlruntimeclient.MergeFrom(oldMutatingWebhook), patchOptions); err != nil {
   368  		return fmt.Errorf("failed to patch mutating webhook config: %w", err)
   369  	}
   370  	return nil
   371  }
   372  
   373  // we would like both webhookconfigurations to exist at any given time so this function ensures both are present
   374  // and returns their caBundle contents
   375  func checkWebhooksExist(ctx context.Context, client ctrlruntimeclient.Client) (string, string, bool, error) {
   376  	var mutatingExists bool
   377  	var validatingExists bool
   378  	var mutatingWebhookConfig admregistration.MutatingWebhookConfiguration
   379  	var validatingWebhookConfig admregistration.ValidatingWebhookConfiguration
   380  	mutatingKey := types.NamespacedName{
   381  		Namespace: defaultNamespace,
   382  		Name:      prowJobMutatingWebhookName,
   383  	}
   384  	validatingKey := types.NamespacedName{
   385  		Namespace: defaultNamespace,
   386  		Name:      prowJobValidatingWebhookName,
   387  	}
   388  
   389  	err := client.Get(ctx, mutatingKey, &mutatingWebhookConfig)
   390  	if err != nil && strings.Contains(err.Error(), "not found") {
   391  		return "", "", false, nil
   392  	} else if err != nil {
   393  		return "", "", false, fmt.Errorf("error getting mutating webhook config %v", err)
   394  	}
   395  	mutatingExists = true
   396  
   397  	err = client.Get(ctx, validatingKey, &validatingWebhookConfig)
   398  	if err != nil && strings.Contains(err.Error(), "not found") {
   399  		return "", "", false, nil
   400  	} else if err != nil {
   401  		return "", "", false, fmt.Errorf("error getting validating webhook config %v", err)
   402  	}
   403  	validatingExists = true
   404  
   405  	if mutatingExists && validatingExists {
   406  		return string(mutatingWebhookConfig.Webhooks[0].ClientConfig.CABundle), string(validatingWebhookConfig.Webhooks[0].ClientConfig.CABundle), true, nil
   407  	}
   408  
   409  	return "", "", false, nil
   410  }
   411  
   412  func reconcileWebhooks(ctx context.Context, caPem string, cl ctrlruntimeclient.Client) error {
   413  	mutatingCAPem, validatingCAPem, exist, err := checkWebhooksExist(ctx, cl)
   414  	if err != nil {
   415  		return err
   416  	}
   417  	if exist && (validatingCAPem != caPem || mutatingCAPem != caPem) {
   418  		if err := patchValidatingWebhookConfig(ctx, caPem, cl); err != nil {
   419  			return fmt.Errorf("unable to patch ValidatingWebhookConfig %v", err)
   420  		}
   421  		if err := patchMutatingWebhookConfig(ctx, caPem, cl); err != nil {
   422  			return fmt.Errorf("unable to patch MutatingWebhookConfig %v", err)
   423  		}
   424  	} else if !exist {
   425  		if err = ensureValidatingWebhookConfig(ctx, caPem, cl); err != nil {
   426  			return fmt.Errorf("unable to generate ValidatingWebhookConfig %v", err)
   427  		}
   428  		if err = ensureMutatingWebhookConfig(ctx, caPem, cl); err != nil {
   429  			return fmt.Errorf("unable to generate MutatingWebhookConfig %v", err)
   430  		}
   431  	}
   432  	return nil
   433  }
   434  
   435  // this method runs on a go routine as a periodic task to continuously fetch the clusters in the config
   436  func (wa *webhookAgent) fetchClusters(d time.Duration, ctx context.Context, statuses *map[string]plank.ClusterStatus, configAgent *config.Agent) error {
   437  	ticker := time.NewTicker(d)
   438  	defer ticker.Stop()
   439  	cfg := configAgent.Config()
   440  	opener, err := io.NewOpener(context.Background(), wa.storage.GCSCredentialsFile, wa.storage.S3CredentialsFile)
   441  	if err != nil {
   442  		return err
   443  	}
   444  
   445  	for {
   446  		select {
   447  		case <-ctx.Done():
   448  			return nil
   449  		case <-ticker.C:
   450  			if location := cfg.Plank.BuildClusterStatusFile; location != "" {
   451  				reader, err := opener.Reader(context.Background(), location)
   452  				if err != nil {
   453  					if !io.IsNotExist(err) {
   454  						return fmt.Errorf("error opening build cluster status file for reading: %w", err)
   455  					}
   456  					logrus.Warnf("Build cluster status file location was specified, but could not be found: %v. This is expected when the location is first configured, before plank creates the file.", err)
   457  				} else {
   458  					defer reader.Close()
   459  					b, err := stdio.ReadAll(reader)
   460  					if err != nil {
   461  						return fmt.Errorf("error reading build cluster status file: %w", err)
   462  					}
   463  					var tempMap map[string]plank.ClusterStatus
   464  					if err := json.Unmarshal(b, &tempMap); err != nil {
   465  						return fmt.Errorf("error unmarshaling build cluster status file: %w", err)
   466  					}
   467  					wa.mu.Lock()
   468  					wa.statuses = tempMap
   469  					wa.mu.Unlock()
   470  				}
   471  			}
   472  		}
   473  	}
   474  }