diff --git a/.github/workflows/build-docker.yaml b/.github/workflows/build-docker.yaml index c023409..4b8ac9c 100644 --- a/.github/workflows/build-docker.yaml +++ b/.github/workflows/build-docker.yaml @@ -1,4 +1,4 @@ -name: Build and Push Docker Images +name: Build, Test, and Push Docker Images on: workflow_dispatch: {} @@ -6,11 +6,38 @@ on: branches: - main paths: - - .github/workflows/build-docker.yaml - - Dockerfile + - "**/*.go" + - "go.mod" + - "go.sum" + - "Dockerfile" + - ".github/workflows/build-docker.yaml" + pull_request: + branches: + - main + paths: + - "**/*.go" + - "go.mod" + - "go.sum" + - "Dockerfile" + - ".github/workflows/build-docker.yaml" jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21" + + - name: Run tests + run: go test -v ./... + build-and-push: + needs: test runs-on: ubuntu-latest permissions: contents: read @@ -38,13 +65,24 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Generate version + id: version + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "VERSION=$(date +'%Y.%m.%d')-${GITHUB_SHA::8}" >> $GITHUB_OUTPUT + fi + - name: Build and Push - if: github.ref != 'refs/heads/master' uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5 with: context: . file: Dockerfile - platforms: linux/amd64 - push: true + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} tags: | ghcr.io/xunholy/kustomize-mutating-webhook:latest + ghcr.io/xunholy/kustomize-mutating-webhook:${{ steps.version.outputs.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 0dbfbde..0325d35 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,78 @@ kubectl apply -k kubernetes/static You can verify the correct values are being collected by either using the `debug` log level which outputs the values on start-up, alternatively you may also verify by inspecting a Kustomization resource that has been mutated. +## Testing and Benchmarking + +This project includes unit tests and benchmarks to ensure reliability and performance. Here's how to run them and interpret the results: + +### Running Tests + +To run the unit tests, use the following command in the project root: + +```bash +go test -v ./... +``` + +This will run all tests and provide verbose output. A successful test run will show "PASS" for each test case. + +### Running Benchmarks + +To run the benchmarks, use: + +```bash +go test -bench=. -benchmem +``` + +This command runs all benchmarks and includes memory allocation statistics. + +### Interpreting Results + +#### Test Results + +After running the tests, you should see output similar to: + +```log +=== RUN TestMutatingWebhook +=== RUN TestMutatingWebhook/Add_postBuild_and_substitute +[... more test output ...] +PASS +ok github.com/xunholy/fluxcd-mutating-webhook 0.015s +``` + +- "PASS" indicates all tests have passed successfully. +- The time at the end (0.015s in this example) shows how long the tests took to run. + +#### Benchmark Results + +Benchmark results will look something like this: + +```log +4:47PM INF Request details Kind=Kustomization Name= Namespace= Resource= UID= + 25410 41239 ns/op +PASS +ok github.com/xunholy/fluxcd-mutating-webhook 1.535s +``` + +Here's how to interpret these results: + +- The first line shows a log output from the benchmark run. +- "25410" is the number of iterations the benchmark ran. +- "41239 ns/op" means each operation took an average of 41,239 nanoseconds (about 0.04 milliseconds). +- "PASS" indicates the benchmark completed successfully. +- "1.535s" is the total time taken for all benchmark runs. + +### Importance of Testing and Benchmarking + +Regular testing and benchmarking are crucial for several reasons: + +1. **Reliability**: Tests ensure that the webhook behaves correctly under various scenarios. +2. **Performance Monitoring**: Benchmarks help track the webhook's performance over time, allowing us to detect and address any performance regressions. +3. **Optimization**: Benchmark results can guide optimization efforts by identifying slow operations. +4. **Confidence in Changes**: Running tests and benchmarks before and after changes helps ensure that modifications don't introduce bugs or performance issues. + +We encourage contributors to run tests and benchmarks locally before submitting pull requests, and to include new tests for any added functionality. + + ## License Distributed under the Apache 2.0 License. See [LICENSE](./LICENSE) for more information. diff --git a/go.mod b/go.mod index 088a6cc..d88ea10 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,32 @@ module github.com/xunholy/fluxcd-mutating-webhook go 1.21.1 require ( + github.com/fsnotify/fsnotify v1.7.0 + github.com/go-chi/chi/v5 v5.1.0 + github.com/json-iterator/go v1.1.12 github.com/rs/zerolog v1.31.0 + github.com/stretchr/testify v1.8.4 + golang.org/x/time v0.3.0 k8s.io/api v0.29.0 k8s.io/apimachinery v0.29.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.110.1 // indirect k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect diff --git a/go.sum b/go.sum index 64b1813..6e845a1 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -73,6 +77,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/kubernetes/static/deployment.yaml b/kubernetes/static/deployment.yaml index 9194321..e1341f8 100644 --- a/kubernetes/static/deployment.yaml +++ b/kubernetes/static/deployment.yaml @@ -11,6 +11,11 @@ spec: selector: matchLabels: &labels app: *name + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 template: metadata: labels: @@ -25,33 +30,67 @@ spec: seccompProfile: type: RuntimeDefault containers: - - name: webhook - image: ghcr.io/xunholy/kustomize-mutating-webhook:latest - imagePullPolicy: Always - env: - - name: LOG_LEVEL - value: info - ports: - - containerPort: 8443 - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsUser: 1000 - runAsGroup: 1000 - capabilities: - drop: - - ALL - volumeMounts: + - name: webhook + image: ghcr.io/xunholy/kustomize-mutating-webhook:latest + imagePullPolicy: Always + env: + - name: LOG_LEVEL + value: info + - name: RATE_LIMIT + value: "100" + ports: + - containerPort: 8443 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsUser: 1000 + runAsGroup: 1000 + capabilities: + drop: + - ALL + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + readinessProbe: + httpGet: + path: /ready + port: 8443 + scheme: HTTPS + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8443 + scheme: HTTPS + initialDelaySeconds: 15 + periodSeconds: 20 + volumeMounts: + - name: webhook-certs + mountPath: /etc/webhook/certs + readOnly: true + - name: cluster-config + mountPath: /etc/config + readOnly: true + volumes: - name: webhook-certs - mountPath: /etc/webhook/certs - readOnly: true + secret: + secretName: kustomize-mutating-webhook-tls - name: cluster-config - mountPath: /etc/config - readOnly: true - volumes: - - name: webhook-certs - secret: - secretName: kustomize-mutating-webhook-tls - - name: cluster-config - configMap: - name: cluster-config + configMap: + name: cluster-config +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: kustomize-mutating-webhook-pdb + namespace: flux-system +spec: + minAvailable: 2 + selector: + matchLabels: + app: kustomize-mutating-webhook diff --git a/kubernetes/static/mutating-webhook-configuration.yaml b/kubernetes/static/mutating-webhook-configuration.yaml index a1b4012..2e805e8 100644 --- a/kubernetes/static/mutating-webhook-configuration.yaml +++ b/kubernetes/static/mutating-webhook-configuration.yaml @@ -6,30 +6,33 @@ metadata: namespace: flux-system annotations: cert-manager.io/inject-ca-from: flux-system/kustomize-mutating-webhook + labels: + app: kustomize-mutating-webhook webhooks: -- name: kustomize-mutating-webhook.xunholy.com - admissionReviewVersions: ["v1"] - failurePolicy: Fail - matchPolicy: Equivalent - # TODO: Determine if flux-system should be ignored - namespaceSelector: - matchExpressions: - - key: kubernetes.io/metadata.name - operator: NotIn - values: ["flux-system"] - objectSelector: {} - reinvocationPolicy: Never - clientConfig: - service: - name: kustomize-mutating-webhook - namespace: flux-system - path: /mutate - port: 8443 - rules: - - apiGroups: ["kustomize.toolkit.fluxcd.io"] - apiVersions: ["v1"] - operations: ["CREATE", "UPDATE"] - resources: ["kustomizations"] - scope: '*' - sideEffects: None - timeoutSeconds: 10 + - name: kustomize-mutating-webhook.xunholy.com + admissionReviewVersions: ["v1"] + # TODO: Set Ignore if issues persist and kustomization has already been patched + failurePolicy: Fail + matchPolicy: Equivalent + # NOTE: Expected behaviour is that flux-system contains required secrets which can be directly added and not required to be patched. + namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: NotIn + values: ["flux-system"] + objectSelector: {} + reinvocationPolicy: Never + clientConfig: + service: + name: kustomize-mutating-webhook + namespace: flux-system + path: /mutate + port: 8443 + rules: + - apiGroups: ["kustomize.toolkit.fluxcd.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["kustomizations"] + scope: "*" + sideEffects: None + timeoutSeconds: 30 diff --git a/main.go b/main.go index 4042236..4408d49 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,125 @@ package main import ( + "context" + "crypto/tls" "encoding/json" + "errors" + "fmt" "net/http" "os" + "os/signal" "path/filepath" + "strconv" "strings" - + "sync" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + jsoniter "github.com/json-iterator/go" "github.com/rs/zerolog" log "github.com/rs/zerolog/log" - + "golang.org/x/time/rate" v1 "k8s.io/api/admission/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -var appConfig map[string]string +const ( + defaultServerAddress = ":8443" + defaultCertFile = "/etc/webhook/certs/tls.crt" + defaultKeyFile = "/etc/webhook/certs/tls.key" + defaultConfigDir = "/etc/config" + defaultLogLevel = "info" + defaultRateLimit = 100 +) + +var ( + appConfig map[string]string + errConfigNotFound = errors.New("configuration not found") +) + +type CertWatcher struct { + certFile string + keyFile string + cert *tls.Certificate + mu sync.RWMutex + watcher *fsnotify.Watcher + done chan struct{} +} + +func NewCertWatcher(certFile, keyFile string) (*CertWatcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("failed to create file watcher: %w", err) + } + + cw := &CertWatcher{ + certFile: certFile, + keyFile: keyFile, + watcher: watcher, + done: make(chan struct{}), + } + if err := cw.loadCertificate(); err != nil { + return nil, fmt.Errorf("failed to load initial certificate: %w", err) + } + return cw, nil +} + +func (cw *CertWatcher) loadCertificate() error { + cert, err := tls.LoadX509KeyPair(cw.certFile, cw.keyFile) + if err != nil { + return fmt.Errorf("failed to load key pair: %w", err) + } + cw.mu.Lock() + cw.cert = &cert + cw.mu.Unlock() + return nil +} + +func (cw *CertWatcher) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + cw.mu.RLock() + defer cw.mu.RUnlock() + return cw.cert, nil +} + +func (cw *CertWatcher) Watch() error { + if err := cw.watcher.Add(filepath.Dir(cw.certFile)); err != nil { + return fmt.Errorf("failed to add directory to watcher: %w", err) + } + + for { + select { + case event, ok := <-cw.watcher.Events: + if !ok { + return errors.New("watcher channel closed") + } + if event.Op&fsnotify.Write == fsnotify.Write { + log.Info().Msg("Certificate files modified. Reloading...") + if err := cw.loadCertificate(); err != nil { + log.Error().Err(err).Msg("Failed to reload certificate") + } else { + log.Info().Msg("Certificate reloaded successfully") + } + } + case err, ok := <-cw.watcher.Errors: + if !ok { + return errors.New("watcher error channel closed") + } + log.Error().Err(err).Msg("Error watching certificate files") + case <-cw.done: + return nil + } + } +} + +func (cw *CertWatcher) Stop() { + close(cw.done) + cw.watcher.Close() +} func init() { // Set up logging to console @@ -25,63 +129,77 @@ func init() { consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: zerolog.TimeFieldFormat, NoColor: false} log.Logger = log.Output(consoleWriter) - // Initialize log level to Info as default - var level zerolog.Level = zerolog.InfoLevel - - // Determine log level from environment variable - if logLevel, ok := os.LookupEnv("LOG_LEVEL"); ok { - var err error - level, err = zerolog.ParseLevel(logLevel) - if err != nil { - log.Warn().Msgf("Invalid log level '%s'. Falling back to '%s'", logLevel, level) - } + // Set log level + logLevel := os.Getenv("LOG_LEVEL") + if logLevel == "" { + logLevel = defaultLogLevel + } + level, err := zerolog.ParseLevel(logLevel) + if err != nil { + level = zerolog.InfoLevel } // Set the global log level zerolog.SetGlobalLevel(level) - log.Info().Msgf("Log level set to '%s'", level.String()) } -func readConfigMap(directory string) map[string]string { +func readConfigMap(directory string) (map[string]string, error) { config := make(map[string]string) - files, err := os.ReadDir(directory) if err != nil { - log.Error().Err(err).Msg("Error reading directory") + return nil, fmt.Errorf("error reading directory: %w", err) } for _, file := range files { - fileName := file.Name() - - if file.IsDir() || strings.HasPrefix(fileName, ".") { - // Skip Directories + if file.IsDir() || strings.HasPrefix(file.Name(), ".") { continue } - fullPath := filepath.Join(directory, fileName) + fullPath := filepath.Join(directory, file.Name()) value, err := os.ReadFile(fullPath) if err != nil { - log.Error().Err(err).Msgf("Error reading file %s", fullPath) - continue + return nil, fmt.Errorf("error reading file %s: %w", fullPath, err) } - config[fileName] = string(value) + config[file.Name()] = string(value) } - return config + + if len(config) == 0 { + return nil, errConfigNotFound + } + + return config, nil } func handleMutate(w http.ResponseWriter, r *http.Request) { var admissionReviewReq v1.AdmissionReview - // Decode the incoming AdmissionReview - decoder := json.NewDecoder(r.Body) - if err := decoder.Decode(&admissionReviewReq); err != nil { + if err := jsoniter.NewDecoder(r.Body).Decode(&admissionReviewReq); err != nil { log.Error().Err(err).Msg("Failed to decode AdmissionReview request") http.Error(w, "Could not decode request", http.StatusBadRequest) return } - // Unmarshal Object into unstructured.Unstructured + // Create a default response that allows the admission request + admissionResponse := v1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + }, + Response: &v1.AdmissionResponse{ + UID: admissionReviewReq.Request.UID, + Allowed: true, + }, + } + + // Only mutate Kustomization resources + // This allows other resources to pass through without modification + if admissionReviewReq.Request.Kind.Kind != "Kustomization" { + log.Info().Msgf("Skipping mutation for non-Kustomization resource: %s", admissionReviewReq.Request.Kind.Kind) + respondWithAdmissionReview(w, admissionResponse) + return + } + var obj unstructured.Unstructured if err := json.Unmarshal(admissionReviewReq.Request.Object.Raw, &obj); err != nil { log.Error().Err(err).Msg("Failed to unmarshal Object") @@ -89,26 +207,12 @@ func handleMutate(w http.ResponseWriter, r *http.Request) { return } - // Check if the resource is being deleted + // Allow deletions to proceed without modification if admissionReviewReq.Request.Operation == v1.Delete || !obj.GetDeletionTimestamp().IsZero() { - // Allow the deletion to proceed without modification - admissionResponse := v1.AdmissionReview{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "admission.k8s.io/v1", - Kind: "AdmissionReview", - }, - Response: &v1.AdmissionResponse{ - UID: admissionReviewReq.Request.UID, - Allowed: true, - }, - } - - // Send the response to avoid locking deletion respondWithAdmissionReview(w, admissionResponse) return } - // Log details of the request log.Info(). Str("UID", string(admissionReviewReq.Request.UID)). Str("Kind", admissionReviewReq.Request.Kind.Kind). @@ -117,7 +221,7 @@ func handleMutate(w http.ResponseWriter, r *http.Request) { Str("Namespace", admissionReviewReq.Request.Namespace). Msg("Request details") - // Process the AdmissionReview request and create a patch + // Create patch for Kustomization resources var patch []map[string]interface{} // Ensure /spec/postBuild exists @@ -147,28 +251,17 @@ func handleMutate(w http.ResponseWriter, r *http.Request) { "value": value, }) } - patchBytes, _ := json.Marshal(patch) - // Log the mutation details - log.Debug(). - Str("Patch", string(patchBytes)). - Msg("Applying mutation to resource") + // Apply the patch if any modifications were made + if len(patch) > 0 { + patchBytes, _ := json.Marshal(patch) + admissionResponse.Response.Patch = patchBytes + pt := v1.PatchTypeJSONPatch + admissionResponse.Response.PatchType = &pt - // Create the AdmissionReview response - admissionResponse := v1.AdmissionReview{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "admission.k8s.io/v1", - Kind: "AdmissionReview", - }, - Response: &v1.AdmissionResponse{ - UID: admissionReviewReq.Request.UID, - Allowed: true, - Patch: patchBytes, - PatchType: func() *v1.PatchType { - pt := v1.PatchTypeJSONPatch - return &pt - }(), - }, + log.Debug(). + Str("Patch", string(patchBytes)). + Msg("Applying mutation to resource") } respondWithAdmissionReview(w, admissionResponse) @@ -177,8 +270,7 @@ func handleMutate(w http.ResponseWriter, r *http.Request) { // Encodes and sends the AdmissionReview response func respondWithAdmissionReview(w http.ResponseWriter, admissionResponse v1.AdmissionReview) { w.Header().Set("Content-Type", "application/json") - encoder := json.NewEncoder(w) - if err := encoder.Encode(admissionResponse); err != nil { + if err := json.NewEncoder(w).Encode(admissionResponse); err != nil { log.Error().Err(err).Msg("Failed to encode AdmissionReview response") http.Error(w, "Could not encode response", http.StatusInternalServerError) } @@ -191,23 +283,128 @@ func escapeJsonPointer(value string) string { return value } -func main() { - // Log the starting of the server - log.Info().Msg("Starting the webhook server on port 8443") +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func handleReady(w http.ResponseWriter, r *http.Request) { + if len(appConfig) == 0 { + http.Error(w, "Configuration not loaded", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ready")) +} - // Load the ConfigMap with cluster config - configMapPath := "/etc/config" - appConfig = readConfigMap(configMapPath) +func rateLimitMiddleware(r rate.Limit, b int) func(http.Handler) http.Handler { + limiter := rate.NewLimiter(r, b) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !limiter.Allow() { + http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func main() { + serverAddress := getEnv("SERVER_ADDRESS", defaultServerAddress) + certFile := getEnv("CERT_FILE", defaultCertFile) + keyFile := getEnv("KEY_FILE", defaultKeyFile) + configDir := getEnv("CONFIG_DIR", defaultConfigDir) + rateLimit := getEnvAsInt("RATE_LIMIT", defaultRateLimit) + + var err error + appConfig, err = readConfigMap(configDir) + if err != nil { + if errors.Is(err, errConfigNotFound) { + log.Warn().Msg("No configuration found, starting with empty config") + } else { + log.Fatal().Err(err).Msg("Failed to read configuration") + } + } log.Debug().Msg("Loaded appConfig:") for key, value := range appConfig { log.Debug().Msgf("Config - Key: %s, Value: %s", key, value) } - // Set up HTTP handler and server - http.HandleFunc("/mutate", handleMutate) - if err := http.ListenAndServeTLS(":8443", "/etc/webhook/certs/tls.crt", "/etc/webhook/certs/tls.key", nil); err != nil { - log.Fatal().Err(err).Msg("Failed to start server") - os.Exit(1) + // Initialize certificate watcher + certWatcher, err := NewCertWatcher(certFile, keyFile) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize certificate watcher") + } + + go func() { + if err := certWatcher.Watch(); err != nil { + log.Error().Err(err).Msg("Certificate watcher error") + } + }() + + // Initialize router + r := chi.NewRouter() + + // Middleware + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(rateLimitMiddleware(rate.Limit(rateLimit), rateLimit)) + + // Routes + r.Post("/mutate", handleMutate) + r.Get("/health", handleHealth) + r.Get("/ready", handleReady) + + // Initialize server + server := &http.Server{ + Addr: serverAddress, + Handler: r, + TLSConfig: &tls.Config{ + GetCertificate: certWatcher.GetCertificate, + }, + } + + // Start server + go func() { + log.Info().Msgf("Starting the webhook server on %s", server.Addr) + if err := server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + log.Fatal().Err(err).Msg("Failed to start server") + } + }() + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Info().Msg("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + certWatcher.Stop() + + if err := server.Shutdown(ctx); err != nil { + log.Fatal().Err(err).Msg("Server forced to shutdown") + } + + log.Info().Msg("Server exiting") +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func getEnvAsInt(key string, fallback int) int { + strValue := getEnv(key, "") + if value, err := strconv.Atoi(strValue); err == nil { + return value } + return fallback } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..3412da8 --- /dev/null +++ b/main_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestMutatingWebhook(t *testing.T) { + // Set up test config + appConfig = map[string]string{ + "TEST_KEY": "test_value", + } + + tests := []struct { + name string + inputObject map[string]interface{} + kind metav1.GroupVersionKind + expectedPatch []map[string]interface{} + expectedAllowed bool + }{ + { + name: "Add postBuild and substitute", + inputObject: map[string]interface{}{ + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "Kustomization", + "metadata": map[string]interface{}{ + "name": "test-kustomization", + "namespace": "default", + }, + "spec": map[string]interface{}{}, + }, + kind: metav1.GroupVersionKind{ + Group: "kustomize.toolkit.fluxcd.io", + Version: "v1", + Kind: "Kustomization", + }, + expectedPatch: []map[string]interface{}{ + { + "op": "add", + "path": "/spec/postBuild", + "value": map[string]interface{}{}, + }, + { + "op": "add", + "path": "/spec/postBuild/substitute", + "value": map[string]interface{}{}, + }, + { + "op": "add", + "path": "/spec/postBuild/substitute/TEST_KEY", + "value": "test_value", + }, + }, + expectedAllowed: true, + }, + { + name: "No mutation for non-Kustomization resource", + inputObject: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-configmap", + "namespace": "default", + }, + "data": map[string]interface{}{}, + }, + kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + expectedPatch: nil, + expectedAllowed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create admission review request + objBytes, err := json.Marshal(tt.inputObject) + require.NoError(t, err) + + ar := admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: objBytes}, + Kind: tt.kind, + Operation: admissionv1.Create, + }, + } + + arBytes, err := json.Marshal(ar) + require.NoError(t, err) + + // Create request + req, err := http.NewRequest("POST", "/mutate", bytes.NewBuffer(arBytes)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + // Create response recorder + rr := httptest.NewRecorder() + + // Call the handler + handleMutate(rr, req) + + // Check the status code + assert.Equal(t, http.StatusOK, rr.Code) + + // Parse the response + var respAR admissionv1.AdmissionReview + err = json.Unmarshal(rr.Body.Bytes(), &respAR) + require.NoError(t, err) + + // Check the response + assert.Equal(t, tt.expectedAllowed, respAR.Response.Allowed) + + if tt.expectedPatch != nil { + var patch []map[string]interface{} + err = json.Unmarshal(respAR.Response.Patch, &patch) + require.NoError(t, err) + assert.Equal(t, tt.expectedPatch, patch) + } else { + assert.Nil(t, respAR.Response.Patch) + } + + t.Logf("Test case: %s", tt.name) + t.Logf("Input object: %v", tt.inputObject) + t.Logf("Response: %v", respAR.Response) + }) + } +} + +func BenchmarkMutatingWebhook(b *testing.B) { + // Set up test config + appConfig = map[string]string{ + "TEST_KEY": "test_value", + } + + inputObject := map[string]interface{}{ + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "Kustomization", + "metadata": map[string]interface{}{ + "name": "test-kustomization", + "namespace": "default", + }, + "spec": map[string]interface{}{}, + } + + objBytes, _ := json.Marshal(inputObject) + + ar := admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: objBytes}, + Kind: metav1.GroupVersionKind{ + Group: "kustomize.toolkit.fluxcd.io", + Version: "v1", + Kind: "Kustomization", + }, + Operation: admissionv1.Create, + }, + } + + arBytes, _ := json.Marshal(ar) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req, _ := http.NewRequest("POST", "/mutate", bytes.NewBuffer(arBytes)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handleMutate(rr, req) + } +}