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 }