├── dist ├── chart_icon.png └── chart │ ├── Chart.yaml │ └── README.md ├── .tool-versions ├── config ├── network-policy │ ├── kustomization.yaml │ ├── allow-metrics-traffic.yaml │ └── allow-webhook-traffic.yaml ├── webhook │ ├── kustomization.yaml │ ├── service.yaml │ ├── kustomizeconfig.yaml │ └── manifests.yaml ├── samples │ ├── kustomization.yaml │ ├── sample.yaml │ └── fqdn_v1alpha1_networkpolicy.yaml ├── certmanager │ ├── kustomization.yaml │ ├── kustomizeconfig.yaml │ ├── issuer.yaml │ ├── certificate-webhook.yaml │ └── certificate-metrics.yaml ├── default │ ├── manager_metrics_patch.yaml │ ├── metrics_service.yaml │ ├── cert_metrics_manager_patch.yaml │ ├── manager_webhook_patch.yaml │ └── kustomization.yaml ├── rbac │ ├── metrics_reader_role.yaml │ ├── service_account.yaml │ ├── metrics_auth_role_binding.yaml │ ├── metrics_auth_role.yaml │ ├── role_binding.yaml │ ├── leader_election_role_binding.yaml │ ├── leader_election_role.yaml │ ├── networkpolicy_admin_role.yaml │ ├── networkpolicy_viewer_role.yaml │ ├── role.yaml │ ├── networkpolicy_editor_role.yaml │ └── kustomization.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── prometheus │ ├── kustomization.yaml │ ├── monitor_tls_patch.yaml │ └── monitor.yaml └── crd │ ├── kustomizeconfig.yaml │ └── kustomization.yaml ├── .dockerignore ├── renovate.json ├── pkg ├── utils │ ├── map.go │ ├── network_policy.go │ ├── operation.go │ ├── operation_test.go │ └── network_policy_test.go └── network │ ├── resolver.go │ └── resolver_test.go ├── hack ├── deps │ ├── certmanager │ │ └── main.go │ └── prometheus │ │ └── main.go └── boilerplate.go.txt ├── test ├── utils │ ├── print.go │ ├── structs.go │ ├── kubectl.go │ ├── curlpod.go │ └── utils.go └── e2e │ └── e2e_suite_test.go ├── .github ├── workflows │ ├── test.yml │ ├── lint.yml │ ├── test-e2e.yml │ ├── test-chart.yml │ └── release.yml └── dependabot.yml ├── .gitignore ├── .devcontainer ├── devcontainer.json └── post-install.sh ├── PROJECT ├── .golangci.yml ├── internal ├── controller │ ├── network_policy_delete.go │ ├── network_policy_fqdn_status_test.go │ ├── network_policy_fqdn_status.go │ ├── network_policy_create.go │ ├── network_policy_delete_test.go │ ├── network_policy_create_test.go │ ├── suite_test.go │ └── network_policy_controller.go └── webhook │ └── v1alpha1 │ ├── network_policy_webhook_test.go │ ├── webhook_suite_test.go │ └── network_policy_webhook.go ├── api └── v1alpha1 │ ├── groupversion_info.go │ ├── network_policy_conditions.go │ ├── network_policy_types_test.go │ ├── network_policy_conditions_test.go │ ├── network_policy_funcs.go │ ├── zz_generated.deepcopy.go │ └── network_policy_types.go ├── Dockerfile ├── go.mod ├── cmd └── main.go ├── README.md └── LICENSE /dist/chart_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konsole-is/fqdn-controller/HEAD/dist/chart_icon.png -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | kubectl 1.34.0 2 | golang 1.25.3 3 | kind 0.30.0 4 | golangci-lint 2.5.0 5 | helm 3.19.0 6 | -------------------------------------------------------------------------------- /config/network-policy/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - allow-webhook-traffic.yaml 3 | - allow-metrics-traffic.yaml 4 | -------------------------------------------------------------------------------- /config/webhook/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manifests.yaml 3 | - service.yaml 4 | 5 | configurations: 6 | - kustomizeconfig.yaml 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | -------------------------------------------------------------------------------- /config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## Append samples of your project ## 2 | resources: 3 | - fqdn_v1alpha1_networkpolicy.yaml 4 | # +kubebuilder:scaffold:manifestskustomizesamples 5 | -------------------------------------------------------------------------------- /config/certmanager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - issuer.yaml 3 | - certificate-webhook.yaml 4 | - certificate-metrics.yaml 5 | 6 | configurations: 7 | - kustomizeconfig.yaml 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "ignorePaths": [ 7 | "**/config/manager/**" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /config/default/manager_metrics_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch adds the args to allow exposing the metrics endpoint using HTTPS 2 | - op: add 3 | path: /spec/template/spec/containers/0/args/0 4 | value: --metrics-bind-address=:8443 5 | -------------------------------------------------------------------------------- /config/rbac/metrics_reader_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-reader 5 | rules: 6 | - nonResourceURLs: 7 | - "/metrics" 8 | verbs: 9 | - get 10 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | apiVersion: kustomize.config.k8s.io/v1beta1 4 | kind: Kustomization 5 | images: 6 | - name: controller 7 | newName: example.com/fqdn-controller 8 | newTag: 0.0.1 9 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: fqdn-controller 6 | app.kubernetes.io/managed-by: kustomize 7 | name: controller-manager 8 | namespace: system 9 | -------------------------------------------------------------------------------- /pkg/utils/map.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func MapContains(superset, subset map[string]string) bool { 4 | for k, v := range subset { 5 | if actual, ok := superset[k]; !ok || actual != v { 6 | return false 7 | } 8 | } 9 | return true 10 | } 11 | -------------------------------------------------------------------------------- /config/certmanager/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This configuration is for teaching kustomize how to update name ref substitution 2 | nameReference: 3 | - kind: Issuer 4 | group: cert-manager.io 5 | fieldSpecs: 6 | - kind: Certificate 7 | group: cert-manager.io 8 | path: spec/issuerRef/name 9 | -------------------------------------------------------------------------------- /hack/deps/certmanager/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/konsole-is/fqdn-controller/test/utils" 7 | ) 8 | 9 | func main() { 10 | if err := utils.InstallCertManager(); err != nil { 11 | log.Fatalf("failed to install cert-manager: %v", err) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /hack/deps/prometheus/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/konsole-is/fqdn-controller/test/utils" 7 | ) 8 | 9 | func main() { 10 | if err := utils.InstallPrometheusOperator(); err != nil { 11 | log.Fatalf("failed to install prometheus-operator: %v", err) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /dist/chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: fqdn-controller 3 | description: A Helm chart to distribute the project fqdn-controller 4 | type: application 5 | version: 0.0.1 6 | appVersion: "0.0.1" 7 | sources: 8 | - https://github.com/konsole-is/fqdn-controller 9 | icon: "https://github.com/konsole-is/fqdn-controller/blob/master/dist/chart_icon.png?raw=true" 10 | -------------------------------------------------------------------------------- /config/rbac/metrics_auth_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: metrics-auth-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: metrics-auth-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/metrics_auth_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-auth-role 5 | rules: 6 | - apiGroups: 7 | - authentication.k8s.io 8 | resources: 9 | - tokenreviews 10 | verbs: 11 | - create 12 | - apiGroups: 13 | - authorization.k8s.io 14 | resources: 15 | - subjectaccessreviews 16 | verbs: 17 | - create 18 | -------------------------------------------------------------------------------- /test/utils/print.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // PrettyForPrint returns an indented string version of any Go struct or value. 9 | func PrettyForPrint(v interface{}) string { 10 | bytes, err := json.MarshalIndent(v, "", " ") 11 | if err != nil { 12 | return fmt.Sprintf("PrettyPrint error: %v", err) 13 | } 14 | return string(bytes) 15 | } 16 | -------------------------------------------------------------------------------- /config/webhook/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: fqdn-controller 6 | app.kubernetes.io/managed-by: kustomize 7 | name: webhook-service 8 | namespace: system 9 | spec: 10 | ports: 11 | - port: 443 12 | protocol: TCP 13 | targetPort: 9443 14 | selector: 15 | control-plane: controller-manager 16 | app.kubernetes.io/name: fqdn-controller 17 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: fqdn-controller 6 | app.kubernetes.io/managed-by: kustomize 7 | name: manager-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: manager-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: controller-manager 15 | namespace: system 16 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: fqdn-controller 6 | app.kubernetes.io/managed-by: kustomize 7 | name: leader-election-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: Role 11 | name: leader-election-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: controller-manager 15 | namespace: system 16 | -------------------------------------------------------------------------------- /config/samples/sample.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: fqdn.konsole.is/v1alpha1 2 | kind: NetworkPolicy 3 | metadata: 4 | name: allow-selected-egress 5 | spec: 6 | podSelector: 7 | matchLabels: 8 | app: backend 9 | egress: 10 | - toFQDNS: 11 | - github.com 12 | ports: 13 | - protocol: TCP 14 | port: 443 15 | - toFQDNS: 16 | - example.com 17 | ports: 18 | - protocol: TCP 19 | port: 80 20 | blockPrivateIPs: true -------------------------------------------------------------------------------- /config/default/metrics_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: fqdn-controller 7 | app.kubernetes.io/managed-by: kustomize 8 | name: controller-manager-metrics-service 9 | namespace: system 10 | spec: 11 | ports: 12 | - name: https 13 | port: 8443 14 | protocol: TCP 15 | targetPort: 8443 16 | selector: 17 | control-plane: controller-manager 18 | app.kubernetes.io/name: fqdn-controller 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Run on Ubuntu 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Clone the code 15 | uses: actions/checkout@v5 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v6 19 | with: 20 | go-version-file: go.mod 21 | 22 | - name: Running Tests 23 | run: | 24 | go mod tidy 25 | make test 26 | -------------------------------------------------------------------------------- /config/certmanager/issuer.yaml: -------------------------------------------------------------------------------- 1 | # The following manifest contains a self-signed issuer CR. 2 | # More information can be found at https://docs.cert-manager.io 3 | # WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes. 4 | apiVersion: cert-manager.io/v1 5 | kind: Issuer 6 | metadata: 7 | labels: 8 | app.kubernetes.io/name: fqdn-controller 9 | app.kubernetes.io/managed-by: kustomize 10 | name: selfsigned-issuer 11 | namespace: system 12 | spec: 13 | selfSigned: {} 14 | -------------------------------------------------------------------------------- /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | 4 | # [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus 5 | # to securely reference certificates created and managed by cert-manager. 6 | # Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml 7 | # to mount the "metrics-server-cert" secret in the Manager Deployment. 8 | #patches: 9 | # - path: monitor_tls_patch.yaml 10 | # target: 11 | # kind: ServiceMonitor 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | version: v1 14 | group: apiextensions.k8s.io 15 | path: spec/conversion/webhook/clientConfig/service/namespace 16 | create: false 17 | 18 | varReference: 19 | - path: metadata/annotations 20 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 | */ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin/* 8 | Dockerfile.cross 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Go workspace file 17 | go.work 18 | 19 | # Kubernetes Generated files - skip generated files, except for vendored files 20 | !vendor/**/zz_generated.* 21 | 22 | # editor and IDE paraphernalia 23 | .idea 24 | .vscode 25 | *.swp 26 | *.swo 27 | *~ 28 | dist/**/* 29 | !dist/chart/ 30 | !dist/chart/Chart.yaml 31 | !dist/chart/README.md 32 | !dist/chart_icon.png 33 | cover.out -------------------------------------------------------------------------------- /config/prometheus/monitor_tls_patch.yaml: -------------------------------------------------------------------------------- 1 | # Patch for Prometheus ServiceMonitor to enable secure TLS configuration 2 | # using certificates managed by cert-manager 3 | - op: replace 4 | path: /spec/endpoints/0/tlsConfig 5 | value: 6 | # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize 7 | serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc 8 | insecureSkipVerify: false 9 | ca: 10 | secret: 11 | name: metrics-server-cert 12 | key: ca.crt 13 | cert: 14 | secret: 15 | name: metrics-server-cert 16 | key: tls.crt 17 | keySecret: 18 | name: metrics-server-cert 19 | key: tls.key 20 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kubebuilder DevContainer", 3 | "image": "golang:1.25", 4 | "features": { 5 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}, 6 | "ghcr.io/devcontainers/features/git:1": {} 7 | }, 8 | 9 | "runArgs": ["--network=host"], 10 | 11 | "customizations": { 12 | "vscode": { 13 | "settings": { 14 | "terminal.integrated.shell.linux": "/bin/bash" 15 | }, 16 | "extensions": [ 17 | "ms-kubernetes-tools.vscode-kubernetes-tools", 18 | "ms-azuretools.vscode-docker" 19 | ] 20 | } 21 | }, 22 | 23 | "onCreateCommand": "bash .devcontainer/post-install.sh" 24 | } 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | name: Run on Ubuntu 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Clone the code 15 | uses: actions/checkout@v5 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v6 19 | with: 20 | go-version-file: go.mod 21 | 22 | - name: Generate and diff 23 | run: | 24 | make manifests 25 | make generate 26 | git diff --exit-code 27 | 28 | - name: Run linter 29 | uses: golangci/golangci-lint-action@v8 30 | with: 31 | version: v2.1.0 32 | -------------------------------------------------------------------------------- /.devcontainer/post-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 5 | chmod +x ./kind 6 | mv ./kind /usr/local/bin/kind 7 | 8 | curl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/linux/amd64 9 | chmod +x kubebuilder 10 | mv kubebuilder /usr/local/bin/ 11 | 12 | KUBECTL_VERSION=$(curl -L -s https://dl.k8s.io/release/stable.txt) 13 | curl -LO "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/linux/amd64/kubectl" 14 | chmod +x kubectl 15 | mv kubectl /usr/local/bin/kubectl 16 | 17 | docker network create -d=bridge --subnet=172.19.0.0/24 kind 18 | 19 | kind version 20 | kubebuilder version 21 | docker --version 22 | go version 23 | kubectl version --client 24 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/fqdn.konsole.is_fqdnnetworkpolicies.yaml 6 | # +kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patches: 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | # +kubebuilder:scaffold:crdkustomizewebhookpatch 12 | 13 | # [WEBHOOK] To enable webhook, uncomment the following section 14 | # the following config is for teaching kustomize how to do kustomization for CRDs. 15 | configurations: 16 | - kustomizeconfig.yaml 17 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: fqdn-controller 7 | app.kubernetes.io/managed-by: kustomize 8 | name: leader-election-role 9 | rules: 10 | - apiGroups: 11 | - "" 12 | resources: 13 | - configmaps 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - patch 21 | - delete 22 | - apiGroups: 23 | - coordination.k8s.io 24 | resources: 25 | - leases 26 | verbs: 27 | - get 28 | - list 29 | - watch 30 | - create 31 | - update 32 | - patch 33 | - delete 34 | - apiGroups: 35 | - "" 36 | resources: 37 | - events 38 | verbs: 39 | - create 40 | - patch 41 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | # Code generated by tool. DO NOT EDIT. 2 | # This file is used to track the info used to scaffold your project 3 | # and allow the plugins properly work. 4 | # More info: https://book.kubebuilder.io/reference/project-config.html 5 | cliVersion: 4.6.0 6 | domain: konsole.is 7 | layout: 8 | - go.kubebuilder.io/v4 9 | plugins: 10 | helm.kubebuilder.io/v1-alpha: {} 11 | projectName: fqdn-controller 12 | repo: github.com/konsole-is/fqdn-controller 13 | resources: 14 | - api: 15 | crdVersion: v1 16 | namespaced: true 17 | controller: true 18 | domain: konsole.is 19 | group: fqdn 20 | kind: NetworkPolicy 21 | path: github.com/konsole-is/fqdn-controller/api/v1alpha1 22 | version: v1alpha1 23 | webhooks: 24 | defaulting: true 25 | validation: true 26 | webhookVersion: v1 27 | version: "3" 28 | -------------------------------------------------------------------------------- /config/samples/fqdn_v1alpha1_networkpolicy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: fqdn.konsole.is/v1alpha1 2 | kind: NetworkPolicy 3 | metadata: 4 | name: networkpolicy-sample 5 | namespace: default 6 | labels: 7 | app.kubernetes.io/name: fqdn-controller 8 | app.kubernetes.io/managed-by: helm 9 | spec: 10 | podSelector: 11 | matchLabels: 12 | app: my-app 13 | enabledNetworkType: ipv4 14 | ttlSeconds: 60 15 | resolveTimeoutSeconds: 3 16 | retryTimeoutSeconds: 3600 17 | blockPrivateIPs: false 18 | egress: 19 | - toFQDNS: 20 | - api.example.com 21 | - github.com 22 | ports: 23 | - protocol: TCP 24 | port: 443 25 | - toFQDNS: 26 | - telemetry.example.net 27 | ports: 28 | - protocol: TCP 29 | port: 443 30 | blockPrivateIPs: true 31 | -------------------------------------------------------------------------------- /config/certmanager/certificate-webhook.yaml: -------------------------------------------------------------------------------- 1 | # The following manifests contain a self-signed issuer CR and a certificate CR. 2 | # More document can be found at https://docs.cert-manager.io 3 | apiVersion: cert-manager.io/v1 4 | kind: Certificate 5 | metadata: 6 | labels: 7 | app.kubernetes.io/name: fqdn-controller 8 | app.kubernetes.io/managed-by: kustomize 9 | name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml 10 | namespace: system 11 | spec: 12 | # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize 13 | # replacements in the config/default/kustomization.yaml file. 14 | dnsNames: 15 | - SERVICE_NAME.SERVICE_NAMESPACE.svc 16 | - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local 17 | issuerRef: 18 | kind: Issuer 19 | name: selfsigned-issuer 20 | secretName: webhook-server-cert 21 | -------------------------------------------------------------------------------- /.github/workflows/test-e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test-e2e: 11 | name: Run on Ubuntu 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Clone the code 15 | uses: actions/checkout@v5 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v6 19 | with: 20 | go-version-file: go.mod 21 | 22 | - name: Install the latest version of kind 23 | run: | 24 | curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 25 | chmod +x ./kind 26 | sudo mv ./kind /usr/local/bin/kind 27 | 28 | - name: Verify kind installation 29 | run: kind version 30 | 31 | - name: Running Test e2e 32 | run: | 33 | go mod tidy 34 | make test-e2e 35 | -------------------------------------------------------------------------------- /config/certmanager/certificate-metrics.yaml: -------------------------------------------------------------------------------- 1 | # The following manifests contain a self-signed issuer CR and a metrics certificate CR. 2 | # More document can be found at https://docs.cert-manager.io 3 | apiVersion: cert-manager.io/v1 4 | kind: Certificate 5 | metadata: 6 | labels: 7 | app.kubernetes.io/name: fqdn-controller 8 | app.kubernetes.io/managed-by: kustomize 9 | name: metrics-certs # this name should match the one appeared in kustomizeconfig.yaml 10 | namespace: system 11 | spec: 12 | dnsNames: 13 | # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize 14 | # replacements in the config/default/kustomization.yaml file. 15 | - SERVICE_NAME.SERVICE_NAMESPACE.svc 16 | - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local 17 | issuerRef: 18 | kind: Issuer 19 | name: selfsigned-issuer 20 | secretName: metrics-server-cert 21 | -------------------------------------------------------------------------------- /config/webhook/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # the following config is for teaching kustomize where to look at when substituting nameReference. 2 | # It requires kustomize v2.1.0 or newer to work properly. 3 | nameReference: 4 | - kind: Service 5 | version: v1 6 | fieldSpecs: 7 | - kind: MutatingWebhookConfiguration 8 | group: admissionregistration.k8s.io 9 | path: webhooks/clientConfig/service/name 10 | - kind: ValidatingWebhookConfiguration 11 | group: admissionregistration.k8s.io 12 | path: webhooks/clientConfig/service/name 13 | 14 | namespace: 15 | - kind: MutatingWebhookConfiguration 16 | group: admissionregistration.k8s.io 17 | path: webhooks/clientConfig/service/namespace 18 | create: true 19 | - kind: ValidatingWebhookConfiguration 20 | group: admissionregistration.k8s.io 21 | path: webhooks/clientConfig/service/namespace 22 | create: true 23 | -------------------------------------------------------------------------------- /config/rbac/networkpolicy_admin_role.yaml: -------------------------------------------------------------------------------- 1 | # This rule is not used by the project fqdn-controller itself. 2 | # It is provided to allow the cluster admin to help manage permissions for users. 3 | # 4 | # Grants full permissions ('*') over fqdn.konsole.is. 5 | # This role is intended for users authorized to modify roles and bindings within the cluster, 6 | # enabling them to delegate specific permissions to other users or groups as needed. 7 | 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | kind: ClusterRole 10 | metadata: 11 | labels: 12 | app.kubernetes.io/name: fqdn-controller 13 | app.kubernetes.io/managed-by: kustomize 14 | name: networkpolicy-admin-role 15 | rules: 16 | - apiGroups: 17 | - fqdn.konsole.is 18 | resources: 19 | - networkpolicies 20 | verbs: 21 | - '*' 22 | - apiGroups: 23 | - fqdn.konsole.is 24 | resources: 25 | - networkpolicies/status 26 | verbs: 27 | - get 28 | -------------------------------------------------------------------------------- /config/rbac/networkpolicy_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # This rule is not used by the project fqdn-controller itself. 2 | # It is provided to allow the cluster admin to help manage permissions for users. 3 | # 4 | # Grants read-only access to fqdn.konsole.is resources. 5 | # This role is intended for users who need visibility into these resources 6 | # without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. 7 | 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | kind: ClusterRole 10 | metadata: 11 | labels: 12 | app.kubernetes.io/name: fqdn-controller 13 | app.kubernetes.io/managed-by: kustomize 14 | name: networkpolicy-viewer-role 15 | rules: 16 | - apiGroups: 17 | - fqdn.konsole.is 18 | resources: 19 | - networkpolicies 20 | verbs: 21 | - get 22 | - list 23 | - watch 24 | - apiGroups: 25 | - fqdn.konsole.is 26 | resources: 27 | - networkpolicies/status 28 | verbs: 29 | - get 30 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - events 11 | verbs: 12 | - create 13 | - patch 14 | - update 15 | - apiGroups: 16 | - fqdn.konsole.is 17 | resources: 18 | - fqdnnetworkpolicies 19 | verbs: 20 | - create 21 | - delete 22 | - get 23 | - list 24 | - patch 25 | - update 26 | - watch 27 | - apiGroups: 28 | - fqdn.konsole.is 29 | resources: 30 | - fqdnnetworkpolicies/finalizers 31 | verbs: 32 | - update 33 | - apiGroups: 34 | - fqdn.konsole.is 35 | resources: 36 | - fqdnnetworkpolicies/status 37 | verbs: 38 | - get 39 | - patch 40 | - update 41 | - apiGroups: 42 | - networking.k8s.io 43 | resources: 44 | - networkpolicies 45 | verbs: 46 | - create 47 | - delete 48 | - get 49 | - list 50 | - patch 51 | - update 52 | - watch 53 | -------------------------------------------------------------------------------- /config/rbac/networkpolicy_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # This rule is not used by the project fqdn-controller itself. 2 | # It is provided to allow the cluster admin to help manage permissions for users. 3 | # 4 | # Grants permissions to create, update, and delete resources within the fqdn.konsole.is. 5 | # This role is intended for users who need to manage these resources 6 | # but should not control RBAC or manage permissions for others. 7 | 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | kind: ClusterRole 10 | metadata: 11 | labels: 12 | app.kubernetes.io/name: fqdn-controller 13 | app.kubernetes.io/managed-by: kustomize 14 | name: networkpolicy-editor-role 15 | rules: 16 | - apiGroups: 17 | - fqdn.konsole.is 18 | resources: 19 | - networkpolicies 20 | verbs: 21 | - create 22 | - delete 23 | - get 24 | - list 25 | - patch 26 | - update 27 | - watch 28 | - apiGroups: 29 | - fqdn.konsole.is 30 | resources: 31 | - networkpolicies/status 32 | verbs: 33 | - get 34 | -------------------------------------------------------------------------------- /config/network-policy/allow-metrics-traffic.yaml: -------------------------------------------------------------------------------- 1 | # This NetworkPolicy allows ingress traffic 2 | # with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those 3 | # namespaces are able to gather data from the metrics endpoint. 4 | apiVersion: networking.k8s.io/v1 5 | kind: NetworkPolicy 6 | metadata: 7 | labels: 8 | app.kubernetes.io/name: fqdn-controller 9 | app.kubernetes.io/managed-by: kustomize 10 | name: allow-metrics-traffic 11 | namespace: system 12 | spec: 13 | podSelector: 14 | matchLabels: 15 | control-plane: controller-manager 16 | app.kubernetes.io/name: fqdn-controller 17 | policyTypes: 18 | - Ingress 19 | ingress: 20 | # This allows ingress traffic from any namespace with the label metrics: enabled 21 | - from: 22 | - namespaceSelector: 23 | matchLabels: 24 | metrics: enabled # Only from namespaces with this label 25 | ports: 26 | - port: 8443 27 | protocol: TCP 28 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | timeout: 5m 5 | allow-parallel-runners: true 6 | relative-path-mode: cfg 7 | 8 | linters: 9 | default: none 10 | enable: 11 | - dupl 12 | - errcheck 13 | - copyloopvar 14 | - ginkgolinter 15 | - goconst 16 | - gocyclo 17 | - govet 18 | - ineffassign 19 | - misspell 20 | - nakedret 21 | - unconvert 22 | - unparam 23 | - unused 24 | - staticcheck 25 | disable: 26 | - prealloc 27 | - revive 28 | exclusions: 29 | rules: 30 | - path: "^api/.*" 31 | linters: 32 | - lll 33 | - path: "^internal/.*" 34 | linters: 35 | - dupl 36 | - lll 37 | settings: 38 | revive: 39 | rules: 40 | - name: comment-spacings 41 | 42 | formatters: 43 | enable: 44 | - gofmt 45 | - goimports 46 | # example settings for formatters; remove if unused 47 | settings: 48 | gofmt: 49 | simplify: true 50 | 51 | issues: {} 52 | -------------------------------------------------------------------------------- /internal/controller/network_policy_delete.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 7 | "github.com/konsole-is/fqdn-controller/pkg/utils" 8 | corev1 "k8s.io/api/core/v1" 9 | netv1 "k8s.io/api/networking/v1" 10 | "k8s.io/apimachinery/pkg/api/errors" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | ) 13 | 14 | // reconcileNetworkPolicyCreation Removes the underlying network policy 15 | func (r *NetworkPolicyReconciler) reconcileNetworkPolicyDeletion(ctx context.Context, np *v1alpha1.NetworkPolicy) error { 16 | networkPolicy := &netv1.NetworkPolicy{ 17 | ObjectMeta: metav1.ObjectMeta{ 18 | Name: np.Name, 19 | Namespace: np.Namespace, 20 | }, 21 | } 22 | if err := r.Delete(ctx, networkPolicy); err != nil && !errors.IsNotFound(err) { 23 | return err 24 | } 25 | r.EventRecorder.Event( 26 | np, corev1.EventTypeNormal, 27 | utils.DeletionReason(networkPolicy), utils.DeletionMessage(networkPolicy), 28 | ) 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /config/default/cert_metrics_manager_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs. 2 | 3 | # Add the volumeMount for the metrics-server certs 4 | - op: add 5 | path: /spec/template/spec/containers/0/volumeMounts/- 6 | value: 7 | mountPath: /tmp/k8s-metrics-server/metrics-certs 8 | name: metrics-certs 9 | readOnly: true 10 | 11 | # Add the --metrics-cert-path argument for the metrics server 12 | - op: add 13 | path: /spec/template/spec/containers/0/args/- 14 | value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs 15 | 16 | # Add the metrics-server certs volume configuration 17 | - op: add 18 | path: /spec/template/spec/volumes/- 19 | value: 20 | name: metrics-certs 21 | secret: 22 | secretName: metrics-server-cert 23 | optional: false 24 | items: 25 | - key: ca.crt 26 | path: ca.crt 27 | - key: tls.crt 28 | path: tls.crt 29 | - key: tls.key 30 | path: tls.key 31 | -------------------------------------------------------------------------------- /config/network-policy/allow-webhook-traffic.yaml: -------------------------------------------------------------------------------- 1 | # This NetworkPolicy allows ingress traffic to your webhook server running 2 | # as part of the controller-manager from specific namespaces and pods. CR(s) which uses webhooks 3 | # will only work when applied in namespaces labeled with 'webhook: enabled' 4 | apiVersion: networking.k8s.io/v1 5 | kind: NetworkPolicy 6 | metadata: 7 | labels: 8 | app.kubernetes.io/name: fqdn-controller 9 | app.kubernetes.io/managed-by: kustomize 10 | name: allow-webhook-traffic 11 | namespace: system 12 | spec: 13 | podSelector: 14 | matchLabels: 15 | control-plane: controller-manager 16 | app.kubernetes.io/name: fqdn-controller 17 | policyTypes: 18 | - Ingress 19 | ingress: 20 | # This allows ingress traffic from any namespace with the label webhook: enabled 21 | - from: 22 | - namespaceSelector: 23 | matchLabels: 24 | webhook: enabled # Only from namespaces with this label 25 | ports: 26 | - port: 443 27 | protocol: TCP 28 | -------------------------------------------------------------------------------- /test/utils/structs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 5 | corev1 "k8s.io/api/core/v1" 6 | netv1 "k8s.io/api/networking/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/util/intstr" 9 | "k8s.io/utils/ptr" 10 | ) 11 | 12 | func TCPNetworkPolicyPort(port int, endPort int) netv1.NetworkPolicyPort { 13 | return netv1.NetworkPolicyPort{ 14 | Protocol: ptr.To(corev1.ProtocolTCP), 15 | Port: ptr.To(intstr.FromInt32(int32(port))), 16 | EndPort: ptr.To(int32(endPort)), 17 | } 18 | } 19 | 20 | func TCPEgressRule(fqdns []v1alpha1.FQDN, ports []int) v1alpha1.EgressRule { 21 | var policyPorts []netv1.NetworkPolicyPort 22 | for _, port := range ports { 23 | policyPorts = append(policyPorts, TCPNetworkPolicyPort(port, port)) 24 | } 25 | return v1alpha1.EgressRule{ 26 | Ports: policyPorts, 27 | ToFQDNS: fqdns, 28 | } 29 | } 30 | 31 | func PodSelector(key string, value string) metav1.LabelSelector { 32 | return metav1.LabelSelector{ 33 | MatchLabels: map[string]string{ 34 | key: value, 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/default/manager_webhook_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch ensures the webhook certificates are properly mounted in the manager container. 2 | # It configures the necessary arguments, volumes, volume mounts, and container ports. 3 | 4 | # Add the --webhook-cert-path argument for configuring the webhook certificate path 5 | - op: add 6 | path: /spec/template/spec/containers/0/args/- 7 | value: --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs 8 | 9 | # Add the volumeMount for the webhook certificates 10 | - op: add 11 | path: /spec/template/spec/containers/0/volumeMounts/- 12 | value: 13 | mountPath: /tmp/k8s-webhook-server/serving-certs 14 | name: webhook-certs 15 | readOnly: true 16 | 17 | # Add the port configuration for the webhook server 18 | - op: add 19 | path: /spec/template/spec/containers/0/ports/- 20 | value: 21 | containerPort: 9443 22 | name: webhook-server 23 | protocol: TCP 24 | 25 | # Add the volume configuration for the webhook certificates 26 | - op: add 27 | path: /spec/template/spec/volumes/- 28 | value: 29 | name: webhook-certs 30 | secret: 31 | secretName: webhook-server-cert 32 | -------------------------------------------------------------------------------- /pkg/utils/network_policy.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 5 | netv1 "k8s.io/api/networking/v1" 6 | ) 7 | 8 | // UniqueCidrsInNetworkPolicy returns all the unique CIDR's applied in the network policy 9 | func UniqueCidrsInNetworkPolicy(networkPolicy *netv1.NetworkPolicy) []*v1alpha1.CIDR { 10 | if networkPolicy == nil { 11 | return []*v1alpha1.CIDR{} 12 | } 13 | 14 | set := make(map[string]struct{}) 15 | for _, rule := range networkPolicy.Spec.Ingress { 16 | for _, from := range rule.From { 17 | if from.IPBlock != nil { 18 | set[from.IPBlock.CIDR] = struct{}{} 19 | } 20 | } 21 | } 22 | for _, rule := range networkPolicy.Spec.Egress { 23 | for _, to := range rule.To { 24 | if to.IPBlock != nil { 25 | set[to.IPBlock.CIDR] = struct{}{} 26 | } 27 | } 28 | } 29 | var cidrs []*v1alpha1.CIDR 30 | for cidr := range set { 31 | if c, err := v1alpha1.NewCIDR(cidr); err == nil { 32 | cidrs = append(cidrs, c) 33 | } 34 | } 35 | return cidrs 36 | } 37 | 38 | func IsEmpty(networkPolicy *netv1.NetworkPolicy) bool { 39 | return len(networkPolicy.Spec.Ingress) == 0 && len(networkPolicy.Spec.Egress) == 0 40 | } 41 | -------------------------------------------------------------------------------- /dist/chart/README.md: -------------------------------------------------------------------------------- 1 | # FQDN Controller 2 | 3 | A Helm chart for deploying the `fqdn-controller`, a Kubernetes controller that manages FQDN-based egress 4 | NetworkPolicies. 5 | 6 | Check out the [GitHub Repository](https://github.com/konsole-is/fqdn-controller) for more information. 7 | 8 | --- 9 | 10 | ## Prerequisites 11 | 12 | Install cert-manager in your cluster if you intend to enable webhooks. 13 | 14 | ## Installation 15 | 16 | If you wish to manage the CRDs outside the helm chart you can install them with 17 | 18 | ```bash 19 | curl -sL https://github.com/konsole-is/fqdn-controller/releases/download//crds.yaml | kubectl apply -f - 20 | ``` 21 | 22 | Install the controller using the helm chart 23 | 24 | ```bash 25 | helm repo add fqdn-controller https://konsole-is.github.io/fqdn-controller/charts 26 | helm install fqdn-controller fqdn-controller/fqdn-controller --version 27 | ``` 28 | 29 | ## Verifying chart signatures 30 | 31 | All charts are signed using GPG. You can verify the authenticity and integrity of a chart using the .prov file and the 32 | public GPG key. 33 | 34 | ```helm 35 | gpg --keyserver hkps://keys.openpgp.org --recv-keys 6D2CDAA28E7B8D360B8C63817D7F57D9C5527906 36 | helm pull konsole/fqdn-controller --version --verify 37 | ``` -------------------------------------------------------------------------------- /config/webhook/manifests.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: admissionregistration.k8s.io/v1 3 | kind: MutatingWebhookConfiguration 4 | metadata: 5 | name: mutating-webhook-configuration 6 | webhooks: 7 | - admissionReviewVersions: 8 | - v1 9 | clientConfig: 10 | service: 11 | name: webhook-service 12 | namespace: system 13 | path: /mutate-fqdn-konsole-is-v1alpha1-networkpolicy 14 | failurePolicy: Fail 15 | name: mnetworkpolicy-v1alpha1.kb.io 16 | rules: 17 | - apiGroups: 18 | - fqdn.konsole.is 19 | apiVersions: 20 | - v1alpha1 21 | operations: 22 | - CREATE 23 | - UPDATE 24 | resources: 25 | - networkpolicies 26 | sideEffects: None 27 | --- 28 | apiVersion: admissionregistration.k8s.io/v1 29 | kind: ValidatingWebhookConfiguration 30 | metadata: 31 | name: validating-webhook-configuration 32 | webhooks: 33 | - admissionReviewVersions: 34 | - v1 35 | clientConfig: 36 | service: 37 | name: webhook-service 38 | namespace: system 39 | path: /validate-fqdn-konsole-is-v1alpha1-networkpolicy 40 | failurePolicy: Fail 41 | name: vnetworkpolicy-v1alpha1.kb.io 42 | rules: 43 | - apiGroups: 44 | - fqdn.konsole.is 45 | apiVersions: 46 | - v1alpha1 47 | operations: 48 | - CREATE 49 | - UPDATE 50 | resources: 51 | - networkpolicies 52 | sideEffects: None 53 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | # Prometheus Monitor Service (Metrics) 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | labels: 6 | control-plane: controller-manager 7 | app.kubernetes.io/name: fqdn-controller 8 | app.kubernetes.io/managed-by: kustomize 9 | name: controller-manager-metrics-monitor 10 | namespace: system 11 | spec: 12 | endpoints: 13 | - path: /metrics 14 | port: https # Ensure this is the name of the port that exposes HTTPS metrics 15 | scheme: https 16 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 17 | tlsConfig: 18 | # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables 19 | # certificate verification, exposing the system to potential man-in-the-middle attacks. 20 | # For production environments, it is recommended to use cert-manager for automatic TLS certificate management. 21 | # To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml, 22 | # which securely references the certificate from the 'metrics-server-cert' secret. 23 | insecureSkipVerify: true 24 | selector: 25 | matchLabels: 26 | control-plane: controller-manager 27 | app.kubernetes.io/name: fqdn-controller 28 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | # All RBAC will be applied under this service account in 3 | # the deployment namespace. You may comment out this resource 4 | # if your manager will use a service account that exists at 5 | # runtime. Be sure to update RoleBinding and ClusterRoleBinding 6 | # subjects if changing service account names. 7 | - service_account.yaml 8 | - role.yaml 9 | - role_binding.yaml 10 | - leader_election_role.yaml 11 | - leader_election_role_binding.yaml 12 | # The following RBAC configurations are used to protect 13 | # the metrics endpoint with authn/authz. These configurations 14 | # ensure that only authorized users and service accounts 15 | # can access the metrics endpoint. Comment the following 16 | # permissions if you want to disable this protection. 17 | # More info: https://book.kubebuilder.io/reference/metrics.html 18 | - metrics_auth_role.yaml 19 | - metrics_auth_role_binding.yaml 20 | - metrics_reader_role.yaml 21 | # For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by 22 | # default, aiding admins in cluster management. Those roles are 23 | # not used by the fqdn-controller itself. You can comment the following lines 24 | # if you do not want those helpers be installed with your Project. 25 | - networkpolicy_admin_role.yaml 26 | - networkpolicy_editor_role.yaml 27 | - networkpolicy_viewer_role.yaml 28 | 29 | -------------------------------------------------------------------------------- /test/utils/kubectl.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os/exec" 7 | 8 | "sigs.k8s.io/controller-runtime/pkg/client" 9 | "sigs.k8s.io/yaml" 10 | ) 11 | 12 | func KubectlGet(object client.Object, kind string) (string, error) { 13 | cmd := exec.Command("kubectl", "get", kind, object.GetName(), "-n", object.GetNamespace(), "-o", "yaml") 14 | return Run(cmd) 15 | } 16 | 17 | func KubectlApply(object client.Object) error { 18 | objYaml, err := yaml.Marshal(object) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | cmd := exec.Command("kubectl", "apply", "-f", "-") 24 | cmd.Stdin = bytes.NewReader(objYaml) 25 | _, err = Run(cmd) 26 | return err 27 | } 28 | 29 | func KubectlDelete(object client.Object) error { 30 | objYaml, err := yaml.Marshal(object) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | cmd := exec.Command("kubectl", "delete", "-f", "-") 36 | cmd.Stdin = bytes.NewReader(objYaml) 37 | _, err = Run(cmd) 38 | return err 39 | } 40 | 41 | func KubectlGetJSONPath(obj client.Object, kind string, jsonPath string) (string, error) { 42 | cmd := exec.Command( 43 | "kubectl", "get", kind, obj.GetName(), 44 | "-n", obj.GetNamespace(), 45 | "-o", fmt.Sprintf("jsonpath={%s}", jsonPath), 46 | ) 47 | 48 | output, err := Run(cmd) 49 | if err != nil { 50 | return "", err 51 | } 52 | return output, nil 53 | } 54 | -------------------------------------------------------------------------------- /api/v1alpha1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 v1alpha1 contains API Schema definitions for the fqdn v1alpha1 API group. 18 | // +kubebuilder:object:generate=true 19 | // +groupName=fqdn.konsole.is 20 | package v1alpha1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | var ( 28 | // GroupVersion is group version used to register these objects. 29 | GroupVersion = schema.GroupVersion{Group: "fqdn.konsole.is", Version: "v1alpha1"} 30 | 31 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme. 32 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 33 | 34 | // AddToScheme adds the types in this group-version to the given scheme. 35 | AddToScheme = SchemeBuilder.AddToScheme 36 | ) 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.25 AS builder 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | 6 | WORKDIR /workspace 7 | # Copy the Go Modules manifests 8 | COPY go.mod go.mod 9 | COPY go.sum go.sum 10 | # cache deps before building and copying source so that we don't need to re-download as much 11 | # and so that source changes don't invalidate our downloaded layer 12 | RUN go mod download 13 | 14 | # Copy the go source 15 | COPY cmd/main.go cmd/main.go 16 | COPY api/ api/ 17 | COPY internal/ internal/ 18 | COPY pkg/ pkg/ 19 | 20 | # Build 21 | # the GOARCH has not a default value to allow the binary be built according to the host where the command 22 | # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO 23 | # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, 24 | # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. 25 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go 26 | 27 | # Use distroless as minimal base image to package the manager binary 28 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 29 | FROM gcr.io/distroless/static:nonroot 30 | WORKDIR / 31 | COPY --from=builder /workspace/manager . 32 | USER 65532:65532 33 | 34 | ENTRYPOINT ["/manager"] -------------------------------------------------------------------------------- /internal/controller/network_policy_fqdn_status_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 10 | "github.com/konsole-is/fqdn-controller/pkg/network" 11 | corev1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/client-go/tools/record" 14 | ) 15 | 16 | func Test_updateFQDNStatuses(t *testing.T) { 17 | fqdn := v1alpha1.FQDN("example.com") 18 | past := time.Now().Add(-5 * time.Minute) 19 | 20 | previous := []v1alpha1.FQDNStatus{ 21 | { 22 | FQDN: fqdn, 23 | LastSuccessfulTime: metav1.NewTime(past), 24 | LastTransitionTime: metav1.NewTime(past), 25 | ResolveReason: v1alpha1.NetworkPolicyResolveSuccess, 26 | ResolveMessage: "initial success", 27 | Addresses: []string{"1.2.3.4/32"}, 28 | }, 29 | } 30 | 31 | // Transient error, timeout expired 32 | results := network.DNSResolverResultList{ 33 | { 34 | Domain: fqdn, 35 | Error: fmt.Errorf("temporary error"), 36 | Status: v1alpha1.NetworkPolicyResolveTemporaryError, 37 | Message: "failed temporarily", 38 | CIDRs: []*v1alpha1.CIDR{}, 39 | }, 40 | } 41 | 42 | recorder := record.NewFakeRecorder(1) 43 | updated := updateFQDNStatuses(recorder, &corev1.Pod{}, previous, results, 1) 44 | 45 | if len(updated) != 1 { 46 | t.Fatalf("expected 1 status, got %d", len(updated)) 47 | } 48 | 49 | status := updated[0] 50 | if len(status.Addresses) != 0 { 51 | t.Errorf("expected addresses to be cleared, got: %v", status.Addresses) 52 | } 53 | 54 | select { 55 | case msg := <-recorder.Events: 56 | if !strings.Contains(msg, "FQDNRemoved") { 57 | t.Errorf("expected FQDNRemoved event, got: %s", msg) 58 | } 59 | default: 60 | t.Error("expected an event to be emitted but got none") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/controller/network_policy_fqdn_status.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 8 | "github.com/konsole-is/fqdn-controller/pkg/network" 9 | corev1 "k8s.io/api/core/v1" 10 | "k8s.io/apimachinery/pkg/runtime" 11 | "k8s.io/client-go/tools/record" 12 | ) 13 | 14 | // updateFQDNStatuses updates the status of each FQDN in the network policy according to the results and the previous 15 | // status 16 | func updateFQDNStatuses( 17 | recorder record.EventRecorder, object runtime.Object, 18 | previous []v1alpha1.FQDNStatus, results network.DNSResolverResultList, 19 | retryTimeoutSeconds int, 20 | ) []v1alpha1.FQDNStatus { 21 | var newFQDNStatuses []v1alpha1.FQDNStatus 22 | previousLookup := v1alpha1.FQDNStatusList(previous).LookupTable() 23 | 24 | for _, result := range results { 25 | if status, ok := previousLookup[result.Domain]; ok { 26 | cleared := status.Update(result.CIDRs, result.Status, result.Message, retryTimeoutSeconds) 27 | newFQDNStatuses = append(newFQDNStatuses, *status) 28 | 29 | if cleared { 30 | timeNow := time.Now() 31 | recorder.Event( 32 | object, corev1.EventTypeWarning, "FQDNRemoved", 33 | fmt.Sprintf( 34 | "IP Addresses of FQDN %s removed after being stale for %s. "+ 35 | "Resolve status at removal time was %s (for %s). "+ 36 | "Last successful resolve time was %s ago.", 37 | status.FQDN, (time.Duration(retryTimeoutSeconds)*time.Second).String(), 38 | status.ResolveReason, timeNow.Sub(status.LastTransitionTime.Time).String(), 39 | timeNow.Sub(status.LastSuccessfulTime.Time).String(), 40 | ), 41 | ) 42 | } 43 | } else { 44 | newFQDNStatuses = append(newFQDNStatuses, v1alpha1.NewFQDNStatus( 45 | result.Domain, 46 | result.CIDRs, 47 | result.Status, 48 | result.Message, 49 | )) 50 | } 51 | } 52 | return newFQDNStatuses 53 | } 54 | -------------------------------------------------------------------------------- /internal/controller/network_policy_create.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "maps" 6 | 7 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 8 | "github.com/konsole-is/fqdn-controller/pkg/utils" 9 | corev1 "k8s.io/api/core/v1" 10 | netv1 "k8s.io/api/networking/v1" 11 | "k8s.io/apimachinery/pkg/api/equality" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ctrl "sigs.k8s.io/controller-runtime" 14 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 15 | ) 16 | 17 | // reconcileNetworkPolicyCreation Creates the underlying network policy 18 | func (r *NetworkPolicyReconciler) reconcileNetworkPolicyCreation( 19 | ctx context.Context, np *v1alpha1.NetworkPolicy, networkPolicy *netv1.NetworkPolicy, 20 | ) error { 21 | current := &netv1.NetworkPolicy{ 22 | ObjectMeta: metav1.ObjectMeta{ 23 | Name: np.Name, 24 | Namespace: np.Namespace, 25 | }, 26 | } 27 | op, err := controllerutil.CreateOrUpdate(ctx, r.Client, current, func() error { 28 | if !utils.MapContains(current.Labels, networkPolicy.Labels) { 29 | current.Labels = networkPolicy.Labels 30 | } 31 | 32 | if !utils.MapContains(current.Annotations, networkPolicy.Annotations) { 33 | current.Annotations = maps.Clone(networkPolicy.Annotations) 34 | } 35 | 36 | if !equality.Semantic.DeepEqual(current.Spec, networkPolicy.Spec) { 37 | current.Spec = *networkPolicy.Spec.DeepCopy() 38 | } 39 | return ctrl.SetControllerReference(np, current, r.Scheme) 40 | }) 41 | if err != nil { 42 | r.EventRecorder.Event( 43 | np, 44 | corev1.EventTypeWarning, 45 | utils.OperationErrorReason(networkPolicy), 46 | err.Error(), 47 | ) 48 | return err 49 | } 50 | if op != controllerutil.OperationResultNone { 51 | r.EventRecorder.Event( 52 | np, 53 | corev1.EventTypeNormal, 54 | utils.OperationReason(networkPolicy, op), 55 | utils.OperationMessage(networkPolicy, op)) 56 | } 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/utils/operation.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 9 | ) 10 | 11 | func typeName(obj interface{}) string { 12 | t := reflect.TypeOf(obj) 13 | if t.Kind() == reflect.Ptr { 14 | t = t.Elem() 15 | } 16 | return t.Name() 17 | } 18 | 19 | func OperationErrorReason(object client.Object) string { 20 | return fmt.Sprintf("%sError", typeName(object)) 21 | } 22 | 23 | func OperationReason(object client.Object, op controllerutil.OperationResult) string { 24 | reason := "" 25 | switch op { 26 | case controllerutil.OperationResultCreated: 27 | reason = "Created" 28 | case controllerutil.OperationResultUpdated: 29 | reason = "Updated" 30 | case controllerutil.OperationResultUpdatedStatus: 31 | reason = "StatusUpdated" 32 | case controllerutil.OperationResultUpdatedStatusOnly: 33 | reason = "StatusUpdated" 34 | case controllerutil.OperationResultNone: 35 | reason = "Unchanged" 36 | } 37 | return fmt.Sprintf("%s%s", typeName(object), reason) 38 | } 39 | 40 | func OperationMessage(object client.Object, op controllerutil.OperationResult) string { 41 | message := "" 42 | switch op { 43 | case controllerutil.OperationResultCreated: 44 | message = "was created" 45 | case controllerutil.OperationResultUpdated: 46 | message = "was updated" 47 | case controllerutil.OperationResultUpdatedStatus: 48 | message = "had it's status updated" 49 | case controllerutil.OperationResultUpdatedStatusOnly: 50 | message = "had it's status updated" 51 | case controllerutil.OperationResultNone: 52 | message = "is unchanged" 53 | } 54 | return fmt.Sprintf("%s %s", typeName(object), message) 55 | } 56 | 57 | func DeletionReason(object client.Object) string { 58 | return fmt.Sprintf("%sDeleted", typeName(object)) 59 | } 60 | 61 | func DeletionMessage(object client.Object) string { 62 | return fmt.Sprintf("%s was removed", typeName(object)) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/utils/operation_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 7 | "github.com/stretchr/testify/assert" 8 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 9 | ) 10 | 11 | var object = &v1alpha1.NetworkPolicy{} 12 | 13 | func Test_OperationErrorReason(t *testing.T) { 14 | reason := OperationErrorReason(object) 15 | assert.Equal(t, "NetworkPolicyError", reason) 16 | } 17 | 18 | func Test_OperationReason(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | op controllerutil.OperationResult 22 | expected string 23 | }{ 24 | {"created", controllerutil.OperationResultCreated, "NetworkPolicyCreated"}, 25 | {"updated", controllerutil.OperationResultUpdated, "NetworkPolicyUpdated"}, 26 | {"status updated", controllerutil.OperationResultUpdatedStatus, "NetworkPolicyStatusUpdated"}, 27 | {"status updated only", controllerutil.OperationResultUpdatedStatusOnly, "NetworkPolicyStatusUpdated"}, 28 | {"unchanged", controllerutil.OperationResultNone, "NetworkPolicyUnchanged"}, 29 | } 30 | 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | reason := OperationReason(object, tt.op) 34 | assert.Equal(t, tt.expected, reason) 35 | }) 36 | } 37 | } 38 | 39 | func Test_OperationMessage(t *testing.T) { 40 | tests := []struct { 41 | name string 42 | op controllerutil.OperationResult 43 | expected string 44 | }{ 45 | {"created", controllerutil.OperationResultCreated, "NetworkPolicy was created"}, 46 | {"updated", controllerutil.OperationResultUpdated, "NetworkPolicy was updated"}, 47 | {"status updated", controllerutil.OperationResultUpdatedStatus, "NetworkPolicy had it's status updated"}, 48 | {"status updated only", controllerutil.OperationResultUpdatedStatusOnly, "NetworkPolicy had it's status updated"}, 49 | {"unchanged", controllerutil.OperationResultNone, "NetworkPolicy is unchanged"}, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | msg := OperationMessage(object, tt.op) 55 | assert.Equal(t, tt.expected, msg) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /api/v1alpha1/network_policy_conditions.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/api/meta" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // SetResolveCondition updates the Resolve condition based on the provided reason and message. 9 | // If the reason indicates success, the status is set to True with a standard success message. 10 | func (np *NetworkPolicy) SetResolveCondition(reason NetworkPolicyResolveConditionReason, message string) { 11 | condition := metav1.ConditionFalse 12 | if reason == NetworkPolicyResolveSuccess { 13 | condition = metav1.ConditionTrue 14 | message = "The network policy resolved successfully." 15 | } 16 | meta.SetStatusCondition(&np.Status.Conditions, metav1.Condition{ 17 | Type: string(NetworkPolicyResolveCondition), 18 | Status: condition, 19 | Reason: string(reason), 20 | Message: message, 21 | ObservedGeneration: np.GetGeneration(), 22 | }) 23 | } 24 | 25 | // SetReadyConditionTrue sets the Ready condition to True with a standard success message. 26 | // Updates the ObservedGeneration to reflect the current spec generation. 27 | func (np *NetworkPolicy) SetReadyConditionTrue(reason NetworkPolicyReadyConditionReason, message string) { 28 | meta.SetStatusCondition(&np.Status.Conditions, metav1.Condition{ 29 | Type: string(NetworkPolicyReadyCondition), 30 | Status: metav1.ConditionTrue, 31 | Reason: string(reason), 32 | Message: message, 33 | ObservedGeneration: np.GetGeneration(), 34 | }) 35 | np.Status.ObservedGeneration = np.GetGeneration() 36 | } 37 | 38 | // SetReadyConditionFalse sets the Ready condition to False with the provided reason and message. 39 | func (np *NetworkPolicy) SetReadyConditionFalse(reason NetworkPolicyReadyConditionReason, message string) { 40 | meta.SetStatusCondition(&np.Status.Conditions, metav1.Condition{ 41 | Type: string(NetworkPolicyReadyCondition), 42 | Status: metav1.ConditionFalse, 43 | Reason: string(reason), 44 | Message: message, 45 | ObservedGeneration: np.GetGeneration(), 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /internal/controller/network_policy_delete_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | 8 | netv1 "k8s.io/api/networking/v1" 9 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/types" 12 | "k8s.io/client-go/tools/record" 13 | ) 14 | 15 | var _ = Describe("NetworkPolicyReconciler", func() { 16 | Context("when calling reconcileNetworkPolicyDeletion", func() { 17 | var ( 18 | reconciler *NetworkPolicyReconciler 19 | np *v1alpha1.NetworkPolicy 20 | networkPolicy *netv1.NetworkPolicy 21 | ) 22 | 23 | BeforeEach(func() { 24 | np = &v1alpha1.NetworkPolicy{ 25 | ObjectMeta: metav1.ObjectMeta{ 26 | Name: "test-delete-policy", 27 | Namespace: "default", 28 | }, 29 | } 30 | Expect(k8sClient.Create(ctx, np)).To(Succeed()) 31 | 32 | reconciler = &NetworkPolicyReconciler{ 33 | Client: k8sClient, 34 | Scheme: k8sClient.Scheme(), 35 | EventRecorder: record.NewFakeRecorder(10), 36 | } 37 | }) 38 | 39 | AfterEach(func() { 40 | Expect(k8sClient.Delete(ctx, np)).To(Succeed()) 41 | }) 42 | 43 | It("should successfully delete the network policy and emit event", func() { 44 | networkPolicy = &netv1.NetworkPolicy{ 45 | ObjectMeta: metav1.ObjectMeta{ 46 | Name: np.Name, 47 | Namespace: np.Namespace, 48 | }, 49 | } 50 | Expect(k8sClient.Create(ctx, networkPolicy)).To(Succeed()) 51 | 52 | // Sanity check 53 | found := &netv1.NetworkPolicy{} 54 | Expect(k8sClient.Get(ctx, types.NamespacedName{Name: np.Name, Namespace: np.Namespace}, found)).To(Succeed()) 55 | 56 | // Perform deletion 57 | err := reconciler.reconcileNetworkPolicyDeletion(ctx, np) 58 | Expect(err).NotTo(HaveOccurred()) 59 | 60 | // Verify deletion 61 | err = k8sClient.Get(ctx, types.NamespacedName{Name: np.Name, Namespace: np.Namespace}, found) 62 | Expect(k8serrors.IsNotFound(err)).To(BeTrue()) 63 | }) 64 | 65 | It("should not return error if the network policy does not exist", func() { 66 | // Do not create the network policy 67 | err := reconciler.reconcileNetworkPolicyDeletion(ctx, np) 68 | Expect(err).NotTo(HaveOccurred()) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /api/v1alpha1/network_policy_types_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_NetworkType_ResolverString(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input NetworkType 13 | expected string 14 | }{ 15 | {"returns ip for All", All, "ip"}, 16 | {"returns ip4 for Ipv4", Ipv4, "ip4"}, 17 | {"returns ip6 for Ipv6", Ipv6, "ip6"}, 18 | {"returns empty string for unknown type", NetworkType("invalid"), ""}, 19 | } 20 | 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | result := tt.input.ResolverString() 24 | assert.Equal(t, tt.expected, result) 25 | }) 26 | } 27 | } 28 | 29 | func Test_NetworkPolicyResolveConditionReason_Priority(t *testing.T) { 30 | tests := []struct { 31 | name string 32 | reason NetworkPolicyResolveConditionReason 33 | expected int 34 | }{ 35 | {"OtherError returns 6", NetworkPolicyResolveOtherError, 6}, 36 | {"InvalidDomain returns 5", NetworkPolicyResolveInvalidDomain, 5}, 37 | {"DomainNotFound returns 4", NetworkPolicyResolveDomainNotFound, 4}, 38 | {"Timeout returns 3", NetworkPolicyResolveTimeout, 3}, 39 | {"TemporaryError returns 2", NetworkPolicyResolveTemporaryError, 2}, 40 | {"Unknown returns 1", NetworkPolicyResolveUnknown, 1}, 41 | {"Success returns 0", NetworkPolicyResolveSuccess, 0}, 42 | {"Unrecognized reason returns 0", NetworkPolicyResolveConditionReason("garbage"), 0}, 43 | } 44 | 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | actual := tt.reason.Priority() 48 | assert.Equal(t, tt.expected, actual) 49 | }) 50 | } 51 | } 52 | 53 | func Test_NetworkPolicyResolveConditionReason_Transient(t *testing.T) { 54 | tests := []struct { 55 | name string 56 | reason NetworkPolicyResolveConditionReason 57 | expected bool 58 | }{ 59 | { 60 | name: "InvalidDomain is not transient", 61 | reason: NetworkPolicyResolveInvalidDomain, 62 | expected: false, 63 | }, 64 | { 65 | name: "DomainNotFound is not transient", 66 | reason: NetworkPolicyResolveDomainNotFound, 67 | expected: false, 68 | }, 69 | { 70 | name: "Unknown reason is transient", 71 | reason: "FooBar", // arbitrary string not matched by switch 72 | expected: true, 73 | }, 74 | } 75 | 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | actual := tt.reason.Transient() 79 | assert.Equal(t, tt.expected, actual) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pkg/utils/network_policy_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | netv1 "k8s.io/api/networking/v1" 8 | ) 9 | 10 | func Test_UniqueCidrsInNetworkPolicy(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | policy *netv1.NetworkPolicy 14 | expected []string // expected CIDR strings 15 | }{ 16 | { 17 | name: "nil network policy", 18 | policy: nil, 19 | expected: []string{}, 20 | }, 21 | { 22 | name: "ingress and egress with unique CIDRs", 23 | policy: &netv1.NetworkPolicy{ 24 | Spec: netv1.NetworkPolicySpec{ 25 | Ingress: []netv1.NetworkPolicyIngressRule{ 26 | { 27 | From: []netv1.NetworkPolicyPeer{ 28 | {IPBlock: &netv1.IPBlock{CIDR: "1.1.1.1/32"}}, 29 | {IPBlock: &netv1.IPBlock{CIDR: "2.2.2.2/32"}}, 30 | }, 31 | }, 32 | }, 33 | Egress: []netv1.NetworkPolicyEgressRule{ 34 | { 35 | To: []netv1.NetworkPolicyPeer{ 36 | {IPBlock: &netv1.IPBlock{CIDR: "3.3.3.3/32"}}, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | expected: []string{"1.1.1.1/32", "2.2.2.2/32", "3.3.3.3/32"}, 43 | }, 44 | { 45 | name: "duplicate CIDRs in ingress and egress", 46 | policy: &netv1.NetworkPolicy{ 47 | Spec: netv1.NetworkPolicySpec{ 48 | Ingress: []netv1.NetworkPolicyIngressRule{ 49 | {From: []netv1.NetworkPolicyPeer{ 50 | {IPBlock: &netv1.IPBlock{CIDR: "4.4.4.4/32"}}, 51 | }}, 52 | }, 53 | Egress: []netv1.NetworkPolicyEgressRule{ 54 | {To: []netv1.NetworkPolicyPeer{ 55 | {IPBlock: &netv1.IPBlock{CIDR: "4.4.4.4/32"}}, 56 | }}, 57 | }, 58 | }, 59 | }, 60 | expected: []string{"4.4.4.4/32"}, 61 | }, 62 | { 63 | name: "include 0.0.0.0/0 CIDR", 64 | policy: &netv1.NetworkPolicy{ 65 | Spec: netv1.NetworkPolicySpec{ 66 | Ingress: []netv1.NetworkPolicyIngressRule{ 67 | {From: []netv1.NetworkPolicyPeer{ 68 | {IPBlock: &netv1.IPBlock{CIDR: "0.0.0.0/0"}}, 69 | {IPBlock: &netv1.IPBlock{CIDR: "10.0.0.0/8"}}, 70 | }}, 71 | }, 72 | }, 73 | }, 74 | expected: []string{"0.0.0.0/0", "10.0.0.0/8"}, 75 | }, 76 | } 77 | 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | cidrs := UniqueCidrsInNetworkPolicy(tt.policy) 81 | var result []string 82 | for _, c := range cidrs { 83 | result = append(result, c.String()) 84 | } 85 | assert.ElementsMatch(t, tt.expected, result) 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /internal/controller/network_policy_create_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | netv1 "k8s.io/api/networking/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/types" 10 | "k8s.io/client-go/tools/record" 11 | ) 12 | 13 | var _ = Describe("NetworkPolicyReconciler", func() { 14 | Context("when calling reconcileNetworkPolicyCreation", func() { 15 | var ( 16 | reconciler *NetworkPolicyReconciler 17 | np *v1alpha1.NetworkPolicy 18 | networkPolicy *netv1.NetworkPolicy 19 | ) 20 | 21 | BeforeEach(func() { 22 | np = &v1alpha1.NetworkPolicy{ 23 | ObjectMeta: metav1.ObjectMeta{ 24 | Name: "test-policy", 25 | Namespace: "default", 26 | }, 27 | } 28 | 29 | networkPolicy = &netv1.NetworkPolicy{ 30 | ObjectMeta: metav1.ObjectMeta{ 31 | Name: np.Name, 32 | Namespace: np.Namespace, 33 | Labels: map[string]string{ 34 | "app": "test", 35 | }, 36 | Annotations: map[string]string{ 37 | "note": "created by test", 38 | }, 39 | }, 40 | Spec: netv1.NetworkPolicySpec{ 41 | PodSelector: metav1.LabelSelector{ 42 | MatchLabels: map[string]string{ 43 | "role": "db", 44 | }, 45 | }, 46 | PolicyTypes: []netv1.PolicyType{netv1.PolicyTypeEgress}, 47 | }, 48 | } 49 | 50 | reconciler = &NetworkPolicyReconciler{ 51 | Client: k8sClient, 52 | Scheme: k8sClient.Scheme(), 53 | EventRecorder: record.NewFakeRecorder(10), 54 | } 55 | 56 | // Create the parent NetworkPolicy so the controller reference works 57 | Expect(k8sClient.Create(ctx, np)).To(Succeed()) 58 | }) 59 | 60 | AfterEach(func() { 61 | Expect(k8sClient.Delete(ctx, np)).To(Succeed()) 62 | }) 63 | 64 | It("should create the network policy and set controller reference", func() { 65 | err := reconciler.reconcileNetworkPolicyCreation(ctx, np, networkPolicy) 66 | Expect(err).NotTo(HaveOccurred()) 67 | 68 | current := &netv1.NetworkPolicy{} 69 | Expect(k8sClient.Get(ctx, types.NamespacedName{Name: np.Name, Namespace: np.Namespace}, current)).To(Succeed()) 70 | 71 | Expect(current.Labels).To(Equal(networkPolicy.Labels)) 72 | Expect(current.Annotations).To(Equal(networkPolicy.Annotations)) 73 | Expect(current.Spec.PodSelector.MatchLabels).To(Equal(networkPolicy.Spec.PodSelector.MatchLabels)) 74 | Expect(current.Spec.PolicyTypes).To(Equal(networkPolicy.Spec.PolicyTypes)) 75 | 76 | // Confirm the owner reference is set correctly 77 | Expect(current.OwnerReferences).To(HaveLen(1)) 78 | Expect(current.OwnerReferences[0].Kind).To(Equal("NetworkPolicy")) 79 | Expect(current.OwnerReferences[0].Name).To(Equal(np.Name)) 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /api/v1alpha1/network_policy_conditions_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "k8s.io/apimachinery/pkg/api/meta" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | ) 10 | 11 | func Test_SetReadyConditionTrue(t *testing.T) { 12 | np := &NetworkPolicy{} 13 | np.Generation = int64(2) 14 | np.Status.ObservedGeneration = int64(1) 15 | np.SetReadyConditionTrue(NetworkPolicyReady, "Ready") 16 | 17 | cond := meta.FindStatusCondition(np.Status.Conditions, string(NetworkPolicyReadyCondition)) 18 | assert.NotNil(t, cond) 19 | assert.Equal(t, metav1.ConditionTrue, cond.Status) 20 | assert.Equal(t, string(NetworkPolicyReady), cond.Reason) 21 | assert.Equal(t, np.Generation, cond.ObservedGeneration) 22 | assert.Equal(t, np.Generation, np.Status.ObservedGeneration) 23 | } 24 | 25 | func Test_SetReadyConditionFalse(t *testing.T) { 26 | np := &NetworkPolicy{} 27 | np.Generation = int64(2) 28 | np.Status.ObservedGeneration = int64(1) 29 | np.SetReadyConditionFalse(NetworkPolicyFailed, "Failure!") 30 | 31 | cond := meta.FindStatusCondition(np.Status.Conditions, string(NetworkPolicyReadyCondition)) 32 | assert.NotNil(t, cond) 33 | assert.Equal(t, metav1.ConditionFalse, cond.Status) 34 | assert.Equal(t, string(NetworkPolicyFailed), cond.Reason) 35 | assert.Equal(t, "Failure!", cond.Message) 36 | assert.Equal(t, np.Generation, cond.ObservedGeneration) 37 | assert.NotEqual(t, np.Generation, np.Status.ObservedGeneration) 38 | } 39 | 40 | func Test_SetResolveCondition_Success(t *testing.T) { 41 | np := &NetworkPolicy{} 42 | np.Generation = int64(2) 43 | np.Status.ObservedGeneration = int64(1) 44 | np.SetResolveCondition(NetworkPolicyResolveSuccess, "") 45 | 46 | cond := meta.FindStatusCondition(np.Status.Conditions, string(NetworkPolicyResolveCondition)) 47 | assert.NotNil(t, cond) 48 | assert.Equal(t, metav1.ConditionTrue, cond.Status) 49 | assert.Equal(t, string(NetworkPolicyResolveSuccess), cond.Reason) 50 | assert.Equal(t, "The network policy resolved successfully.", cond.Message) 51 | assert.Equal(t, np.Generation, cond.ObservedGeneration) 52 | assert.NotEqual(t, np.Generation, np.Status.ObservedGeneration) 53 | } 54 | 55 | func Test_SetResolveCondition_Error(t *testing.T) { 56 | np := &NetworkPolicy{} 57 | np.Generation = int64(2) 58 | np.Status.ObservedGeneration = int64(1) 59 | np.SetResolveCondition(NetworkPolicyResolveDomainNotFound, "Error happened!") 60 | 61 | cond := meta.FindStatusCondition(np.Status.Conditions, string(NetworkPolicyResolveCondition)) 62 | assert.NotNil(t, cond) 63 | assert.Equal(t, metav1.ConditionFalse, cond.Status) 64 | assert.Equal(t, string(NetworkPolicyResolveDomainNotFound), cond.Reason) 65 | assert.Equal(t, "Error happened!", cond.Message) 66 | assert.Equal(t, np.Generation, cond.ObservedGeneration) 67 | assert.NotEqual(t, np.Generation, np.Status.ObservedGeneration) 68 | } 69 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: fqdn-controller 7 | app.kubernetes.io/managed-by: kustomize 8 | name: system 9 | --- 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | metadata: 13 | name: controller-manager 14 | namespace: system 15 | labels: 16 | control-plane: controller-manager 17 | app.kubernetes.io/name: fqdn-controller 18 | app.kubernetes.io/managed-by: kustomize 19 | spec: 20 | selector: 21 | matchLabels: 22 | control-plane: controller-manager 23 | app.kubernetes.io/name: fqdn-controller 24 | replicas: 1 25 | template: 26 | metadata: 27 | annotations: 28 | kubectl.kubernetes.io/default-container: manager 29 | labels: 30 | control-plane: controller-manager 31 | app.kubernetes.io/name: fqdn-controller 32 | spec: 33 | # TODO(user): Uncomment the following code to configure the nodeAffinity expression 34 | # according to the platforms which are supported by your solution. 35 | # It is considered best practice to support multiple architectures. You can 36 | # build your manager image using the makefile target docker-buildx. 37 | # affinity: 38 | # nodeAffinity: 39 | # requiredDuringSchedulingIgnoredDuringExecution: 40 | # nodeSelectorTerms: 41 | # - matchExpressions: 42 | # - key: kubernetes.io/arch 43 | # operator: In 44 | # values: 45 | # - amd64 46 | # - arm64 47 | # - ppc64le 48 | # - s390x 49 | # - key: kubernetes.io/os 50 | # operator: In 51 | # values: 52 | # - linux 53 | securityContext: 54 | # Projects are configured by default to adhere to the "restricted" Pod Security Standards. 55 | # This ensures that deployments meet the highest security requirements for Kubernetes. 56 | # For more details, see: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted 57 | runAsNonRoot: true 58 | seccompProfile: 59 | type: RuntimeDefault 60 | containers: 61 | - command: 62 | - /manager 63 | args: 64 | - --leader-elect 65 | - --health-probe-bind-address=:8081 66 | image: controller:latest 67 | name: manager 68 | ports: [] 69 | securityContext: 70 | allowPrivilegeEscalation: false 71 | capabilities: 72 | drop: 73 | - "ALL" 74 | livenessProbe: 75 | httpGet: 76 | path: /healthz 77 | port: 8081 78 | initialDelaySeconds: 15 79 | periodSeconds: 20 80 | readinessProbe: 81 | httpGet: 82 | path: /readyz 83 | port: 8081 84 | initialDelaySeconds: 5 85 | periodSeconds: 10 86 | resources: 87 | limits: 88 | cpu: 500m 89 | memory: 128Mi 90 | requests: 91 | cpu: 100m 92 | memory: 64Mi 93 | volumeMounts: [] 94 | volumes: [] 95 | serviceAccountName: controller-manager 96 | terminationGracePeriodSeconds: 10 97 | -------------------------------------------------------------------------------- /test/utils/curlpod.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | 10 | corev1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/types" 13 | "k8s.io/utils/ptr" 14 | ) 15 | 16 | // NewCurlPod returns a long-lived curl pod definition with the given name, namespace, and labels. 17 | // The pod sleeps indefinitely and can be exec'd into for manual curl commands. 18 | func NewCurlPod(name, namespace string, labels map[string]string) *corev1.Pod { 19 | return &corev1.Pod{ 20 | TypeMeta: metav1.TypeMeta{ 21 | APIVersion: "v1", 22 | Kind: "Pod", 23 | }, 24 | ObjectMeta: metav1.ObjectMeta{ 25 | Name: name, 26 | Namespace: namespace, 27 | Labels: labels, 28 | }, 29 | Spec: corev1.PodSpec{ 30 | RestartPolicy: corev1.RestartPolicyAlways, 31 | Containers: []corev1.Container{ 32 | { 33 | Name: "curl", 34 | Image: "curlimages/curl:latest", 35 | Command: []string{"sh", "-c", "sleep infinity"}, 36 | SecurityContext: &corev1.SecurityContext{ 37 | AllowPrivilegeEscalation: ptr.To(false), 38 | RunAsNonRoot: ptr.To(true), 39 | RunAsUser: ptr.To(int64(1001)), 40 | Capabilities: &corev1.Capabilities{ 41 | Drop: []corev1.Capability{"ALL"}, 42 | }, 43 | SeccompProfile: &corev1.SeccompProfile{ 44 | Type: corev1.SeccompProfileTypeRuntimeDefault, 45 | }, 46 | }, 47 | }, 48 | }, 49 | }, 50 | } 51 | } 52 | 53 | // curlFromPod runs a curl command in the specified pod. 54 | func curlFromPod(podName, namespace, url string, connectTimeout, maxTime int) (string, error) { 55 | cmd := exec.Command("kubectl", "exec", podName, "-n", namespace, "--", 56 | "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", 57 | "--connect-timeout", fmt.Sprintf("%d", connectTimeout), 58 | "--max-time", fmt.Sprintf("%d", maxTime), 59 | url, 60 | ) 61 | 62 | var out bytes.Buffer 63 | cmd.Stdout = &out 64 | err := cmd.Run() 65 | return out.String(), err 66 | } 67 | 68 | // CurlSuccess returns true if the curl output indicates a successful HTTP request. 69 | func CurlSuccess(pod types.NamespacedName, url string, timeoutSeconds int) (bool, string, error) { 70 | response, err := curlFromPod(pod.Name, pod.Namespace, url, timeoutSeconds, timeoutSeconds) 71 | statusLine := strings.TrimSpace(response) 72 | status, convErr := strconv.Atoi(statusLine) 73 | if convErr != nil { 74 | return false, response, fmt.Errorf("failed to parse HTTP status code from response: %q", response) 75 | } 76 | if status == 0 { 77 | return false, response, nil // "000" means no HTTP response 78 | } 79 | return status < 400, response, err // err may be non-nil but still OK for HTTP 301 etc. 80 | } 81 | 82 | // CurlFailure returns true if the curl output shows failure. 83 | func CurlFailure(pod types.NamespacedName, url string, timeoutSeconds int) (bool, string, error) { 84 | success, response, err := CurlSuccess(pod, url, timeoutSeconds) 85 | 86 | if err != nil { 87 | // Treat known exit codes (like timeout) as valid failure cases 88 | if strings.Contains(err.Error(), "exit status 28") || 89 | strings.Contains(err.Error(), "exit status 7") || 90 | strings.Contains(err.Error(), "exit status 6") { 91 | return true, response, nil 92 | } 93 | // Any other errors are real 94 | return false, response, err 95 | } 96 | 97 | return !success, response, nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/webhook/v1alpha1/network_policy_webhook_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 v1alpha1 18 | 19 | import ( 20 | . "github.com/onsi/ginkgo/v2" 21 | . "github.com/onsi/gomega" 22 | 23 | fqdnv1alpha1 "github.com/konsole-is/fqdn-controller/api/v1alpha1" 24 | // TODO (user): Add any additional imports if needed 25 | ) 26 | 27 | var _ = Describe("NetworkPolicy Webhook", func() { 28 | var ( 29 | obj *fqdnv1alpha1.NetworkPolicy 30 | oldObj *fqdnv1alpha1.NetworkPolicy 31 | validator NetworkPolicyCustomValidator 32 | defaulter NetworkPolicyCustomDefaulter 33 | ) 34 | 35 | BeforeEach(func() { 36 | obj = &fqdnv1alpha1.NetworkPolicy{} 37 | oldObj = &fqdnv1alpha1.NetworkPolicy{} 38 | validator = NetworkPolicyCustomValidator{} 39 | Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") 40 | defaulter = NetworkPolicyCustomDefaulter{} 41 | Expect(defaulter).NotTo(BeNil(), "Expected defaulter to be initialized") 42 | Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") 43 | Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") 44 | // TODO (user): Add any setup logic common to all tests 45 | }) 46 | 47 | AfterEach(func() { 48 | // TODO (user): Add any teardown logic common to all tests 49 | }) 50 | 51 | Context("When creating NetworkPolicy under Defaulting Webhook", func() { 52 | // TODO (user): Add logic for defaulting webhooks 53 | // Example: 54 | // It("Should apply defaults when a required field is empty", func() { 55 | // By("simulating a scenario where defaults should be applied") 56 | // obj.SomeFieldWithDefault = "" 57 | // By("calling the Default method to apply defaults") 58 | // defaulter.Default(ctx, obj) 59 | // By("checking that the default values are set") 60 | // Expect(obj.SomeFieldWithDefault).To(Equal("default_value")) 61 | // }) 62 | }) 63 | 64 | Context("When creating or updating NetworkPolicy under Validating Webhook", func() { 65 | // TODO (user): Add logic for validating webhooks 66 | // Example: 67 | // It("Should deny creation if a required field is missing", func() { 68 | // By("simulating an invalid creation scenario") 69 | // obj.SomeRequiredField = "" 70 | // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) 71 | // }) 72 | // 73 | // It("Should admit creation if all required fields are present", func() { 74 | // By("simulating an invalid creation scenario") 75 | // obj.SomeRequiredField = "valid_value" 76 | // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) 77 | // }) 78 | // 79 | // It("Should validate updates correctly", func() { 80 | // By("simulating a valid update scenario") 81 | // oldObj.SomeRequiredField = "updated_value" 82 | // obj.SomeRequiredField = "updated_value" 83 | // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) 84 | // }) 85 | }) 86 | 87 | }) 88 | -------------------------------------------------------------------------------- /test/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 e2e 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | "os/exec" 23 | "testing" 24 | 25 | . "github.com/onsi/ginkgo/v2" 26 | . "github.com/onsi/gomega" 27 | 28 | "github.com/konsole-is/fqdn-controller/test/utils" 29 | ) 30 | 31 | var ( 32 | // Optional Environment Variables: 33 | // - CERT_MANAGER_INSTALL_SKIP=true: Skips CertManager installation during test setup. 34 | // These variables are useful if CertManager is already installed, avoiding 35 | // re-installation and conflicts. 36 | skipCertManagerInstall = os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true" 37 | // isCertManagerAlreadyInstalled will be set true when CertManager CRDs be found on the cluster 38 | isCertManagerAlreadyInstalled = false 39 | 40 | // projectImage is the name of the image which will be build and loaded 41 | // with the code source changes to be tested. 42 | projectImage = "example.com/fqdn-controller:v0.0.1" 43 | ) 44 | 45 | // TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated, 46 | // temporary environment to validate project changes with the purposed to be used in CI jobs. 47 | // The default setup requires Kind, builds/loads the Manager Docker image locally, and installs 48 | // CertManager. 49 | func TestE2E(t *testing.T) { 50 | RegisterFailHandler(Fail) 51 | _, _ = fmt.Fprintf(GinkgoWriter, "Starting fqdn-controller integration test suite\n") 52 | RunSpecs(t, "e2e suite") 53 | } 54 | 55 | var _ = BeforeSuite(func() { 56 | By("building the manager(Operator) image") 57 | cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectImage)) 58 | _, err := utils.Run(cmd) 59 | ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") 60 | 61 | By("loading the manager(Operator) image on Kind") 62 | err = utils.LoadImageToKindClusterWithName(projectImage) 63 | ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind") 64 | 65 | // The tests-e2e are intended to run on a temporary cluster that is created and destroyed for testing. 66 | // To prevent errors when tests run in environments with CertManager already installed, 67 | // we check for its presence before execution. 68 | // Setup CertManager before the suite if not skipped and if not already installed 69 | if !skipCertManagerInstall { 70 | By("checking if cert manager is installed already") 71 | isCertManagerAlreadyInstalled = utils.IsCertManagerCRDsInstalled() 72 | if !isCertManagerAlreadyInstalled { 73 | _, _ = fmt.Fprintf(GinkgoWriter, "Installing CertManager...\n") 74 | Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") 75 | } else { 76 | _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: CertManager is already installed. Skipping installation...\n") 77 | } 78 | } 79 | }) 80 | 81 | var _ = AfterSuite(func() { 82 | // Teardown CertManager after the suite if not skipped and if it was not already installed 83 | if !skipCertManagerInstall && !isCertManagerAlreadyInstalled { 84 | _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling CertManager...\n") 85 | utils.UninstallCertManager() 86 | } 87 | }) 88 | -------------------------------------------------------------------------------- /internal/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 controller 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "path/filepath" 23 | "testing" 24 | 25 | . "github.com/onsi/ginkgo/v2" 26 | . "github.com/onsi/gomega" 27 | 28 | "k8s.io/client-go/kubernetes/scheme" 29 | "k8s.io/client-go/rest" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | "sigs.k8s.io/controller-runtime/pkg/envtest" 32 | logf "sigs.k8s.io/controller-runtime/pkg/log" 33 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 34 | 35 | fqdnv1alpha1 "github.com/konsole-is/fqdn-controller/api/v1alpha1" 36 | // +kubebuilder:scaffold:imports 37 | ) 38 | 39 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 40 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 41 | 42 | var ( 43 | ctx context.Context 44 | cancel context.CancelFunc 45 | testEnv *envtest.Environment 46 | cfg *rest.Config 47 | k8sClient client.Client 48 | ) 49 | 50 | func TestControllers(t *testing.T) { 51 | RegisterFailHandler(Fail) 52 | 53 | RunSpecs(t, "Controller Suite") 54 | } 55 | 56 | var _ = BeforeSuite(func() { 57 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 58 | 59 | ctx, cancel = context.WithCancel(context.TODO()) 60 | 61 | var err error 62 | err = fqdnv1alpha1.AddToScheme(scheme.Scheme) 63 | Expect(err).NotTo(HaveOccurred()) 64 | 65 | // +kubebuilder:scaffold:scheme 66 | 67 | By("bootstrapping test environment") 68 | testEnv = &envtest.Environment{ 69 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, 70 | ErrorIfCRDPathMissing: true, 71 | } 72 | 73 | // Retrieve the first found binary directory to allow running tests from IDEs 74 | if getFirstFoundEnvTestBinaryDir() != "" { 75 | testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() 76 | } 77 | 78 | // cfg is defined in this file globally. 79 | cfg, err = testEnv.Start() 80 | Expect(err).NotTo(HaveOccurred()) 81 | Expect(cfg).NotTo(BeNil()) 82 | 83 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 84 | Expect(err).NotTo(HaveOccurred()) 85 | Expect(k8sClient).NotTo(BeNil()) 86 | }) 87 | 88 | var _ = AfterSuite(func() { 89 | By("tearing down the test environment") 90 | cancel() 91 | err := testEnv.Stop() 92 | Expect(err).NotTo(HaveOccurred()) 93 | }) 94 | 95 | // getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. 96 | // ENVTEST-based tests depend on specific binaries, usually located in paths set by 97 | // controller-runtime. When running tests directly (e.g., via an IDE) without using 98 | // Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. 99 | // 100 | // This function streamlines the process by finding the required binaries, similar to 101 | // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are 102 | // properly set up, run 'make setup-envtest' beforehand. 103 | func getFirstFoundEnvTestBinaryDir() string { 104 | basePath := filepath.Join("..", "..", "bin", "k8s") 105 | entries, err := os.ReadDir(basePath) 106 | if err != nil { 107 | logf.Log.Error(err, "Failed to read directory", "path", basePath) 108 | return "" 109 | } 110 | for _, entry := range entries { 111 | if entry.IsDir() { 112 | return filepath.Join(basePath, entry.Name()) 113 | } 114 | } 115 | return "" 116 | } 117 | -------------------------------------------------------------------------------- /.github/workflows/test-chart.yml: -------------------------------------------------------------------------------- 1 | name: Test Chart 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test-e2e: 11 | name: Run on Ubuntu 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Clone the code 15 | uses: actions/checkout@v5 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v6 19 | with: 20 | go-version-file: go.mod 21 | 22 | - name: Install kubebuilder 23 | run: | 24 | VERSION=4.6.0 25 | OS=$(go env GOOS) 26 | ARCH=$(go env GOARCH) 27 | curl -L -o kubebuilder "https://github.com/kubernetes-sigs/kubebuilder/releases/download/v${VERSION}/kubebuilder_${OS}_${ARCH}" 28 | chmod +x kubebuilder && sudo mv kubebuilder /usr/local/bin/ 29 | 30 | - name: Install the latest version of kind 31 | run: | 32 | curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 33 | chmod +x ./kind 34 | sudo mv ./kind /usr/local/bin/kind 35 | 36 | - name: Verify kind installation 37 | run: kind version 38 | 39 | - name: Create kind cluster 40 | run: kind create cluster 41 | 42 | - name: Prepare fqdn-controller 43 | run: | 44 | go mod tidy 45 | make docker-build IMG=fqdn-controller:v0.1.0 46 | kind load docker-image fqdn-controller:v0.1.0 47 | 48 | - name: Install Helm 49 | run: | 50 | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash 51 | 52 | - name: Verify Helm installation 53 | run: helm version 54 | 55 | - name: Generate helm chart 56 | run: make build-installer 57 | 58 | - name: Lint Helm Chart 59 | run: | 60 | make build-installer 61 | helm lint ./dist/chart 62 | 63 | - name: Install cert-manager via Helm 64 | run: | 65 | helm repo add jetstack https://charts.jetstack.io 66 | helm repo update 67 | helm install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace --set installCRDs=true 68 | 69 | - name: Wait for cert-manager to be ready 70 | run: | 71 | kubectl wait --namespace cert-manager --for=condition=available --timeout=300s deployment/cert-manager 72 | kubectl wait --namespace cert-manager --for=condition=available --timeout=300s deployment/cert-manager-cainjector 73 | kubectl wait --namespace cert-manager --for=condition=available --timeout=300s deployment/cert-manager-webhook 74 | 75 | - name: Install Prometheus Operator CRDs 76 | run: | 77 | helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 78 | helm repo update 79 | helm install prometheus-crds prometheus-community/prometheus-operator-crds 80 | 81 | - name: Install Prometheus via Helm 82 | run: | 83 | helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 84 | helm repo update 85 | helm install prometheus prometheus-community/prometheus --namespace monitoring --create-namespace 86 | 87 | - name: Wait for Prometheus to be ready 88 | run: | 89 | kubectl wait --namespace monitoring --for=condition=available --timeout=300s deployment/prometheus-server 90 | 91 | - name: Install Helm chart for project 92 | run: | 93 | helm install fqdn-controller ./dist/chart --create-namespace --namespace fqdn-controller-system --set prometheus.enable=true 94 | 95 | - name: Check Helm release status 96 | run: | 97 | helm status fqdn-controller --namespace fqdn-controller-system 98 | 99 | - name: Check Presence of ServiceMonitor 100 | run: | 101 | kubectl wait --namespace fqdn-controller-system --for=jsonpath='{.kind}'=ServiceMonitor servicemonitor/fqdn-controller-controller-manager-metrics-monitor 102 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | name: Build and Publish Artifacts 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | packages: write 15 | 16 | env: 17 | TAG: ${{ github.ref_name }} 18 | IMG: ghcr.io/${{ github.repository }}:${{ github.ref_name }} 19 | 20 | steps: 21 | - name: Checkout repo 22 | uses: actions/checkout@v5 23 | 24 | - name: Import GPG key for Helm signing 25 | env: 26 | KEY: ${{ secrets.HELM_GPG_KEYRING }} 27 | FINGERPRINT: ${{ secrets.HELM_GPG_KEY_FINGERPRINT }} 28 | PASSPHRASE: ${{ secrets.HELM_GPG_PASSPHRASE }} 29 | run: | 30 | # See https://gist.github.com/sourcehawk/ce6346cf0df5e53ced46402b9a1356f2 31 | echo -n "$KEY" > key.asc 32 | echo "$PASSPHRASE" | \ 33 | gpg --pinentry-mode loopback --passphrase-fd 0 --import key.asc 34 | mkdir -p ~/.gnupg-legacy 35 | chmod 700 ~/.gnupg-legacy 36 | echo "$PASSPHRASE" | \ 37 | gpg --pinentry-mode loopback --passphrase-fd 0 --export-secret-keys $FINGERPRINT > ~/.gnupg-legacy/secring.gpg 38 | gpg --export $FINGERPRINT > ~/.gnupg-legacy/pubring.gpg 39 | chmod 600 ~/.gnupg-legacy/secring.gpg 40 | chmod 600 ~/.gnupg-legacy/pubring.gpg 41 | gpg --no-default-keyring --secret-keyring ~/.gnupg-legacy/secring.gpg --list-secret-keys 42 | 43 | - name: Set up Go 44 | uses: actions/setup-go@v6 45 | with: 46 | go-version-file: go.mod 47 | 48 | - name: Install helm 49 | uses: azure/setup-helm@v4.3.1 50 | with: 51 | version: v3.18.4 52 | id: install 53 | 54 | - name: Install kubebuilder 55 | run: | 56 | VERSION=4.6.0 57 | OS=$(go env GOOS) 58 | ARCH=$(go env GOARCH) 59 | curl -L -o kubebuilder "https://github.com/kubernetes-sigs/kubebuilder/releases/download/v${VERSION}/kubebuilder_${OS}_${ARCH}" 60 | chmod +x kubebuilder && sudo mv kubebuilder /usr/local/bin/ 61 | 62 | - name: Generate Kustomize release manifests 63 | run: | 64 | make build-installer IMG=${IMG} 65 | 66 | - name: Package helm chart 67 | env: 68 | # IMPORTANT NOTE: This must not be the fingerprint but a substring of email/user 69 | GPG_KEY_UID: ${{ secrets.HELM_GPG_KEY_UID }} 70 | PASSPHRASE: ${{ secrets.HELM_GPG_PASSPHRASE }} 71 | run: | 72 | echo "$PASSPHRASE" | \ 73 | helm package --sign --key "$GPG_KEY_UID" --keyring ~/.gnupg-legacy/secring.gpg --passphrase-file "-" dist/chart 74 | cp fqdn-controller-${TAG}.tgz chart.tgz 75 | cp fqdn-controller-${TAG}.tgz.prov chart.tgz.prov 76 | mv fqdn-controller-${TAG}.tgz dist/ 77 | mv fqdn-controller-${TAG}.tgz.prov dist/ 78 | 79 | - name: Log in to GitHub Container Registry 80 | uses: docker/login-action@v3 81 | with: 82 | registry: ghcr.io 83 | username: ${{ github.actor }} 84 | password: ${{ secrets.GITHUB_TOKEN }} 85 | 86 | - name: Build Docker image 87 | run: make docker-build IMG=${IMG} 88 | 89 | - name: Push Docker image 90 | run: make docker-push IMG=${IMG} 91 | 92 | - name: Upload release files 93 | uses: softprops/action-gh-release@v2 94 | with: 95 | files: | 96 | dist/install.yaml 97 | dist/crds.yaml 98 | chart.tgz 99 | chart.tgz.prov 100 | 101 | - name: Set up Git for GitHub Pages 102 | if: github.event.release.prerelease == false 103 | run: | 104 | git config --global user.email "github-actions@users.noreply.github.com" 105 | git config --global user.name "github-actions" 106 | 107 | - name: Checkout gh-pages branch 108 | if: github.event.release.prerelease == false 109 | uses: actions/checkout@v5 110 | with: 111 | ref: gh-pages 112 | path: gh-pages 113 | 114 | - name: Copy Helm chart and update index.yaml 115 | if: github.event.release.prerelease == false 116 | run: | 117 | mkdir -p gh-pages/charts 118 | cp dist/fqdn-controller-${TAG}.tgz gh-pages/charts/ 119 | cp dist/fqdn-controller-${TAG}.tgz.prov gh-pages/charts/ 120 | cd gh-pages/charts 121 | helm repo index . --url https://konsole-is.github.io/fqdn-controller/charts 122 | 123 | - name: Push updated Helm repo to gh-pages 124 | if: github.event.release.prerelease == false 125 | run: | 126 | cd gh-pages 127 | git add charts 128 | git commit -m "Update Helm repo for ${TAG}" || echo "No changes to commit" 129 | git push origin gh-pages -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/konsole-is/fqdn-controller 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/onsi/ginkgo/v2 v2.27.2 7 | github.com/onsi/gomega v1.38.2 8 | github.com/stretchr/testify v1.11.0 9 | k8s.io/api v0.34.1 10 | k8s.io/apimachinery v0.34.1 11 | k8s.io/client-go v0.34.1 12 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 13 | sigs.k8s.io/controller-runtime v0.22.2 14 | sigs.k8s.io/yaml v1.6.0 15 | ) 16 | 17 | require ( 18 | cel.dev/expr v0.24.0 // indirect 19 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 20 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 21 | github.com/beorn7/perks v1.0.1 // indirect 22 | github.com/blang/semver/v4 v4.0.0 // indirect 23 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 24 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 27 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 28 | github.com/felixge/httpsnoop v1.0.4 // indirect 29 | github.com/fsnotify/fsnotify v1.9.0 // indirect 30 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 31 | github.com/go-logr/logr v1.4.3 // indirect 32 | github.com/go-logr/stdr v1.2.2 // indirect 33 | github.com/go-logr/zapr v1.3.0 // indirect 34 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 35 | github.com/go-openapi/jsonreference v0.20.2 // indirect 36 | github.com/go-openapi/swag v0.23.0 // indirect 37 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 38 | github.com/gogo/protobuf v1.3.2 // indirect 39 | github.com/google/btree v1.1.3 // indirect 40 | github.com/google/cel-go v0.26.0 // indirect 41 | github.com/google/gnostic-models v0.7.0 // indirect 42 | github.com/google/go-cmp v0.7.0 // indirect 43 | github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect 44 | github.com/google/uuid v1.6.0 // indirect 45 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 46 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 47 | github.com/josharian/intern v1.0.0 // indirect 48 | github.com/json-iterator/go v1.1.12 // indirect 49 | github.com/mailru/easyjson v0.7.7 // indirect 50 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 51 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 52 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 53 | github.com/pkg/errors v0.9.1 // indirect 54 | github.com/pmezard/go-difflib v1.0.0 // indirect 55 | github.com/prometheus/client_golang v1.22.0 // indirect 56 | github.com/prometheus/client_model v0.6.1 // indirect 57 | github.com/prometheus/common v0.62.0 // indirect 58 | github.com/prometheus/procfs v0.15.1 // indirect 59 | github.com/spf13/cobra v1.9.1 // indirect 60 | github.com/spf13/pflag v1.0.6 // indirect 61 | github.com/stoewer/go-strcase v1.3.0 // indirect 62 | github.com/x448/float16 v0.8.4 // indirect 63 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 64 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 65 | go.opentelemetry.io/otel v1.35.0 // indirect 66 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect 67 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect 68 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 69 | go.opentelemetry.io/otel/sdk v1.34.0 // indirect 70 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 71 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 72 | go.uber.org/multierr v1.11.0 // indirect 73 | go.uber.org/zap v1.27.0 // indirect 74 | go.yaml.in/yaml/v2 v2.4.2 // indirect 75 | go.yaml.in/yaml/v3 v3.0.4 // indirect 76 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 77 | golang.org/x/mod v0.27.0 // indirect 78 | golang.org/x/net v0.43.0 // indirect 79 | golang.org/x/oauth2 v0.27.0 // indirect 80 | golang.org/x/sync v0.16.0 // indirect 81 | golang.org/x/sys v0.35.0 // indirect 82 | golang.org/x/term v0.34.0 // indirect 83 | golang.org/x/text v0.28.0 // indirect 84 | golang.org/x/time v0.9.0 // indirect 85 | golang.org/x/tools v0.36.0 // indirect 86 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 87 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect 88 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect 89 | google.golang.org/grpc v1.72.1 // indirect 90 | google.golang.org/protobuf v1.36.7 // indirect 91 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 92 | gopkg.in/inf.v0 v0.9.1 // indirect 93 | gopkg.in/yaml.v3 v3.0.1 // indirect 94 | k8s.io/apiextensions-apiserver v0.34.1 // indirect 95 | k8s.io/apiserver v0.34.1 // indirect 96 | k8s.io/component-base v0.34.1 // indirect 97 | k8s.io/klog/v2 v2.130.1 // indirect 98 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect 99 | sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect 100 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 101 | sigs.k8s.io/randfill v1.0.0 // indirect 102 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 103 | ) 104 | -------------------------------------------------------------------------------- /internal/webhook/v1alpha1/webhook_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 v1alpha1 18 | 19 | import ( 20 | "context" 21 | "crypto/tls" 22 | "fmt" 23 | "net" 24 | "os" 25 | "path/filepath" 26 | "testing" 27 | "time" 28 | 29 | . "github.com/onsi/ginkgo/v2" 30 | . "github.com/onsi/gomega" 31 | 32 | "k8s.io/client-go/kubernetes/scheme" 33 | "k8s.io/client-go/rest" 34 | ctrl "sigs.k8s.io/controller-runtime" 35 | "sigs.k8s.io/controller-runtime/pkg/client" 36 | "sigs.k8s.io/controller-runtime/pkg/envtest" 37 | logf "sigs.k8s.io/controller-runtime/pkg/log" 38 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 39 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 40 | "sigs.k8s.io/controller-runtime/pkg/webhook" 41 | 42 | fqdnv1alpha1 "github.com/konsole-is/fqdn-controller/api/v1alpha1" 43 | // +kubebuilder:scaffold:imports 44 | ) 45 | 46 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 47 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 48 | 49 | var ( 50 | ctx context.Context 51 | cancel context.CancelFunc 52 | k8sClient client.Client 53 | cfg *rest.Config 54 | testEnv *envtest.Environment 55 | ) 56 | 57 | func TestAPIs(t *testing.T) { 58 | RegisterFailHandler(Fail) 59 | 60 | RunSpecs(t, "Webhook Suite") 61 | } 62 | 63 | var _ = BeforeSuite(func() { 64 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 65 | 66 | ctx, cancel = context.WithCancel(context.TODO()) 67 | 68 | var err error 69 | err = fqdnv1alpha1.AddToScheme(scheme.Scheme) 70 | Expect(err).NotTo(HaveOccurred()) 71 | 72 | // +kubebuilder:scaffold:scheme 73 | 74 | By("bootstrapping test environment") 75 | testEnv = &envtest.Environment{ 76 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, 77 | ErrorIfCRDPathMissing: false, 78 | 79 | WebhookInstallOptions: envtest.WebhookInstallOptions{ 80 | Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, 81 | }, 82 | } 83 | 84 | // Retrieve the first found binary directory to allow running tests from IDEs 85 | if getFirstFoundEnvTestBinaryDir() != "" { 86 | testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() 87 | } 88 | 89 | // cfg is defined in this file globally. 90 | cfg, err = testEnv.Start() 91 | Expect(err).NotTo(HaveOccurred()) 92 | Expect(cfg).NotTo(BeNil()) 93 | 94 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 95 | Expect(err).NotTo(HaveOccurred()) 96 | Expect(k8sClient).NotTo(BeNil()) 97 | 98 | // start webhook server using Manager. 99 | webhookInstallOptions := &testEnv.WebhookInstallOptions 100 | mgr, err := ctrl.NewManager(cfg, ctrl.Options{ 101 | Scheme: scheme.Scheme, 102 | WebhookServer: webhook.NewServer(webhook.Options{ 103 | Host: webhookInstallOptions.LocalServingHost, 104 | Port: webhookInstallOptions.LocalServingPort, 105 | CertDir: webhookInstallOptions.LocalServingCertDir, 106 | }), 107 | LeaderElection: false, 108 | Metrics: metricsserver.Options{BindAddress: "0"}, 109 | }) 110 | Expect(err).NotTo(HaveOccurred()) 111 | 112 | err = SetupNetworkPolicyWebhookWithManager(mgr) 113 | Expect(err).NotTo(HaveOccurred()) 114 | 115 | // +kubebuilder:scaffold:webhook 116 | 117 | go func() { 118 | defer GinkgoRecover() 119 | err = mgr.Start(ctx) 120 | Expect(err).NotTo(HaveOccurred()) 121 | }() 122 | 123 | // wait for the webhook server to get ready. 124 | dialer := &net.Dialer{Timeout: time.Second} 125 | addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) 126 | Eventually(func() error { 127 | conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | return conn.Close() 133 | }).Should(Succeed()) 134 | }) 135 | 136 | var _ = AfterSuite(func() { 137 | By("tearing down the test environment") 138 | cancel() 139 | err := testEnv.Stop() 140 | Expect(err).NotTo(HaveOccurred()) 141 | }) 142 | 143 | // getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. 144 | // ENVTEST-based tests depend on specific binaries, usually located in paths set by 145 | // controller-runtime. When running tests directly (e.g., via an IDE) without using 146 | // Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. 147 | // 148 | // This function streamlines the process by finding the required binaries, similar to 149 | // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are 150 | // properly set up, run 'make setup-envtest' beforehand. 151 | func getFirstFoundEnvTestBinaryDir() string { 152 | basePath := filepath.Join("..", "..", "..", "bin", "k8s") 153 | entries, err := os.ReadDir(basePath) 154 | if err != nil { 155 | logf.Log.Error(err, "Failed to read directory", "path", basePath) 156 | return "" 157 | } 158 | for _, entry := range entries { 159 | if entry.IsDir() { 160 | return filepath.Join(basePath, entry.Name()) 161 | } 162 | } 163 | return "" 164 | } 165 | -------------------------------------------------------------------------------- /internal/controller/network_policy_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 controller 18 | 19 | import ( 20 | "context" 21 | "time" 22 | 23 | "sigs.k8s.io/controller-runtime/pkg/builder" 24 | "sigs.k8s.io/controller-runtime/pkg/predicate" 25 | 26 | "github.com/konsole-is/fqdn-controller/pkg/network" 27 | "github.com/konsole-is/fqdn-controller/pkg/utils" 28 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 | "k8s.io/client-go/tools/record" 30 | 31 | "k8s.io/apimachinery/pkg/runtime" 32 | ctrl "sigs.k8s.io/controller-runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/client" 34 | logf "sigs.k8s.io/controller-runtime/pkg/log" 35 | 36 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 37 | ) 38 | 39 | // DNSResolver resolves domains to IP addresses 40 | type DNSResolver interface { 41 | // Resolve all the given fqdns to a DNSResolverResult 42 | Resolve( 43 | ctx context.Context, 44 | timeout time.Duration, 45 | maxConcurrent int, 46 | networkType v1alpha1.NetworkType, 47 | fqdns []v1alpha1.FQDN, 48 | ) network.DNSResolverResultList 49 | } 50 | 51 | // NetworkPolicyReconciler reconciles a NetworkPolicy object 52 | type NetworkPolicyReconciler struct { 53 | client.Client 54 | Scheme *runtime.Scheme 55 | EventRecorder record.EventRecorder 56 | DNSResolver DNSResolver 57 | MaxConcurrentResolves int 58 | } 59 | 60 | // +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update;patch;delete 61 | // +kubebuilder:rbac:groups=fqdn.konsole.is,resources=fqdnnetworkpolicies,verbs=get;list;watch;create;update;patch;delete 62 | // +kubebuilder:rbac:groups=fqdn.konsole.is,resources=fqdnnetworkpolicies/status,verbs=get;update;patch 63 | // +kubebuilder:rbac:groups=fqdn.konsole.is,resources=fqdnnetworkpolicies/finalizers,verbs=update 64 | // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch;update 65 | 66 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 67 | // move the current state of the cluster closer to the desired state. 68 | // 69 | // For more details, check Reconcile and its Result here: 70 | // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile 71 | func (r *NetworkPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 72 | np := &v1alpha1.NetworkPolicy{} 73 | if err := r.Get(ctx, req.NamespacedName, np); err != nil { 74 | return ctrl.Result{}, client.IgnoreNotFound(err) 75 | } 76 | 77 | // Resolve the FQDNs to IP addresses 78 | resolveTimeout := time.Duration(np.Spec.ResolveTimeoutSeconds) * time.Second 79 | results := r.DNSResolver.Resolve( 80 | ctx, resolveTimeout, r.MaxConcurrentResolves, np.Spec.EnabledNetworkType, np.FQDNs(), 81 | ) 82 | 83 | np.Status.FQDNs = updateFQDNStatuses( 84 | r.EventRecorder, np, np.Status.FQDNs, results, int(*np.Spec.RetryTimeoutSeconds), 85 | ) 86 | 87 | // Generate a network policy from the FQDN based network policy using the resolved addresses 88 | networkPolicy := np.ToNetworkPolicy(np.Status.FQDNs) 89 | 90 | np.Status.TotalAddressCount = int32(len(results.CIDRs())) 91 | np.Status.AppliedAddressCount = int32(len(utils.UniqueCidrsInNetworkPolicy(networkPolicy))) 92 | np.Status.LatestLookupTime = metav1.NewTime(time.Now()) 93 | 94 | // Set the resolve status condition 95 | resolveStatus := results.AggregatedResolveStatus() 96 | np.SetResolveCondition( 97 | resolveStatus, 98 | results.AggregatedResolveMessage(), 99 | ) 100 | 101 | logger := logf.FromContext(ctx).WithValues( 102 | "policy", np.GetName(), "namespace", np.GetNamespace(), 103 | "status", resolveStatus, 104 | "resolved", np.Status.TotalAddressCount, 105 | "applied", np.Status.AppliedAddressCount, 106 | ) 107 | logf.IntoContext(ctx, logger) 108 | 109 | // The network policy does not define any Egress rules, delete network policy if it exists 110 | if networkPolicy == nil { 111 | np.SetReadyConditionFalse(v1alpha1.NetworkPolicyFailed, "No Egress rules specified") 112 | if err := r.Client.Status().Update(ctx, np); err != nil { 113 | return ctrl.Result{}, err 114 | } 115 | if err := r.reconcileNetworkPolicyDeletion(ctx, np); err != nil { 116 | return ctrl.Result{}, err 117 | } 118 | logger.Info("No Egress rules, will not requeue until updated") 119 | return ctrl.Result{}, nil 120 | } 121 | 122 | // There are Egress rules defined in our FQDN network policy, we create or update the underlying 123 | // network policy, so we create it. 124 | if err := r.reconcileNetworkPolicyCreation(ctx, np, networkPolicy); err != nil { 125 | np.SetReadyConditionFalse(v1alpha1.NetworkPolicyFailed, err.Error()) 126 | if err := r.Client.Status().Update(ctx, np); err != nil { 127 | return ctrl.Result{}, err 128 | } 129 | return ctrl.Result{}, err 130 | } 131 | 132 | // If the underlying network policy is empty we set a different status 133 | // This happens when the FQDN's do not resole to any valid addresses 134 | if utils.IsEmpty(networkPolicy) { 135 | np.SetReadyConditionTrue( 136 | v1alpha1.NetworkPolicyEmptyRules, 137 | "Resolved to an empty NetworkPolicy. Egress deny-all in effect.", 138 | ) 139 | if err := r.Client.Status().Update(ctx, np); err != nil { 140 | return ctrl.Result{}, err 141 | } 142 | logger.Info("Network policy is empty", "requeueAfter", np.Spec.TTLSeconds) 143 | return ctrl.Result{RequeueAfter: time.Duration(np.Spec.TTLSeconds) * time.Second}, nil 144 | } 145 | 146 | // Creation succeeded, update the status and requeue after TTL 147 | np.SetReadyConditionTrue(v1alpha1.NetworkPolicyReady, "The network policy is ready.") 148 | if err := r.Client.Status().Update(ctx, np); err != nil { 149 | return ctrl.Result{}, err 150 | } 151 | logger.Info("Reconciliation succeeded", "requeueAfter", np.Spec.TTLSeconds) 152 | return ctrl.Result{RequeueAfter: time.Duration(np.Spec.TTLSeconds) * time.Second}, nil 153 | } 154 | 155 | // SetupWithManager sets up the controller with the Manager. 156 | func (r *NetworkPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { 157 | return ctrl.NewControllerManagedBy(mgr). 158 | For(&v1alpha1.NetworkPolicy{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 159 | Named("fqdnnetworkpolicy"). 160 | Complete(r) 161 | } 162 | -------------------------------------------------------------------------------- /internal/webhook/v1alpha1/network_policy_webhook.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 v1alpha1 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "k8s.io/apimachinery/pkg/runtime" 24 | ctrl "sigs.k8s.io/controller-runtime" 25 | logf "sigs.k8s.io/controller-runtime/pkg/log" 26 | "sigs.k8s.io/controller-runtime/pkg/webhook" 27 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 28 | 29 | v1alpha1 "github.com/konsole-is/fqdn-controller/api/v1alpha1" 30 | ) 31 | 32 | // nolint:unused 33 | // log is for logging in this package. 34 | var networkpolicylog = logf.Log.WithName("networkpolicy-resource") 35 | 36 | // SetupNetworkPolicyWebhookWithManager registers the webhook for NetworkPolicy in the manager. 37 | func SetupNetworkPolicyWebhookWithManager(mgr ctrl.Manager) error { 38 | return ctrl.NewWebhookManagedBy(mgr).For(&v1alpha1.NetworkPolicy{}). 39 | WithValidator(&NetworkPolicyCustomValidator{}). 40 | WithDefaulter(&NetworkPolicyCustomDefaulter{}). 41 | Complete() 42 | } 43 | 44 | // TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 45 | 46 | // +kubebuilder:webhook:path=/mutate-fqdn-konsole-is-v1alpha1-networkpolicy,mutating=true,failurePolicy=fail,sideEffects=None,groups=fqdn.konsole.is,resources=networkpolicies,verbs=create;update,versions=v1alpha1,name=mnetworkpolicy-v1alpha1.kb.io,admissionReviewVersions=v1 47 | 48 | // NetworkPolicyCustomDefaulter struct is responsible for setting default values on the custom resource of the 49 | // Kind NetworkPolicy when those are created or updated. 50 | // 51 | // NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, 52 | // as it is used only for temporary operations and does not need to be deeply copied. 53 | type NetworkPolicyCustomDefaulter struct { 54 | // TODO(user): Add more fields as needed for defaulting 55 | } 56 | 57 | var _ webhook.CustomDefaulter = &NetworkPolicyCustomDefaulter{} 58 | 59 | // Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind NetworkPolicy. 60 | func (d *NetworkPolicyCustomDefaulter) Default(_ context.Context, obj runtime.Object) error { 61 | np, ok := obj.(*v1alpha1.NetworkPolicy) 62 | 63 | if !ok { 64 | return fmt.Errorf("expected an NetworkPolicy object but got %T", obj) 65 | } 66 | networkpolicylog.Info("Defaulting for NetworkPolicy", "name", np.GetName()) 67 | 68 | return nil 69 | } 70 | 71 | // TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. 72 | // NOTE: The 'path' attribute must follow a specific pattern and should not be modified directly here. 73 | // Modifying the path for an invalid path can cause API server errors; failing to locate the webhook. 74 | // +kubebuilder:webhook:path=/validate-fqdn-konsole-is-v1alpha1-networkpolicy,mutating=false,failurePolicy=fail,sideEffects=None,groups=fqdn.konsole.is,resources=networkpolicies,verbs=create;update,versions=v1alpha1,name=vnetworkpolicy-v1alpha1.kb.io,admissionReviewVersions=v1 75 | 76 | // NetworkPolicyCustomValidator struct is responsible for validating the NetworkPolicy resource 77 | // when it is created, updated, or deleted. 78 | // 79 | // NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, 80 | // as this struct is used only for temporary operations and does not need to be deeply copied. 81 | type NetworkPolicyCustomValidator struct { 82 | // TODO(user): Add more fields as needed for validation 83 | } 84 | 85 | var _ webhook.CustomValidator = &NetworkPolicyCustomValidator{} 86 | 87 | func validateFQDNs(n *v1alpha1.NetworkPolicy) error { 88 | for i, rule := range n.Spec.Egress { 89 | for j, fqdn := range rule.ToFQDNS { 90 | if !fqdn.Valid() { 91 | return fmt.Errorf("invalid FQDN '%s' in Egress[%d].ToFQDNS[%d]", fqdn, i, j) 92 | } 93 | } 94 | } 95 | return nil 96 | } 97 | 98 | func validateTimeLimits(n *v1alpha1.NetworkPolicy) error { 99 | if n.Spec.TTLSeconds < n.Spec.ResolveTimeoutSeconds { 100 | return fmt.Errorf("TTL seconds must be greater than lookup timeout (%d seconds)", n.Spec.ResolveTimeoutSeconds) 101 | } 102 | return nil 103 | } 104 | 105 | func validateRuleCount(np *v1alpha1.NetworkPolicy) error { 106 | if len(np.Spec.Egress) == 0 { 107 | return fmt.Errorf("at least one Egress rule must be specified") 108 | } 109 | return nil 110 | } 111 | 112 | func defaultValidation(np *v1alpha1.NetworkPolicy) error { 113 | if err := validateFQDNs(np); err != nil { 114 | return err 115 | } 116 | if err := validateTimeLimits(np); err != nil { 117 | return err 118 | } 119 | if err := validateRuleCount(np); err != nil { 120 | return err 121 | } 122 | return nil 123 | } 124 | 125 | // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type FQDNNetworkPolicy. 126 | func (v *NetworkPolicyCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { 127 | np, ok := obj.(*v1alpha1.NetworkPolicy) 128 | if !ok { 129 | return nil, fmt.Errorf("expected a NetworkPolicy object but got %T", obj) 130 | } 131 | networkpolicylog.Info("Validation for NetworkPolicy upon creation", "name", np.GetName()) 132 | 133 | if err := defaultValidation(np); err != nil { 134 | return nil, err 135 | } 136 | return nil, nil 137 | } 138 | 139 | // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type FQDNNetworkPolicy. 140 | func (v *NetworkPolicyCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { 141 | np, ok := newObj.(*v1alpha1.NetworkPolicy) 142 | if !ok { 143 | return nil, fmt.Errorf("expected a NetworkPolicy object for the newObj but got %T", newObj) 144 | } 145 | networkpolicylog.Info("Validation for NetworkPolicy upon update", "name", np.GetName()) 146 | 147 | if err := defaultValidation(np); err != nil { 148 | return nil, err 149 | } 150 | return nil, nil 151 | } 152 | 153 | // ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type NetworkPolicy. 154 | func (v *NetworkPolicyCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 155 | networkpolicy, ok := obj.(*v1alpha1.NetworkPolicy) 156 | if !ok { 157 | return nil, fmt.Errorf("expected a NetworkPolicy object but got %T", obj) 158 | } 159 | networkpolicylog.Info("Validation for NetworkPolicy upon deletion", "name", networkpolicy.GetName()) 160 | 161 | return nil, nil 162 | } 163 | -------------------------------------------------------------------------------- /api/v1alpha1/network_policy_funcs.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "regexp" 7 | "sort" 8 | "strings" 9 | "time" 10 | 11 | netv1 "k8s.io/api/networking/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ) 14 | 15 | // CIDR represents a network range in CIDR (Classless Inter-Domain Routing) notation. 16 | // It consists of an IP address and a Prefix (prefix length) that defines the size of the network. 17 | type CIDR struct { 18 | IP net.IP 19 | Prefix int 20 | } 21 | 22 | func NewCIDR(cidr string) (*CIDR, error) { 23 | ip, ipNet, err := net.ParseCIDR(cidr) 24 | if err != nil { 25 | return nil, err 26 | } 27 | prefix, _ := ipNet.Mask.Size() 28 | return &CIDR{ 29 | IP: ip, 30 | Prefix: prefix, 31 | }, nil 32 | } 33 | 34 | func MustCIDR(cidr string) *CIDR { 35 | if c, err := NewCIDR(cidr); err != nil { 36 | panic(err) 37 | } else { 38 | return c 39 | } 40 | } 41 | 42 | // String returns the string representation of the CIDR 43 | func (c *CIDR) String() string { 44 | return fmt.Sprintf("%s/%d", c.IP.String(), c.Prefix) 45 | } 46 | 47 | // IsPrivate returns true if the CIDR is a private address 48 | func (c *CIDR) IsPrivate() bool { 49 | return c.IP.IsPrivate() 50 | } 51 | 52 | type CIDRList []*CIDR 53 | 54 | func (l CIDRList) String() []string { 55 | var result []string 56 | for _, cidr := range l { 57 | result = append(result, cidr.String()) 58 | } 59 | return result 60 | } 61 | 62 | // Valid returns true if the FQDN is valid 63 | func (f *FQDN) Valid() bool { 64 | labelRegexp := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 65 | labels := strings.Split(string(*f), ".") 66 | if len(labels) < 2 { 67 | return false 68 | } 69 | for _, label := range labels { 70 | if len(label) == 0 || !labelRegexp.MatchString(label) { 71 | return false 72 | } 73 | } 74 | return true 75 | } 76 | 77 | func isAllowed(cidrString string, globalBlock bool, ruleBlock *bool) bool { 78 | blockPrivateIP := globalBlock 79 | if ruleBlock != nil { 80 | blockPrivateIP = *ruleBlock 81 | } 82 | cidr, err := NewCIDR(cidrString) 83 | if err != nil { 84 | return false 85 | } 86 | if cidr.IsPrivate() && blockPrivateIP { 87 | return false 88 | } 89 | return true 90 | } 91 | 92 | func sortPeersByCIDR(peers []netv1.NetworkPolicyPeer) { 93 | sort.SliceStable(peers, func(i, j int) bool { 94 | // Make sure both peers have IPBlocks 95 | if peers[i].IPBlock == nil { 96 | return false 97 | } 98 | if peers[j].IPBlock == nil { 99 | return true 100 | } 101 | return peers[i].IPBlock.CIDR < peers[j].IPBlock.CIDR 102 | }) 103 | } 104 | 105 | func getPeers(fqdns []FQDN, ips map[FQDN]*FQDNStatus, globalBlock bool, ruleBlock *bool) []netv1.NetworkPolicyPeer { 106 | var peers []netv1.NetworkPolicyPeer 107 | 108 | for _, fqdn := range fqdns { 109 | if status, ok := ips[fqdn]; ok { 110 | for _, addr := range status.Addresses { 111 | if isAllowed(addr, globalBlock, ruleBlock) { 112 | peers = append(peers, netv1.NetworkPolicyPeer{IPBlock: &netv1.IPBlock{ 113 | CIDR: addr, 114 | }}) 115 | } 116 | } 117 | } 118 | } 119 | sortPeersByCIDR(peers) 120 | return peers 121 | } 122 | 123 | // toNetworkPolicyEgressRule converts the EgressRule to a netv1.NetworkPolicyEgressRule. 124 | // Returns nil if no peers were found. 125 | func (r *EgressRule) toNetworkPolicyEgressRule(ips map[FQDN]*FQDNStatus, blockPrivate bool) *netv1.NetworkPolicyEgressRule { 126 | peers := getPeers(r.ToFQDNS, ips, blockPrivate, r.BlockPrivateIPs) 127 | if len(peers) == 0 { 128 | return nil 129 | } 130 | 131 | return &netv1.NetworkPolicyEgressRule{ 132 | Ports: r.Ports, 133 | To: peers, 134 | } 135 | } 136 | 137 | // FQDNs Returns all unique FQDNs defined in the network policy 138 | func (np *NetworkPolicy) FQDNs() []FQDN { 139 | set := make(map[FQDN]struct{}) 140 | for _, rule := range np.Spec.Egress { 141 | for _, fqdn := range rule.ToFQDNS { 142 | set[fqdn] = struct{}{} 143 | } 144 | } 145 | 146 | fqdns := make([]FQDN, 0, len(set)) 147 | for fqdn := range set { 148 | fqdns = append(fqdns, fqdn) 149 | } 150 | 151 | sort.SliceStable(fqdns, func(i, j int) bool { 152 | return fqdns[i] < fqdns[j] 153 | }) 154 | 155 | return fqdns 156 | } 157 | 158 | // ToNetworkPolicy converts the NetworkPolicy to a netv1.NetworkPolicy. 159 | // If no Egress rules are specified, nil is returned. 160 | func (np *NetworkPolicy) ToNetworkPolicy(fqdnStatuses []FQDNStatus) *netv1.NetworkPolicy { 161 | if len(np.Spec.Egress) == 0 { 162 | return nil 163 | } 164 | 165 | lookup := FQDNStatusList(fqdnStatuses).LookupTable() 166 | var egress []netv1.NetworkPolicyEgressRule 167 | for _, fqdnRule := range np.Spec.Egress { 168 | if rule := fqdnRule.toNetworkPolicyEgressRule(lookup, np.Spec.BlockPrivateIPs); rule != nil { 169 | egress = append(egress, *rule) 170 | } 171 | } 172 | 173 | return &netv1.NetworkPolicy{ 174 | ObjectMeta: np.ObjectMeta, 175 | Spec: netv1.NetworkPolicySpec{ 176 | PodSelector: np.Spec.PodSelector, 177 | Egress: egress, 178 | PolicyTypes: []netv1.PolicyType{netv1.PolicyTypeEgress}, 179 | }, 180 | } 181 | } 182 | 183 | // Update updates the status of the FQDN. 184 | // If addresses were cleared due to an error during the update, the method returns true. 185 | func (f *FQDNStatus) Update( 186 | cidrs []*CIDR, reason NetworkPolicyResolveConditionReason, message string, retryTimeoutSeconds int, 187 | ) bool { 188 | cleared := false 189 | if reason == NetworkPolicyResolveSuccess { 190 | f.LastSuccessfulTime = metav1.Now() 191 | f.Addresses = CIDRList(cidrs).String() 192 | } 193 | // On transient errors we want to adhere to the retry timeout specification 194 | if reason != NetworkPolicyResolveSuccess && reason.Transient() { 195 | retryLimitReached := time.Now().After( 196 | f.LastSuccessfulTime.Add(time.Duration(retryTimeoutSeconds) * time.Second), 197 | ) 198 | 199 | if retryLimitReached { 200 | f.Addresses = []string{} 201 | cleared = true 202 | } 203 | } 204 | // On non-transient errors we clear the addresses immediately 205 | if reason != NetworkPolicyResolveSuccess && !reason.Transient() { 206 | f.Addresses = []string{} 207 | cleared = true 208 | } 209 | if f.ResolveReason != reason { 210 | f.LastTransitionTime = metav1.Now() 211 | } 212 | f.ResolveReason = reason 213 | f.ResolveMessage = message 214 | return cleared 215 | } 216 | 217 | func NewFQDNStatus(fqdn FQDN, cidrs []*CIDR, reason NetworkPolicyResolveConditionReason, message string) FQDNStatus { 218 | timeNow := metav1.Now() 219 | return FQDNStatus{ 220 | FQDN: fqdn, 221 | LastSuccessfulTime: timeNow, 222 | LastTransitionTime: timeNow, 223 | ResolveReason: reason, 224 | ResolveMessage: message, 225 | Addresses: CIDRList(cidrs).String(), 226 | } 227 | } 228 | 229 | type FQDNStatusList []FQDNStatus 230 | 231 | func (s FQDNStatusList) LookupTable() map[FQDN]*FQDNStatus { 232 | lookupTable := make(map[FQDN]*FQDNStatus) 233 | for _, status := range s { 234 | lookupTable[status.FQDN] = &status 235 | } 236 | return lookupTable 237 | } 238 | -------------------------------------------------------------------------------- /pkg/network/resolver.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "sort" 9 | "sync" 10 | "time" 11 | 12 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 13 | ) 14 | 15 | type lookupError struct { 16 | Reason v1alpha1.NetworkPolicyResolveConditionReason 17 | Message string 18 | } 19 | 20 | func (e lookupError) Error() string { 21 | return e.Message 22 | } 23 | 24 | // DNSResolverResult Is the resulting outcome of a Resolver's DNS lookup 25 | type DNSResolverResult struct { 26 | // Domain that the lookup was for 27 | Domain v1alpha1.FQDN 28 | // Error that the lookup may have caused 29 | Error error 30 | // Resolve status 31 | Status v1alpha1.NetworkPolicyResolveConditionReason 32 | // Message for the reason 33 | Message string 34 | // CIDRs found for the given domain if no error occurred 35 | CIDRs []*v1alpha1.CIDR 36 | } 37 | 38 | func NewDNSResolverResult( 39 | domain v1alpha1.FQDN, 40 | CIDRs []*v1alpha1.CIDR, 41 | error error) *DNSResolverResult { 42 | 43 | sort.SliceStable(CIDRs, func(i, j int) bool { 44 | return CIDRs[i].String() < CIDRs[j].String() 45 | }) 46 | 47 | return &DNSResolverResult{ 48 | Domain: domain, 49 | Error: error, 50 | Message: resolveMessage(error), 51 | Status: resolveReason(error), 52 | CIDRs: CIDRs, 53 | } 54 | } 55 | 56 | // resolveReason returns the reason for the status of the resolve result 57 | func resolveReason(err error) v1alpha1.NetworkPolicyResolveConditionReason { 58 | if err == nil { 59 | return v1alpha1.NetworkPolicyResolveSuccess 60 | } 61 | var lookupErr *lookupError 62 | if errors.As(err, &lookupErr) { 63 | return lookupErr.Reason 64 | } 65 | var dnsErr *net.DNSError 66 | if !errors.As(err, &dnsErr) { 67 | return v1alpha1.NetworkPolicyResolveOtherError 68 | } 69 | if dnsErr.IsTimeout { 70 | return v1alpha1.NetworkPolicyResolveTimeout 71 | } 72 | if dnsErr.IsNotFound { 73 | return v1alpha1.NetworkPolicyResolveTimeout 74 | } 75 | if dnsErr.IsTemporary { 76 | return v1alpha1.NetworkPolicyResolveTemporaryError 77 | } 78 | return v1alpha1.NetworkPolicyResolveOtherError 79 | } 80 | 81 | // resolveMessage returns an error message for the given error 82 | func resolveMessage(err error) string { 83 | if err == nil { 84 | return "Resolve succeeded" 85 | } 86 | var lookupErr *lookupError 87 | if errors.As(err, &lookupErr) { 88 | return lookupErr.Error() 89 | } 90 | var dnsErr *net.DNSError 91 | if !errors.As(err, &dnsErr) { 92 | return err.Error() 93 | } 94 | if dnsErr.IsTimeout { 95 | return "Timeout waiting for DNS response" 96 | } 97 | if dnsErr.IsNotFound { 98 | return "Domain not found" 99 | } 100 | if dnsErr.IsTemporary { 101 | return "Temporary failure in name resolution" 102 | } 103 | return err.Error() 104 | } 105 | 106 | // DNSResolverResultList is a wrapper around DNSResolver result with helpful getter methods 107 | type DNSResolverResultList []*DNSResolverResult 108 | 109 | // CIDRs returns all the CIDRs in the result list 110 | func (dlr DNSResolverResultList) CIDRs() []*v1alpha1.CIDR { 111 | var cidrs []*v1alpha1.CIDR 112 | for _, dr := range dlr { 113 | cidrs = append(cidrs, dr.CIDRs...) 114 | } 115 | return cidrs 116 | } 117 | 118 | // AggregatedResolveStatus returns the reason with the highest priority in the result list 119 | func (dlr DNSResolverResultList) AggregatedResolveStatus() v1alpha1.NetworkPolicyResolveConditionReason { 120 | reason := v1alpha1.NetworkPolicyResolveSuccess 121 | for _, dr := range dlr { 122 | current := dr.Status 123 | if current.Priority() > reason.Priority() { 124 | reason = current 125 | } 126 | } 127 | return reason 128 | } 129 | 130 | // AggregatedResolveMessage returns the message with the highest priority in the result list 131 | func (dlr DNSResolverResultList) AggregatedResolveMessage() string { 132 | reason := v1alpha1.NetworkPolicyResolveSuccess 133 | message := "" 134 | for _, dr := range dlr { 135 | current := dr.Status 136 | if message == "" || current.Priority() > reason.Priority() { 137 | reason = current 138 | message = dr.Message 139 | } 140 | } 141 | return message 142 | } 143 | 144 | // LookupTable returns a FQDN lookup table for the result list 145 | func (dlr DNSResolverResultList) LookupTable() map[v1alpha1.FQDN]*DNSResolverResult { 146 | lookup := make(map[v1alpha1.FQDN]*DNSResolverResult) 147 | for _, dr := range dlr { 148 | lookup[dr.Domain] = dr 149 | } 150 | return lookup 151 | } 152 | 153 | type Resolver interface { 154 | LookupIP(ctx context.Context, network string, host string) ([]net.IP, error) 155 | } 156 | 157 | // DNSResolver resolves domains to IPs 158 | type DNSResolver struct { 159 | resolver Resolver 160 | } 161 | 162 | // NewDNSResolver returns the default resolver to use for DNS lookup 163 | func NewDNSResolver() *DNSResolver { 164 | return &DNSResolver{ 165 | resolver: &net.Resolver{}, 166 | } 167 | } 168 | 169 | // lookupIP resolves the host to its underlying IP addresses 170 | func (r *DNSResolver) lookupIP( 171 | ctx context.Context, 172 | networkType v1alpha1.NetworkType, 173 | host v1alpha1.FQDN, 174 | ) ([]*v1alpha1.CIDR, error) { 175 | if !host.Valid() { 176 | return nil, &lookupError{ 177 | Reason: v1alpha1.NetworkPolicyResolveInvalidDomain, 178 | Message: fmt.Sprintf("Received invalid FQDN '%s'", host), 179 | } 180 | } 181 | ips, err := r.resolver.LookupIP(ctx, networkType.ResolverString(), string(host)) 182 | if err != nil { 183 | return nil, err 184 | } 185 | var cidrs []*v1alpha1.CIDR 186 | for _, ip := range ips { 187 | prefix := 128 188 | if ip.To4() != nil { 189 | prefix = 32 190 | } 191 | cidrs = append(cidrs, &v1alpha1.CIDR{IP: ip, Prefix: prefix}) 192 | } 193 | return cidrs, nil 194 | } 195 | 196 | // Resolve all the given fqdns to a DNSResolverResult 197 | // - maxConcurrent controls how many goroutines are spawned to resolve addresses from FQDNs 198 | func (r *DNSResolver) Resolve( 199 | ctx context.Context, 200 | timeout time.Duration, 201 | maxConcurrent int, 202 | networkType v1alpha1.NetworkType, 203 | fqdns []v1alpha1.FQDN, 204 | ) DNSResolverResultList { 205 | results := make(chan *DNSResolverResult) 206 | sem := make(chan struct{}, maxConcurrent) 207 | 208 | var wg sync.WaitGroup 209 | for _, fqdn := range fqdns { 210 | wg.Add(1) 211 | go func(rFQDN v1alpha1.FQDN) { 212 | defer wg.Done() 213 | 214 | select { 215 | case sem <- struct{}{}: 216 | // acquired a slot 217 | defer func() { <-sem }() 218 | case <-ctx.Done(): 219 | // parent context cancelled before acquiring slot 220 | return 221 | } 222 | 223 | childCtx, cancel := context.WithTimeout(ctx, timeout) 224 | defer cancel() 225 | 226 | cidrs, err := r.lookupIP(childCtx, networkType, rFQDN) 227 | 228 | select { 229 | case results <- NewDNSResolverResult(fqdn, cidrs, err): 230 | case <-ctx.Done(): 231 | // context cancelled while trying to send 232 | } 233 | }(fqdn) 234 | } 235 | 236 | go func() { 237 | wg.Wait() 238 | close(results) 239 | }() 240 | 241 | var lookupResults []*DNSResolverResult 242 | for res := range results { 243 | lookupResults = append(lookupResults, res) 244 | } 245 | return lookupResults 246 | } 247 | 248 | type FakeDNSResolver struct { 249 | Results DNSResolverResultList 250 | } 251 | 252 | func (r *FakeDNSResolver) Resolve( 253 | _ context.Context, 254 | _ time.Duration, 255 | _ int, 256 | _ v1alpha1.NetworkType, 257 | _ []v1alpha1.FQDN, 258 | ) DNSResolverResultList { 259 | return r.Results 260 | } 261 | -------------------------------------------------------------------------------- /test/utils/utils.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 utils 18 | 19 | import ( 20 | "bufio" 21 | "bytes" 22 | "fmt" 23 | "os" 24 | "os/exec" 25 | "strings" 26 | 27 | . "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck 28 | ) 29 | 30 | const ( 31 | prometheusOperatorVersion = "v0.83.0" 32 | prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" + 33 | "releases/download/%s/bundle.yaml" 34 | 35 | certmanagerVersion = "v1.18.1" 36 | certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml" 37 | ) 38 | 39 | func warnError(err error) { 40 | _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) 41 | } 42 | 43 | // Run executes the provided command within this context 44 | func Run(cmd *exec.Cmd) (string, error) { 45 | dir, _ := GetProjectDir() 46 | cmd.Dir = dir 47 | 48 | if err := os.Chdir(cmd.Dir); err != nil { 49 | _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err) 50 | } 51 | 52 | cmd.Env = append(os.Environ(), "GO111MODULE=on") 53 | command := strings.Join(cmd.Args, " ") 54 | _, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command) 55 | output, err := cmd.CombinedOutput() 56 | if err != nil { 57 | return string(output), fmt.Errorf("%q failed with error %q: %w", command, string(output), err) 58 | } 59 | 60 | return string(output), nil 61 | } 62 | 63 | // InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. 64 | func InstallPrometheusOperator() error { 65 | url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) 66 | cmd := exec.Command("kubectl", "create", "-f", url) 67 | _, err := Run(cmd) 68 | return err 69 | } 70 | 71 | // UninstallPrometheusOperator uninstalls the prometheus 72 | func UninstallPrometheusOperator() { 73 | url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) 74 | cmd := exec.Command("kubectl", "delete", "-f", url) 75 | if _, err := Run(cmd); err != nil { 76 | warnError(err) 77 | } 78 | } 79 | 80 | // IsPrometheusCRDsInstalled checks if any Prometheus CRDs are installed 81 | // by verifying the existence of key CRDs related to Prometheus. 82 | func IsPrometheusCRDsInstalled() bool { 83 | // List of common Prometheus CRDs 84 | prometheusCRDs := []string{ 85 | "prometheuses.monitoring.coreos.com", 86 | "prometheusrules.monitoring.coreos.com", 87 | "prometheusagents.monitoring.coreos.com", 88 | } 89 | 90 | cmd := exec.Command("kubectl", "get", "crds", "-o", "custom-columns=NAME:.metadata.name") 91 | output, err := Run(cmd) 92 | if err != nil { 93 | return false 94 | } 95 | crdList := GetNonEmptyLines(output) 96 | for _, crd := range prometheusCRDs { 97 | for _, line := range crdList { 98 | if strings.Contains(line, crd) { 99 | return true 100 | } 101 | } 102 | } 103 | 104 | return false 105 | } 106 | 107 | // UninstallCertManager uninstalls the cert manager 108 | func UninstallCertManager() { 109 | url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) 110 | cmd := exec.Command("kubectl", "delete", "-f", url) 111 | if _, err := Run(cmd); err != nil { 112 | warnError(err) 113 | } 114 | } 115 | 116 | // InstallCertManager installs the cert manager bundle. 117 | func InstallCertManager() error { 118 | url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) 119 | cmd := exec.Command("kubectl", "apply", "-f", url) 120 | if _, err := Run(cmd); err != nil { 121 | return err 122 | } 123 | // Wait for cert-manager-webhook to be ready, which can take time if cert-manager 124 | // was re-installed after uninstalling on a cluster. 125 | cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", 126 | "--for", "condition=Available", 127 | "--namespace", "cert-manager", 128 | "--timeout", "5m", 129 | ) 130 | 131 | _, err := Run(cmd) 132 | return err 133 | } 134 | 135 | // IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed 136 | // by verifying the existence of key CRDs related to Cert Manager. 137 | func IsCertManagerCRDsInstalled() bool { 138 | // List of common Cert Manager CRDs 139 | certManagerCRDs := []string{ 140 | "certificates.cert-manager.io", 141 | "issuers.cert-manager.io", 142 | "clusterissuers.cert-manager.io", 143 | "certificaterequests.cert-manager.io", 144 | "orders.acme.cert-manager.io", 145 | "challenges.acme.cert-manager.io", 146 | } 147 | 148 | // Execute the kubectl command to get all CRDs 149 | cmd := exec.Command("kubectl", "get", "crds") 150 | output, err := Run(cmd) 151 | if err != nil { 152 | return false 153 | } 154 | 155 | // Check if any of the Cert Manager CRDs are present 156 | crdList := GetNonEmptyLines(output) 157 | for _, crd := range certManagerCRDs { 158 | for _, line := range crdList { 159 | if strings.Contains(line, crd) { 160 | return true 161 | } 162 | } 163 | } 164 | 165 | return false 166 | } 167 | 168 | // LoadImageToKindClusterWithName loads a local docker image to the kind cluster 169 | func LoadImageToKindClusterWithName(name string) error { 170 | cluster := "kind" 171 | if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { 172 | cluster = v 173 | } 174 | kindOptions := []string{"load", "docker-image", name, "--name", cluster} 175 | cmd := exec.Command("kind", kindOptions...) 176 | _, err := Run(cmd) 177 | return err 178 | } 179 | 180 | // GetNonEmptyLines converts given command output string into individual objects 181 | // according to line breakers, and ignores the empty elements in it. 182 | func GetNonEmptyLines(output string) []string { 183 | var res []string 184 | elements := strings.Split(output, "\n") 185 | for _, element := range elements { 186 | if element != "" { 187 | res = append(res, element) 188 | } 189 | } 190 | 191 | return res 192 | } 193 | 194 | // GetProjectDir will return the directory where the project is 195 | func GetProjectDir() (string, error) { 196 | wd, err := os.Getwd() 197 | if err != nil { 198 | return wd, fmt.Errorf("failed to get current working directory: %w", err) 199 | } 200 | wd = strings.ReplaceAll(wd, "/test/e2e", "") 201 | return wd, nil 202 | } 203 | 204 | // UncommentCode searches for target in the file and remove the comment prefix 205 | // of the target content. The target content may span multiple lines. 206 | func UncommentCode(filename, target, prefix string) error { 207 | // false positive 208 | // nolint:gosec 209 | content, err := os.ReadFile(filename) 210 | if err != nil { 211 | return fmt.Errorf("failed to read file %q: %w", filename, err) 212 | } 213 | strContent := string(content) 214 | 215 | idx := strings.Index(strContent, target) 216 | if idx < 0 { 217 | return fmt.Errorf("unable to find the code %q to be uncomment", target) 218 | } 219 | 220 | out := new(bytes.Buffer) 221 | _, err = out.Write(content[:idx]) 222 | if err != nil { 223 | return fmt.Errorf("failed to write to output: %w", err) 224 | } 225 | 226 | scanner := bufio.NewScanner(bytes.NewBufferString(target)) 227 | if !scanner.Scan() { 228 | return nil 229 | } 230 | for { 231 | if _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil { 232 | return fmt.Errorf("failed to write to output: %w", err) 233 | } 234 | // Avoid writing a newline in case the previous line was the last in target. 235 | if !scanner.Scan() { 236 | break 237 | } 238 | if _, err = out.WriteString("\n"); err != nil { 239 | return fmt.Errorf("failed to write to output: %w", err) 240 | } 241 | } 242 | 243 | if _, err = out.Write(content[idx+len(target):]); err != nil { 244 | return fmt.Errorf("failed to write to output: %w", err) 245 | } 246 | 247 | // false positive 248 | // nolint:gosec 249 | if err = os.WriteFile(filename, out.Bytes(), 0644); err != nil { 250 | return fmt.Errorf("failed to write file %q: %w", filename, err) 251 | } 252 | 253 | return nil 254 | } 255 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: fqdn-controller-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: fqdn- 10 | 11 | # Labels to add to all resources and selectors. 12 | #labels: 13 | #- includeSelectors: true 14 | # pairs: 15 | # someName: someValue 16 | 17 | resources: 18 | - ../crd 19 | - ../rbac 20 | - ../manager 21 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 22 | # crd/kustomization.yaml 23 | - ../webhook 24 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 25 | - ../certmanager 26 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 27 | #- ../prometheus 28 | # [METRICS] Expose the controller manager metrics service. 29 | - metrics_service.yaml 30 | # [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. 31 | # Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. 32 | # Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will 33 | # be able to communicate with the Webhook Server. 34 | #- ../network-policy 35 | 36 | # Uncomment the patches line if you enable Metrics 37 | patches: 38 | # [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. 39 | # More info: https://book.kubebuilder.io/reference/metrics 40 | - path: manager_metrics_patch.yaml 41 | target: 42 | kind: Deployment 43 | 44 | # Uncomment the patches line if you enable Metrics and CertManager 45 | # [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line. 46 | # This patch will protect the metrics with certManager self-signed certs. 47 | - path: cert_metrics_manager_patch.yaml 48 | target: 49 | kind: Deployment 50 | 51 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 52 | # crd/kustomization.yaml 53 | - path: manager_webhook_patch.yaml 54 | target: 55 | kind: Deployment 56 | 57 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 58 | # Uncomment the following replacements to add the cert-manager CA injection annotations 59 | replacements: 60 | - source: # Uncomment the following block to enable certificates for metrics 61 | kind: Service 62 | version: v1 63 | name: controller-manager-metrics-service 64 | fieldPath: metadata.name 65 | targets: 66 | - select: 67 | kind: Certificate 68 | group: cert-manager.io 69 | version: v1 70 | name: metrics-certs 71 | fieldPaths: 72 | - spec.dnsNames.0 73 | - spec.dnsNames.1 74 | options: 75 | delimiter: '.' 76 | index: 0 77 | create: true 78 | - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor 79 | kind: ServiceMonitor 80 | group: monitoring.coreos.com 81 | version: v1 82 | name: controller-manager-metrics-monitor 83 | fieldPaths: 84 | - spec.endpoints.0.tlsConfig.serverName 85 | options: 86 | delimiter: '.' 87 | index: 0 88 | create: true 89 | 90 | - source: 91 | kind: Service 92 | version: v1 93 | name: controller-manager-metrics-service 94 | fieldPath: metadata.namespace 95 | targets: 96 | - select: 97 | kind: Certificate 98 | group: cert-manager.io 99 | version: v1 100 | name: metrics-certs 101 | fieldPaths: 102 | - spec.dnsNames.0 103 | - spec.dnsNames.1 104 | options: 105 | delimiter: '.' 106 | index: 1 107 | create: true 108 | - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor 109 | kind: ServiceMonitor 110 | group: monitoring.coreos.com 111 | version: v1 112 | name: controller-manager-metrics-monitor 113 | fieldPaths: 114 | - spec.endpoints.0.tlsConfig.serverName 115 | options: 116 | delimiter: '.' 117 | index: 1 118 | create: true 119 | 120 | - source: # Uncomment the following block if you have any webhook 121 | kind: Service 122 | version: v1 123 | name: webhook-service 124 | fieldPath: .metadata.name # Name of the service 125 | targets: 126 | - select: 127 | kind: Certificate 128 | group: cert-manager.io 129 | version: v1 130 | name: serving-cert 131 | fieldPaths: 132 | - .spec.dnsNames.0 133 | - .spec.dnsNames.1 134 | options: 135 | delimiter: '.' 136 | index: 0 137 | create: true 138 | - source: 139 | kind: Service 140 | version: v1 141 | name: webhook-service 142 | fieldPath: .metadata.namespace # Namespace of the service 143 | targets: 144 | - select: 145 | kind: Certificate 146 | group: cert-manager.io 147 | version: v1 148 | name: serving-cert 149 | fieldPaths: 150 | - .spec.dnsNames.0 151 | - .spec.dnsNames.1 152 | options: 153 | delimiter: '.' 154 | index: 1 155 | create: true 156 | 157 | - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) 158 | kind: Certificate 159 | group: cert-manager.io 160 | version: v1 161 | name: serving-cert # This name should match the one in certificate.yaml 162 | fieldPath: .metadata.namespace # Namespace of the certificate CR 163 | targets: 164 | - select: 165 | kind: ValidatingWebhookConfiguration 166 | fieldPaths: 167 | - .metadata.annotations.[cert-manager.io/inject-ca-from] 168 | options: 169 | delimiter: '/' 170 | index: 0 171 | create: true 172 | - source: 173 | kind: Certificate 174 | group: cert-manager.io 175 | version: v1 176 | name: serving-cert 177 | fieldPath: .metadata.name 178 | targets: 179 | - select: 180 | kind: ValidatingWebhookConfiguration 181 | fieldPaths: 182 | - .metadata.annotations.[cert-manager.io/inject-ca-from] 183 | options: 184 | delimiter: '/' 185 | index: 1 186 | create: true 187 | 188 | - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) 189 | kind: Certificate 190 | group: cert-manager.io 191 | version: v1 192 | name: serving-cert 193 | fieldPath: .metadata.namespace # Namespace of the certificate CR 194 | targets: 195 | - select: 196 | kind: MutatingWebhookConfiguration 197 | fieldPaths: 198 | - .metadata.annotations.[cert-manager.io/inject-ca-from] 199 | options: 200 | delimiter: '/' 201 | index: 0 202 | create: true 203 | - source: 204 | kind: Certificate 205 | group: cert-manager.io 206 | version: v1 207 | name: serving-cert 208 | fieldPath: .metadata.name 209 | targets: 210 | - select: 211 | kind: MutatingWebhookConfiguration 212 | fieldPaths: 213 | - .metadata.annotations.[cert-manager.io/inject-ca-from] 214 | options: 215 | delimiter: '/' 216 | index: 1 217 | create: true 218 | # 219 | # - source: # Uncomment the following block if you have a ConversionWebhook (--conversion) 220 | # kind: Certificate 221 | # group: cert-manager.io 222 | # version: v1 223 | # name: serving-cert 224 | # fieldPath: .metadata.namespace # Namespace of the certificate CR 225 | # targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. 226 | # +kubebuilder:scaffold:crdkustomizecainjectionns 227 | # - source: 228 | # kind: Certificate 229 | # group: cert-manager.io 230 | # version: v1 231 | # name: serving-cert 232 | # fieldPath: .metadata.name 233 | # targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. 234 | # +kubebuilder:scaffold:crdkustomizecainjectionname 235 | -------------------------------------------------------------------------------- /api/v1alpha1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | /* 4 | Copyright 2025. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by controller-gen. DO NOT EDIT. 20 | 21 | package v1alpha1 22 | 23 | import ( 24 | "k8s.io/api/networking/v1" 25 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 26 | runtime "k8s.io/apimachinery/pkg/runtime" 27 | netx "net" 28 | ) 29 | 30 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 31 | func (in *CIDR) DeepCopyInto(out *CIDR) { 32 | *out = *in 33 | if in.IP != nil { 34 | in, out := &in.IP, &out.IP 35 | *out = make(netx.IP, len(*in)) 36 | copy(*out, *in) 37 | } 38 | } 39 | 40 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CIDR. 41 | func (in *CIDR) DeepCopy() *CIDR { 42 | if in == nil { 43 | return nil 44 | } 45 | out := new(CIDR) 46 | in.DeepCopyInto(out) 47 | return out 48 | } 49 | 50 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 51 | func (in CIDRList) DeepCopyInto(out *CIDRList) { 52 | { 53 | in := &in 54 | *out = make(CIDRList, len(*in)) 55 | for i := range *in { 56 | if (*in)[i] != nil { 57 | in, out := &(*in)[i], &(*out)[i] 58 | *out = new(CIDR) 59 | (*in).DeepCopyInto(*out) 60 | } 61 | } 62 | } 63 | } 64 | 65 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CIDRList. 66 | func (in CIDRList) DeepCopy() CIDRList { 67 | if in == nil { 68 | return nil 69 | } 70 | out := new(CIDRList) 71 | in.DeepCopyInto(out) 72 | return *out 73 | } 74 | 75 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 76 | func (in *EgressRule) DeepCopyInto(out *EgressRule) { 77 | *out = *in 78 | if in.Ports != nil { 79 | in, out := &in.Ports, &out.Ports 80 | *out = make([]v1.NetworkPolicyPort, len(*in)) 81 | for i := range *in { 82 | (*in)[i].DeepCopyInto(&(*out)[i]) 83 | } 84 | } 85 | if in.ToFQDNS != nil { 86 | in, out := &in.ToFQDNS, &out.ToFQDNS 87 | *out = make([]FQDN, len(*in)) 88 | copy(*out, *in) 89 | } 90 | if in.BlockPrivateIPs != nil { 91 | in, out := &in.BlockPrivateIPs, &out.BlockPrivateIPs 92 | *out = new(bool) 93 | **out = **in 94 | } 95 | } 96 | 97 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressRule. 98 | func (in *EgressRule) DeepCopy() *EgressRule { 99 | if in == nil { 100 | return nil 101 | } 102 | out := new(EgressRule) 103 | in.DeepCopyInto(out) 104 | return out 105 | } 106 | 107 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 108 | func (in *FQDNStatus) DeepCopyInto(out *FQDNStatus) { 109 | *out = *in 110 | in.LastSuccessfulTime.DeepCopyInto(&out.LastSuccessfulTime) 111 | in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) 112 | if in.Addresses != nil { 113 | in, out := &in.Addresses, &out.Addresses 114 | *out = make([]string, len(*in)) 115 | copy(*out, *in) 116 | } 117 | } 118 | 119 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FQDNStatus. 120 | func (in *FQDNStatus) DeepCopy() *FQDNStatus { 121 | if in == nil { 122 | return nil 123 | } 124 | out := new(FQDNStatus) 125 | in.DeepCopyInto(out) 126 | return out 127 | } 128 | 129 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 130 | func (in FQDNStatusList) DeepCopyInto(out *FQDNStatusList) { 131 | { 132 | in := &in 133 | *out = make(FQDNStatusList, len(*in)) 134 | for i := range *in { 135 | (*in)[i].DeepCopyInto(&(*out)[i]) 136 | } 137 | } 138 | } 139 | 140 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FQDNStatusList. 141 | func (in FQDNStatusList) DeepCopy() FQDNStatusList { 142 | if in == nil { 143 | return nil 144 | } 145 | out := new(FQDNStatusList) 146 | in.DeepCopyInto(out) 147 | return *out 148 | } 149 | 150 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 151 | func (in *NetworkPolicy) DeepCopyInto(out *NetworkPolicy) { 152 | *out = *in 153 | out.TypeMeta = in.TypeMeta 154 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 155 | in.Spec.DeepCopyInto(&out.Spec) 156 | in.Status.DeepCopyInto(&out.Status) 157 | } 158 | 159 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPolicy. 160 | func (in *NetworkPolicy) DeepCopy() *NetworkPolicy { 161 | if in == nil { 162 | return nil 163 | } 164 | out := new(NetworkPolicy) 165 | in.DeepCopyInto(out) 166 | return out 167 | } 168 | 169 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 170 | func (in *NetworkPolicy) DeepCopyObject() runtime.Object { 171 | if c := in.DeepCopy(); c != nil { 172 | return c 173 | } 174 | return nil 175 | } 176 | 177 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 178 | func (in *NetworkPolicyList) DeepCopyInto(out *NetworkPolicyList) { 179 | *out = *in 180 | out.TypeMeta = in.TypeMeta 181 | in.ListMeta.DeepCopyInto(&out.ListMeta) 182 | if in.Items != nil { 183 | in, out := &in.Items, &out.Items 184 | *out = make([]NetworkPolicy, len(*in)) 185 | for i := range *in { 186 | (*in)[i].DeepCopyInto(&(*out)[i]) 187 | } 188 | } 189 | } 190 | 191 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPolicyList. 192 | func (in *NetworkPolicyList) DeepCopy() *NetworkPolicyList { 193 | if in == nil { 194 | return nil 195 | } 196 | out := new(NetworkPolicyList) 197 | in.DeepCopyInto(out) 198 | return out 199 | } 200 | 201 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 202 | func (in *NetworkPolicyList) DeepCopyObject() runtime.Object { 203 | if c := in.DeepCopy(); c != nil { 204 | return c 205 | } 206 | return nil 207 | } 208 | 209 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 210 | func (in *NetworkPolicySpec) DeepCopyInto(out *NetworkPolicySpec) { 211 | *out = *in 212 | in.PodSelector.DeepCopyInto(&out.PodSelector) 213 | if in.Egress != nil { 214 | in, out := &in.Egress, &out.Egress 215 | *out = make([]EgressRule, len(*in)) 216 | for i := range *in { 217 | (*in)[i].DeepCopyInto(&(*out)[i]) 218 | } 219 | } 220 | if in.RetryTimeoutSeconds != nil { 221 | in, out := &in.RetryTimeoutSeconds, &out.RetryTimeoutSeconds 222 | *out = new(int32) 223 | **out = **in 224 | } 225 | } 226 | 227 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPolicySpec. 228 | func (in *NetworkPolicySpec) DeepCopy() *NetworkPolicySpec { 229 | if in == nil { 230 | return nil 231 | } 232 | out := new(NetworkPolicySpec) 233 | in.DeepCopyInto(out) 234 | return out 235 | } 236 | 237 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 238 | func (in *NetworkPolicyStatus) DeepCopyInto(out *NetworkPolicyStatus) { 239 | *out = *in 240 | in.LatestLookupTime.DeepCopyInto(&out.LatestLookupTime) 241 | if in.FQDNs != nil { 242 | in, out := &in.FQDNs, &out.FQDNs 243 | *out = make([]FQDNStatus, len(*in)) 244 | for i := range *in { 245 | (*in)[i].DeepCopyInto(&(*out)[i]) 246 | } 247 | } 248 | if in.Conditions != nil { 249 | in, out := &in.Conditions, &out.Conditions 250 | *out = make([]metav1.Condition, len(*in)) 251 | for i := range *in { 252 | (*in)[i].DeepCopyInto(&(*out)[i]) 253 | } 254 | } 255 | } 256 | 257 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPolicyStatus. 258 | func (in *NetworkPolicyStatus) DeepCopy() *NetworkPolicyStatus { 259 | if in == nil { 260 | return nil 261 | } 262 | out := new(NetworkPolicyStatus) 263 | in.DeepCopyInto(out) 264 | return out 265 | } 266 | -------------------------------------------------------------------------------- /pkg/network/resolver_test.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "testing" 8 | "time" 9 | 10 | "github.com/konsole-is/fqdn-controller/api/v1alpha1" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestDNSResolverResult_resolveReason(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | input error 19 | expected v1alpha1.NetworkPolicyResolveConditionReason 20 | }{ 21 | { 22 | name: "no error", 23 | input: nil, 24 | expected: v1alpha1.NetworkPolicyResolveSuccess, 25 | }, 26 | { 27 | name: "lookupError with reason", 28 | input: &lookupError{Reason: v1alpha1.NetworkPolicyResolveInvalidDomain}, 29 | expected: v1alpha1.NetworkPolicyResolveInvalidDomain, 30 | }, 31 | { 32 | name: "dns timeout error", 33 | input: &net.DNSError{ 34 | IsTimeout: true, 35 | }, 36 | expected: v1alpha1.NetworkPolicyResolveTimeout, 37 | }, 38 | { 39 | name: "dns not found", 40 | input: &net.DNSError{ 41 | IsNotFound: true, 42 | }, 43 | expected: v1alpha1.NetworkPolicyResolveTimeout, // your logic maps this too 44 | }, 45 | { 46 | name: "dns temporary", 47 | input: &net.DNSError{ 48 | IsTemporary: true, 49 | }, 50 | expected: v1alpha1.NetworkPolicyResolveTemporaryError, 51 | }, 52 | { 53 | name: "other error", 54 | input: errors.New("something went wrong"), 55 | expected: v1alpha1.NetworkPolicyResolveOtherError, 56 | }, 57 | } 58 | 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | assert.Equal(t, tt.expected, resolveReason(tt.input)) 62 | }) 63 | } 64 | } 65 | 66 | func TestDNSResolverResult_reasonMessage(t *testing.T) { 67 | tests := []struct { 68 | name string 69 | input error 70 | expected string 71 | }{ 72 | { 73 | name: "no error", 74 | input: nil, 75 | expected: "Resolve succeeded", 76 | }, 77 | { 78 | name: "lookupError", 79 | input: &lookupError{Reason: v1alpha1.NetworkPolicyResolveDomainNotFound, Message: "lookup error"}, 80 | expected: "lookup error", 81 | }, 82 | { 83 | name: "dns timeout", 84 | input: &net.DNSError{ 85 | IsTimeout: true, 86 | }, 87 | expected: "Timeout waiting for DNS response", 88 | }, 89 | { 90 | name: "dns not found", 91 | input: &net.DNSError{ 92 | IsNotFound: true, 93 | }, 94 | expected: "Domain not found", 95 | }, 96 | { 97 | name: "dns temporary", 98 | input: &net.DNSError{ 99 | IsTemporary: true, 100 | }, 101 | expected: "Temporary failure in name resolution", 102 | }, 103 | { 104 | name: "generic error", 105 | input: errors.New("something else failed"), 106 | expected: "something else failed", 107 | }, 108 | } 109 | 110 | for _, tt := range tests { 111 | t.Run(tt.name, func(t *testing.T) { 112 | assert.Equal(t, tt.expected, resolveMessage(tt.input)) 113 | }) 114 | } 115 | } 116 | 117 | func makeCIDR(ip string) *v1alpha1.CIDR { 118 | c, _ := v1alpha1.NewCIDR(ip) 119 | return c 120 | } 121 | 122 | func Test_DNSResolverResultList_CIDRs(t *testing.T) { 123 | list := DNSResolverResultList{ 124 | {CIDRs: []*v1alpha1.CIDR{makeCIDR("1.1.1.1/32")}}, 125 | {CIDRs: []*v1alpha1.CIDR{makeCIDR("2.2.2.2/32")}}, 126 | } 127 | cidrs := list.CIDRs() 128 | assert.Len(t, cidrs, 2) 129 | assert.Equal(t, "1.1.1.1", cidrs[0].IP.String()) 130 | assert.Equal(t, "2.2.2.2", cidrs[1].IP.String()) 131 | } 132 | 133 | func Test_DNSResolverResultList_AggregatedResolveReason(t *testing.T) { 134 | list := DNSResolverResultList{ 135 | {Status: v1alpha1.NetworkPolicyResolveSuccess}, 136 | {Status: v1alpha1.NetworkPolicyResolveOtherError}, // Highest Priority 137 | {Status: v1alpha1.NetworkPolicyResolveTemporaryError}, 138 | {Status: v1alpha1.NetworkPolicyResolveSuccess}, 139 | } 140 | reason := list.AggregatedResolveStatus() 141 | assert.Equal(t, v1alpha1.NetworkPolicyResolveOtherError, reason) 142 | } 143 | 144 | func Test_DNSResolverResultList_AggregatedResolveMessage(t *testing.T) { 145 | list := DNSResolverResultList{ 146 | {Status: v1alpha1.NetworkPolicyResolveSuccess, Message: "Not this"}, 147 | {Status: v1alpha1.NetworkPolicyResolveOtherError, Message: "This"}, // Highest Priority 148 | {Status: v1alpha1.NetworkPolicyResolveTemporaryError, Message: "Not this"}, 149 | {Status: v1alpha1.NetworkPolicyResolveSuccess, Message: "Not this"}, 150 | } 151 | msg := list.AggregatedResolveMessage() 152 | assert.Contains(t, msg, "This") 153 | } 154 | 155 | func Test_DNSResolverResultList_LookupTable(t *testing.T) { 156 | list := DNSResolverResultList{ 157 | {Domain: "ok.com", CIDRs: []*v1alpha1.CIDR{makeCIDR("8.8.8.8/32")}}, 158 | {Domain: "fail.com", Error: errors.New("bad")}, 159 | } 160 | table := list.LookupTable() 161 | assert.Len(t, table, 2) 162 | assert.Contains(t, table, v1alpha1.FQDN("ok.com")) 163 | assert.Equal(t, v1alpha1.FQDN("ok.com"), table["ok.com"].Domain) 164 | assert.Contains(t, table, v1alpha1.FQDN("fail.com")) 165 | assert.Equal(t, v1alpha1.FQDN("fail.com"), table["fail.com"].Domain) 166 | } 167 | 168 | // fakeResolver returns a predefined list of IPs and an optional error. 169 | type fakeResolver struct { 170 | results []net.IP 171 | err error 172 | } 173 | 174 | func (f *fakeResolver) LookupIP(_ context.Context, _ string, _ string) ([]net.IP, error) { 175 | return f.results, f.err 176 | } 177 | 178 | func Test_lookupIP_withFakeResolver(t *testing.T) { 179 | tests := []struct { 180 | name string 181 | fqdn v1alpha1.FQDN 182 | networkType v1alpha1.NetworkType 183 | resolver *fakeResolver 184 | expectCIDRs []string 185 | expectErr bool 186 | }{ 187 | { 188 | name: "invalid FQDN", 189 | fqdn: "invalid_fqdn", // invalid because missing dot, etc. 190 | networkType: v1alpha1.All, 191 | resolver: &fakeResolver{results: nil}, 192 | expectErr: true, 193 | }, 194 | { 195 | name: "resolver returns error", 196 | fqdn: "example.com", 197 | networkType: v1alpha1.All, 198 | resolver: &fakeResolver{err: errors.New("mocked DNS error")}, 199 | expectErr: true, 200 | }, 201 | { 202 | name: "successful IP lookup", 203 | fqdn: "example.com", 204 | networkType: v1alpha1.All, 205 | resolver: &fakeResolver{ 206 | results: []net.IP{ 207 | net.ParseIP("1.2.3.4"), 208 | net.ParseIP("5.6.7.8"), 209 | }, 210 | }, 211 | expectCIDRs: []string{"1.2.3.4/32", "5.6.7.8/32"}, 212 | expectErr: false, 213 | }, 214 | } 215 | 216 | for _, tt := range tests { 217 | t.Run(tt.name, func(t *testing.T) { 218 | r := &DNSResolver{ 219 | resolver: tt.resolver, 220 | } 221 | 222 | cidrs, err := r.lookupIP(context.Background(), tt.networkType, tt.fqdn) 223 | 224 | if tt.expectErr { 225 | assert.Error(t, err) 226 | assert.Nil(t, cidrs) 227 | } else { 228 | assert.NoError(t, err) 229 | var got []string 230 | for _, c := range cidrs { 231 | got = append(got, c.String()) 232 | } 233 | assert.ElementsMatch(t, tt.expectCIDRs, got) 234 | } 235 | }) 236 | } 237 | } 238 | 239 | // fnFakeResolver mocks the Resolver interface 240 | type fnFakeResolver struct { 241 | lookupFunc func(ctx context.Context, network, host string) ([]net.IP, error) 242 | } 243 | 244 | func (f *fnFakeResolver) LookupIP(ctx context.Context, network, host string) ([]net.IP, error) { 245 | return f.lookupFunc(ctx, network, host) 246 | } 247 | 248 | func TestDNSResolver_Resolve(t *testing.T) { 249 | t.Run("successful resolution", func(t *testing.T) { 250 | fake := &fnFakeResolver{ 251 | lookupFunc: func(ctx context.Context, network, host string) ([]net.IP, error) { 252 | return []net.IP{net.ParseIP("1.2.3.4")}, nil 253 | }, 254 | } 255 | 256 | resolver := &DNSResolver{resolver: fake} 257 | result := resolver.Resolve( 258 | context.Background(), time.Second, 1, v1alpha1.Ipv4, []v1alpha1.FQDN{"example.com"}, 259 | ) 260 | 261 | assert.Len(t, result, 1) 262 | require.NoError(t, result[0].Error) 263 | assert.Equal(t, "example.com", string(result[0].Domain)) 264 | assert.Len(t, result[0].CIDRs, 1) 265 | assert.Equal(t, "1.2.3.4", result[0].CIDRs[0].IP.String()) 266 | }) 267 | 268 | t.Run("invalid FQDN returns lookupError", func(t *testing.T) { 269 | // Invalid FQDN that will fail `Valid()` 270 | invalidFQDN := v1alpha1.FQDN("") 271 | 272 | fake := &fnFakeResolver{ 273 | lookupFunc: func(ctx context.Context, network, host string) ([]net.IP, error) { 274 | return []net.IP{}, nil // won't be used 275 | }, 276 | } 277 | 278 | resolver := &DNSResolver{resolver: fake} 279 | result := resolver.Resolve( 280 | context.Background(), time.Second, 1, v1alpha1.All, []v1alpha1.FQDN{invalidFQDN}, 281 | ) 282 | 283 | assert.Len(t, result, 1) 284 | require.Error(t, result[0].Error) 285 | assert.Equal(t, invalidFQDN, result[0].Domain) 286 | 287 | var lookupErr *lookupError 288 | assert.True(t, errors.As(result[0].Error, &lookupErr)) 289 | assert.Equal(t, v1alpha1.NetworkPolicyResolveInvalidDomain, lookupErr.Reason) 290 | }) 291 | 292 | t.Run("timeout returns context error", func(t *testing.T) { 293 | fake := &fnFakeResolver{ 294 | lookupFunc: func(ctx context.Context, network, host string) ([]net.IP, error) { 295 | <-ctx.Done() 296 | return nil, ctx.Err() 297 | }, 298 | } 299 | 300 | resolver := &DNSResolver{resolver: fake} 301 | start := time.Now() 302 | result := resolver.Resolve( 303 | context.Background(), 100*time.Millisecond, 1, v1alpha1.All, []v1alpha1.FQDN{"timeout.com"}, 304 | ) 305 | elapsed := time.Since(start) 306 | 307 | assert.Less(t, elapsed, 500*time.Millisecond, "Should timeout early") 308 | assert.Len(t, result, 1) 309 | require.Error(t, result[0].Error) 310 | assert.True(t, errors.Is(result[0].Error, context.DeadlineExceeded)) 311 | }) 312 | } 313 | 314 | func TestDNSResolver_ResolveGoogle(t *testing.T) { 315 | resolver := NewDNSResolver() 316 | result := resolver.Resolve( 317 | context.Background(), time.Second*3, 1, v1alpha1.Ipv4, []v1alpha1.FQDN{"google.com"}, 318 | ) 319 | t.Log(result.CIDRs()) 320 | } 321 | -------------------------------------------------------------------------------- /api/v1alpha1/network_policy_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 v1alpha1 18 | 19 | import ( 20 | netv1 "k8s.io/api/networking/v1" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | // NetworkType defines the available ip address types to resolve 25 | // 26 | // - Options are one of: 'all', 'ipv4', 'ipv6' 27 | // 28 | // +kubebuilder:validation:Enum=all;ipv4;ipv6 29 | type NetworkType string 30 | 31 | const ( 32 | All NetworkType = "all" 33 | Ipv4 NetworkType = "ipv4" 34 | Ipv6 NetworkType = "ipv6" 35 | ) 36 | 37 | // ResolverString returns the string value that net.Resolver expects in LookupIP. 38 | // Returns an empty string for unknown types. 39 | func (n NetworkType) ResolverString() string { 40 | switch n { 41 | case All: 42 | return "ip" 43 | case Ipv4: 44 | return "ip4" 45 | case Ipv6: 46 | return "ip6" 47 | } 48 | return "" 49 | } 50 | 51 | // FQDN is short for Fully Qualified Domain Name and represents a complete domain name that uniquely identifies a host 52 | // on the internet. It must consist of one or more labels separated by dots (e.g., "api.example.com"), where each label 53 | // can contain letters, digits, and hyphens, but cannot start or end with a hyphen. The FQDN must end with a top-level 54 | // domain (e.g., ".com", ".org") of at least two characters. 55 | // 56 | // +kubebuilder:validation:Pattern=`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$` 57 | type FQDN string 58 | 59 | // EgressRule defines rules for outbound network traffic to the specified FQDNs on the specified ports. 60 | // Each FQDNs IP's will be looked up periodically to update the underlying NetworkPolicy. 61 | type EgressRule struct { 62 | // Ports describes the ports to allow traffic on 63 | Ports []netv1.NetworkPolicyPort `json:"ports"` 64 | // ToFQDNS are the FQDNs to which traffic is allowed (outgoing) 65 | // +kubebuilder:validation:MaxItems=20 66 | ToFQDNS []FQDN `json:"toFQDNS"` 67 | // BlockPrivateIPs when set, overwrites the default behavior of the same field in NetworkPolicySpec 68 | BlockPrivateIPs *bool `json:"blockPrivateIPs,omitempty"` 69 | } 70 | 71 | // NetworkPolicySpec defines the desired state of NetworkPolicy. 72 | type NetworkPolicySpec struct { 73 | // PodSelector defines which pods this network policy shall apply to 74 | PodSelector metav1.LabelSelector `json:"podSelector"` 75 | // Egress defines the outbound network traffic rules for the selected pods 76 | Egress []EgressRule `json:"egress,omitempty"` 77 | // EnabledNetworkType defines which type of IP addresses to allow. 78 | // 79 | // - Options are one of: 'all', 'ipv4', 'ipv6' 80 | // - Defaults to 'ipv4' if not specified 81 | // 82 | // +kubebuilder:default:=ipv4 83 | EnabledNetworkType NetworkType `json:"enabledNetworkType,omitempty"` 84 | // TTLSeconds The interval at which the IP addresses of the FQDNs are re-evaluated. 85 | // 86 | // - Defaults to 60 seconds if not specified. 87 | // - Maximum value is 1800 seconds. 88 | // - Minimum value is 5 seconds. 89 | // - Must be greater than ResolveTimeoutSeconds. 90 | // 91 | // +kubebuilder:validation:Minimum=5 92 | // +kubebuilder:validation:Maximum=1800 93 | // +kubebuilder:default:=60 94 | TTLSeconds int32 `json:"ttlSeconds,omitempty"` 95 | // ResolveTimeoutSeconds The timeout to use for lookups of the FQDNs 96 | // 97 | // - Defaults to 3 seconds if not specified. 98 | // - Maximum value is 60 seconds. 99 | // - Minimum value is 1 second. 100 | // - Must be less than TTLSeconds. 101 | // 102 | // +kubebuilder:validation:Minimum=1 103 | // +kubebuilder:validation:Maximum=60 104 | // +kubebuilder:default:=3 105 | ResolveTimeoutSeconds int32 `json:"resolveTimeoutSeconds,omitempty"` 106 | // RetryTimeoutSeconds How long the resolving of an individual FQDN should be retried in case of errors before being 107 | // removed from the underlying network policy. This ensures intermittent failures in name resolution do not clear 108 | // existing addresses causing unwanted service disruption. 109 | // 110 | // - Defaults to 3600 (1 hour) if not specified (nil) 111 | // - Maximum value is 86400 (24 hours) 112 | // 113 | // +kubebuilder:validation:Maximum=86400 114 | // +kubebuilder:default:=3600 115 | RetryTimeoutSeconds *int32 `json:"retryTimeoutSeconds,omitempty"` 116 | // BlockPrivateIPs When set to true, all private IPs are emitted from the rules unless otherwise specified at the 117 | // EgressRule level. 118 | // 119 | // - Defaults to false if not specified 120 | BlockPrivateIPs bool `json:"blockPrivateIPs,omitempty"` 121 | } 122 | 123 | // FQDNStatus defines the status of a given FQDN 124 | type FQDNStatus struct { 125 | // FQDN the FQDN this status refers to 126 | FQDN FQDN `json:"fqdn"` 127 | // LastSuccessfulTime is the last time the FQDN was resolved successfully. I.e. the last time the ResolveReason was 128 | // NetworkPolicyResolveSuccess 129 | LastSuccessfulTime metav1.Time `json:"LastSuccessfulTime,omitempty"` 130 | // LastTransitionTime is the last time the reason changed 131 | LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` 132 | // ResolveReason describes the last resolve status 133 | ResolveReason NetworkPolicyResolveConditionReason `json:"resolveReason,omitempty"` 134 | // ResolveMessage a message describing the reason for the status 135 | ResolveMessage string `json:"resolveMessage,omitempty"` 136 | // Addresses is the list of resolved addresses for the given FQDN. 137 | // The list is cleared if LastSuccessfulTime exceeds the time limit specified by 138 | // NetworkPolicySpec.RetryTimeoutSeconds 139 | Addresses []string `json:"addresses,omitempty"` 140 | } 141 | 142 | // NetworkPolicyStatus defines the observed state of NetworkPolicy. 143 | type NetworkPolicyStatus struct { 144 | // LatestLookupTime The last time the IPs were resolved 145 | LatestLookupTime metav1.Time `json:"latestLookupTime,omitempty"` 146 | 147 | // FQDNs lists the status of each FQDN in the network policy 148 | FQDNs []FQDNStatus `json:"fqdns,omitempty"` 149 | 150 | // AppliedAddressCount Counts the number of unique IPs applied in the generated network policy 151 | AppliedAddressCount int32 `json:"appliedAddressCount,omitempty"` 152 | 153 | // TotalAddressCount The number of total IPs resolved from the FQDNs before filtering 154 | TotalAddressCount int32 `json:"totalAddressesCount,omitempty"` 155 | 156 | Conditions []metav1.Condition `json:"conditions"` 157 | ObservedGeneration int64 `json:"observedGeneration,omitempty"` 158 | } 159 | 160 | type NetworkPolicyConditionType string 161 | 162 | const ( 163 | NetworkPolicyReadyCondition NetworkPolicyConditionType = "Ready" 164 | NetworkPolicyResolveCondition NetworkPolicyConditionType = "Resolve" 165 | ) 166 | 167 | type NetworkPolicyReadyConditionReason string 168 | 169 | const ( 170 | NetworkPolicyReady NetworkPolicyReadyConditionReason = "Ready" 171 | NetworkPolicyEmptyRules NetworkPolicyReadyConditionReason = "EmptyRules" 172 | NetworkPolicyFailed NetworkPolicyReadyConditionReason = "Failed" 173 | ) 174 | 175 | type NetworkPolicyResolveConditionReason string 176 | 177 | const ( 178 | NetworkPolicyResolveOtherError NetworkPolicyResolveConditionReason = "OTHER_ERROR" 179 | NetworkPolicyResolveInvalidDomain NetworkPolicyResolveConditionReason = "INVALID_DOMAIN" 180 | NetworkPolicyResolveDomainNotFound NetworkPolicyResolveConditionReason = "NXDOMAIN" 181 | NetworkPolicyResolveTimeout NetworkPolicyResolveConditionReason = "TIMEOUT" 182 | NetworkPolicyResolveTemporaryError NetworkPolicyResolveConditionReason = "TEMPORARY" 183 | NetworkPolicyResolveUnknown NetworkPolicyResolveConditionReason = "UNKNOWN" 184 | NetworkPolicyResolveSuccess NetworkPolicyResolveConditionReason = "SUCCESS" 185 | ) 186 | 187 | func (r NetworkPolicyResolveConditionReason) Priority() int { 188 | switch r { 189 | case NetworkPolicyResolveOtherError: 190 | return 6 191 | case NetworkPolicyResolveInvalidDomain: 192 | return 5 193 | case NetworkPolicyResolveDomainNotFound: 194 | return 4 195 | case NetworkPolicyResolveTimeout: 196 | return 3 197 | case NetworkPolicyResolveTemporaryError: 198 | return 2 199 | case NetworkPolicyResolveUnknown: 200 | return 1 201 | default: 202 | return 0 203 | } 204 | } 205 | 206 | func (r NetworkPolicyResolveConditionReason) Transient() bool { 207 | switch r { 208 | case NetworkPolicyResolveInvalidDomain: 209 | return false 210 | case NetworkPolicyResolveDomainNotFound: 211 | return false 212 | default: 213 | return true 214 | } 215 | } 216 | 217 | // +kubebuilder:object:root=true 218 | // +kubebuilder:subresource:status 219 | 220 | // NetworkPolicy is the Schema for the networkpolicies API. 221 | // 222 | // - Please ensure the pods you apply this network policy to have a separate policy allowing 223 | // access to CoreDNS / KubeDNS pods in your cluster. Without this, once this Network policy is applied, access to 224 | // DNS will be blocked due to how network policies deny all unspecified traffic by default once applied. 225 | // - If no addresses are resolved from the FQDNs from the Egress rules that were specified, the default 226 | // behavior is to block all Egress traffic. This conforms with the default behavior of network policies 227 | // (networking.k8s.io/v1) 228 | // 229 | // +kubebuilder:resource:path=fqdnnetworkpolicies,shortName=fqdn,singular=fqdnnetworkpolicy,scope=Namespaced 230 | // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`,description="Ready condition status" 231 | // +kubebuilder:printcolumn:name="Resolved",type=string,JSONPath=`.status.conditions[?(@.type=="Resolve")].status`,description="Resolve condition status" 232 | // +kubebuilder:printcolumn:name="Resolved IPs",type=integer,JSONPath=`.status.totalAddressesCount`,description="Number of resolved IPs before filtering" 233 | // +kubebuilder:printcolumn:name="Applied IPs",type=integer,JSONPath=`.status.appliedAddressCount`,description="Number of applied IPs" 234 | // +kubebuilder:printcolumn:name="Last Lookup",type=date,JSONPath=`.status.latestLookupTime`,description="Time of last FQDN resolve" 235 | // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` 236 | type NetworkPolicy struct { 237 | metav1.TypeMeta `json:",inline"` 238 | metav1.ObjectMeta `json:"metadata,omitempty"` 239 | 240 | Spec NetworkPolicySpec `json:"spec,omitempty"` 241 | Status NetworkPolicyStatus `json:"status,omitempty"` 242 | } 243 | 244 | // +kubebuilder:object:root=true 245 | 246 | // NetworkPolicyList contains a list of NetworkPolicy. 247 | type NetworkPolicyList struct { 248 | metav1.TypeMeta `json:",inline"` 249 | metav1.ListMeta `json:"metadata,omitempty"` 250 | Items []NetworkPolicy `json:"items"` 251 | } 252 | 253 | func init() { 254 | SchemeBuilder.Register(&NetworkPolicy{}, &NetworkPolicyList{}) 255 | } 256 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 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 | "crypto/tls" 21 | "flag" 22 | "os" 23 | "path/filepath" 24 | 25 | "github.com/konsole-is/fqdn-controller/pkg/network" 26 | 27 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 28 | // to ensure that exec-entrypoint and run can make use of them. 29 | _ "k8s.io/client-go/plugin/pkg/client/auth" 30 | 31 | goruntime "runtime" 32 | 33 | "k8s.io/apimachinery/pkg/runtime" 34 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 35 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 36 | ctrl "sigs.k8s.io/controller-runtime" 37 | "sigs.k8s.io/controller-runtime/pkg/certwatcher" 38 | "sigs.k8s.io/controller-runtime/pkg/healthz" 39 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 40 | "sigs.k8s.io/controller-runtime/pkg/metrics/filters" 41 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 42 | "sigs.k8s.io/controller-runtime/pkg/webhook" 43 | 44 | fqdnv1alpha1 "github.com/konsole-is/fqdn-controller/api/v1alpha1" 45 | "github.com/konsole-is/fqdn-controller/internal/controller" 46 | webhookv1alpha1 "github.com/konsole-is/fqdn-controller/internal/webhook/v1alpha1" 47 | // +kubebuilder:scaffold:imports 48 | ) 49 | 50 | var ( 51 | scheme = runtime.NewScheme() 52 | setupLog = ctrl.Log.WithName("setup") 53 | ) 54 | 55 | func init() { 56 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 57 | 58 | utilruntime.Must(fqdnv1alpha1.AddToScheme(scheme)) 59 | // +kubebuilder:scaffold:scheme 60 | } 61 | 62 | // nolint:gocyclo 63 | func main() { 64 | var metricsAddr string 65 | var metricsCertPath, metricsCertName, metricsCertKey string 66 | var webhookCertPath, webhookCertName, webhookCertKey string 67 | var enableLeaderElection bool 68 | var probeAddr string 69 | var secureMetrics bool 70 | var enableHTTP2 bool 71 | var tlsOpts []func(*tls.Config) 72 | var maxConcurrentResolves int 73 | flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ 74 | "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") 75 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 76 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, 77 | "Enable leader election for controller manager. "+ 78 | "Enabling this will ensure there is only one active controller manager.") 79 | flag.BoolVar(&secureMetrics, "metrics-secure", true, 80 | "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") 81 | flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") 82 | flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") 83 | flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") 84 | flag.StringVar(&metricsCertPath, "metrics-cert-path", "", 85 | "The directory that contains the metrics server certificate.") 86 | flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") 87 | flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") 88 | flag.BoolVar(&enableHTTP2, "enable-http2", false, 89 | "If set, HTTP/2 will be enabled for the metrics and webhook servers") 90 | flag.IntVar(&maxConcurrentResolves, "max-concurrent-resolves", 0, 91 | "How many goroutines can be spawned to resolve FQDNs to IP addresses.") 92 | opts := zap.Options{ 93 | Development: true, 94 | } 95 | opts.BindFlags(flag.CommandLine) 96 | flag.Parse() 97 | 98 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 99 | 100 | if maxConcurrentResolves <= 0 { 101 | maxConcurrentResolves = goruntime.NumCPU() 102 | if maxConcurrentResolves < 1 { 103 | maxConcurrentResolves = 1 104 | } 105 | } 106 | 107 | // if the enable-http2 flag is false (the default), http/2 should be disabled 108 | // due to its vulnerabilities. More specifically, disabling http/2 will 109 | // prevent from being vulnerable to the HTTP/2 Stream Cancellation and 110 | // Rapid Reset CVEs. For more information see: 111 | // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 112 | // - https://github.com/advisories/GHSA-4374-p667-p6c8 113 | disableHTTP2 := func(c *tls.Config) { 114 | setupLog.Info("disabling http/2") 115 | c.NextProtos = []string{"http/1.1"} 116 | } 117 | 118 | if !enableHTTP2 { 119 | tlsOpts = append(tlsOpts, disableHTTP2) 120 | } 121 | 122 | // Create watchers for metrics and webhooks certificates 123 | var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher 124 | 125 | // Initial webhook TLS options 126 | webhookTLSOpts := tlsOpts 127 | 128 | if len(webhookCertPath) > 0 { 129 | setupLog.Info("Initializing webhook certificate watcher using provided certificates", 130 | "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) 131 | 132 | var err error 133 | webhookCertWatcher, err = certwatcher.New( 134 | filepath.Join(webhookCertPath, webhookCertName), 135 | filepath.Join(webhookCertPath, webhookCertKey), 136 | ) 137 | if err != nil { 138 | setupLog.Error(err, "Failed to initialize webhook certificate watcher") 139 | os.Exit(1) 140 | } 141 | 142 | webhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) { 143 | config.GetCertificate = webhookCertWatcher.GetCertificate 144 | }) 145 | } 146 | 147 | webhookServer := webhook.NewServer(webhook.Options{ 148 | TLSOpts: webhookTLSOpts, 149 | }) 150 | 151 | // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. 152 | // More info: 153 | // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/metrics/server 154 | // - https://book.kubebuilder.io/reference/metrics.html 155 | metricsServerOptions := metricsserver.Options{ 156 | BindAddress: metricsAddr, 157 | SecureServing: secureMetrics, 158 | TLSOpts: tlsOpts, 159 | } 160 | 161 | if secureMetrics { 162 | // FilterProvider is used to protect the metrics endpoint with authn/authz. 163 | // These configurations ensure that only authorized users and service accounts 164 | // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: 165 | // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/metrics/filters#WithAuthenticationAndAuthorization 166 | metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization 167 | } 168 | 169 | // If the certificate is not specified, controller-runtime will automatically 170 | // generate self-signed certificates for the metrics server. While convenient for development and testing, 171 | // this setup is not recommended for production. 172 | // 173 | // TODO(user): If you enable certManager, uncomment the following lines: 174 | // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates 175 | // managed by cert-manager for the metrics server. 176 | // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. 177 | if len(metricsCertPath) > 0 { 178 | setupLog.Info("Initializing metrics certificate watcher using provided certificates", 179 | "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) 180 | 181 | var err error 182 | metricsCertWatcher, err = certwatcher.New( 183 | filepath.Join(metricsCertPath, metricsCertName), 184 | filepath.Join(metricsCertPath, metricsCertKey), 185 | ) 186 | if err != nil { 187 | setupLog.Error(err, "to initialize metrics certificate watcher", "error", err) 188 | os.Exit(1) 189 | } 190 | 191 | metricsServerOptions.TLSOpts = append(metricsServerOptions.TLSOpts, func(config *tls.Config) { 192 | config.GetCertificate = metricsCertWatcher.GetCertificate 193 | }) 194 | } 195 | 196 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 197 | Scheme: scheme, 198 | Metrics: metricsServerOptions, 199 | WebhookServer: webhookServer, 200 | HealthProbeBindAddress: probeAddr, 201 | LeaderElection: enableLeaderElection, 202 | LeaderElectionID: "de9670af.konsole.is", 203 | // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily 204 | // when the Manager ends. This requires the binary to immediately end when the 205 | // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly 206 | // speeds up voluntary leader transitions as the new leader don't have to wait 207 | // LeaseDuration time first. 208 | // 209 | // In the default scaffold provided, the program ends immediately after 210 | // the manager stops, so would be fine to enable this option. However, 211 | // if you are doing or is intended to do any operation such as perform cleanups 212 | // after the manager stops then its usage might be unsafe. 213 | // LeaderElectionReleaseOnCancel: true, 214 | }) 215 | if err != nil { 216 | setupLog.Error(err, "unable to start manager") 217 | os.Exit(1) 218 | } 219 | 220 | ctx := ctrl.SetupSignalHandler() 221 | 222 | if err := (&controller.NetworkPolicyReconciler{ 223 | Client: mgr.GetClient(), 224 | Scheme: mgr.GetScheme(), 225 | EventRecorder: mgr.GetEventRecorderFor("fqdn-controller"), 226 | DNSResolver: network.NewDNSResolver(), 227 | MaxConcurrentResolves: maxConcurrentResolves, 228 | }).SetupWithManager(mgr); err != nil { 229 | setupLog.Error(err, "unable to create controller", "controller", "NetworkPolicy") 230 | os.Exit(1) 231 | } 232 | // nolint:goconst 233 | if os.Getenv("ENABLE_WEBHOOKS") != "false" { 234 | if err := webhookv1alpha1.SetupNetworkPolicyWebhookWithManager(mgr); err != nil { 235 | setupLog.Error(err, "unable to create webhook", "webhook", "NetworkPolicy") 236 | os.Exit(1) 237 | } 238 | } 239 | // +kubebuilder:scaffold:builder 240 | 241 | if metricsCertWatcher != nil { 242 | setupLog.Info("Adding metrics certificate watcher to manager") 243 | if err := mgr.Add(metricsCertWatcher); err != nil { 244 | setupLog.Error(err, "unable to add metrics certificate watcher to manager") 245 | os.Exit(1) 246 | } 247 | } 248 | 249 | if webhookCertWatcher != nil { 250 | setupLog.Info("Adding webhook certificate watcher to manager") 251 | if err := mgr.Add(webhookCertWatcher); err != nil { 252 | setupLog.Error(err, "unable to add webhook certificate watcher to manager") 253 | os.Exit(1) 254 | } 255 | } 256 | 257 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 258 | setupLog.Error(err, "unable to set up health check") 259 | os.Exit(1) 260 | } 261 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 262 | setupLog.Error(err, "unable to set up ready check") 263 | os.Exit(1) 264 | } 265 | 266 | setupLog.Info("starting manager") 267 | if err := mgr.Start(ctx); err != nil { 268 | setupLog.Error(err, "problem running manager") 269 | os.Exit(1) 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fqdn-controller 2 | 3 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/fqdn-controller)](https://artifacthub.io/packages/search?repo=fqdn-controller) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/konsole-is/fqdn-controller)](https://goreportcard.com/report/github.com/konsole-is/fqdn-controller) 5 | [![License](https://img.shields.io/github/license/konsole-is/fqdn-controller)](LICENSE) 6 | 7 | Traditional Kubernetes NetworkPolicy objects do not support rules based on fully qualified domain names (FQDNs). 8 | As a result, teams often resort to installing complex solutions like Cilium, custom VPC CNIs, or service meshes 9 | introducing operational overhead, additional dependencies, and deeper integration into the cluster network stack. 10 | 11 | fqdn-controller addresses this gap by extending Kubernetes with a controller that dynamically resolves FQDNs to IPs 12 | and maintains them in standard NetworkPolicy objects. It avoids invasive networking changes and works with the default 13 | CNI, making it suitable for clusters running in the cloud or in environments where simplicity and portability are 14 | priorities. 15 | 16 | --- 17 | 18 | ## 📚 Table of Contents 19 | 20 | - [✨ Features](#-features) 21 | - [⚠️ Limitations](#-limitations) 22 | - [🧾 CRD Overview](#-crd-overview) 23 | - [Important Behavior Notes](#important-behavior-notes) 24 | - [Resource Kind & Short Name](#resource-kind--short-name) 25 | - [Key Fields](#key-fields) 26 | - [IP Filtering](#ip-filtering) 27 | - [IP Retention on Failure](#ip-retention-on-failure) 28 | - [Status and Observability](#status-and-observability) 29 | - [📄 Custom Resource Example](#custom-resource-example) 30 | - [🚀 Installation](#-installation) 31 | - [Helm Installation](#helm-installation) 32 | - [Kubectl Installation](#kubectl-installation) 33 | - [🧪 Development](#-development) 34 | - [📦 Releases](#-releases) 35 | - [🤝 Contributing](#-contributing) 36 | 37 | --- 38 | 39 | ## ✨ Features 40 | 41 | - Create `NetworkPolicy` egress rules based on FQDNs 42 | - Automatically resolve and refresh IPs on a configurable schedule 43 | - Optionally filter private IPs to enforce security policies 44 | - Supports IPv4, IPv6, or both 45 | - Helm chart available via Artifact Hub 46 | 47 | --- 48 | 49 | ## ⚠️ Limitations 50 | 51 | The controller is not suitable for domains with highly dynamic IP resolution. For example, domains like google.com may return different IPs every few minutes. If your workloads rely on consistent IP connectivity to such domains and cannot tolerate brief network disruptions, this operator may not meet your needs. 52 | 53 | That said, domains with mostly static IPs, but that may occasionally change, can still work well. In such cases, network connectivity may be briefly interrupted during the window between an IP change and the next successful DNS resolution. The duration of this outage is at most equal to the ttlSeconds value specified in your policy. 54 | 55 | --- 56 | 57 | ## 🧾 CRD Overview 58 | 59 | The NetworkPolicy custom resource allows you to specify egress rules by domain name. The controller performs DNS 60 | resolution on these FQDNs and applies the resolved IPs into [standard Kubernetes NetworkPolicy objects](https://kubernetes.io/docs/concepts/services-networking/network-policies/). 61 | 62 | > [!IMPORTANT] 63 | > If your pods rely on DNS, you must define a separate policy that allows egress to CoreDNS or KubeDNS. 64 | > Since you are considering an FQDN based egress policy, this is highly likely the case. 65 | 66 | > [!IMPORTANT] 67 | > **No IPs = Egress Deny All**. If no IPs are resolved for a rule, egress traffic is blocked. This conforms with standard 68 | > Kubernetes NetworkPolicy behavior. 69 | 70 | ### Resource Kind & Short Name 71 | 72 | This controller defines a custom resource with the kind `NetworkPolicy` (under the fqdn.konsole.is API group). 73 | To avoid conflicts with the built-in Kubernetes networking.k8s.io/v1 NetworkPolicy, the CRD is registered with: 74 | 75 | - Long name: `fqdnnetworkpolicy` 76 | - Short name: `fqdn` 77 | 78 | This allows you to interact with the resource easily via kubectl without clashing with the standard resource: 79 | 80 | ```bash 81 | kubectl get fqdn # shorthand 82 | kubectl get fqdnnetworkpolicy # full resource name 83 | ``` 84 | 85 | Use these when inspecting or managing FQDN-based policies in your cluster. 86 | 87 | ### Key Fields 88 | 89 | | Field | Description | 90 | |--------------------------|------------------------------------------------------------------------| 91 | | `podSelector` | Selector to match the target pods | 92 | | `egress.toFQDNs` | List of FQDNs to allow traffic to (max 20 per rule) | 93 | | `egress.ports` | Ports and protocols for each egress rule | 94 | | `egress.blockPrivateIPs` | Whether to exclude RFC1918 IPs for the rule (overrides global setting) | 95 | | `ttlSeconds` | Frequency (in seconds) to re-resolve FQDNs. Default: `60` | 96 | | `resolveTimeoutSeconds` | Timeout (in seconds) for DNS lookups. Default: `3` | 97 | | `retryTimeoutSeconds` | Time to keep using stale IPs before dropping them. Default: `3600` | 98 | | `blockPrivateIPs` | Whether to exclude private IPs globally | 99 | | `enabledNetworkType` | IP types to allow: `ipv4`, `ipv6`, or `all`. Default: `ipv4` | 100 | 101 | ### IP Filtering 102 | 103 | Private IPs (RFC1918) can be excluded from resolved FQDN results using the `blockPrivateIPs` setting. This helps enforce 104 | policies that restrict traffic to public endpoints only. 105 | 106 | - The default is `false` (private IPs are allowed). 107 | 108 | - Set `spec.blockPrivateIPs: true` to apply filtering to all egress rules by default. 109 | 110 | - You can override this behavior on a per-rule basis using `egress[].blockPrivateIPs`. 111 | If set, this value takes precedence over the global spec.blockPrivateIPs. 112 | 113 | ### IP Retention on Failure 114 | 115 | When FQDN resolution fails, previously resolved IPs are not immediately removed. Instead, they are retained and continue 116 | to be used in the underlying NetworkPolicy until the `retryTimeoutSeconds` window expires. This ensures that temporary 117 | DNS issues do not disrupt network access. 118 | 119 | #### ✅ IPs are retained for: 120 | 121 | - `TIMEOUT`: DNS server did not respond in time 122 | 123 | - `TEMPORARY`: A transient network error occurred 124 | 125 | - `UNKNOWN`: The controller could not determine the exact error 126 | 127 | - `OTHER_ERROR`: Unspecified failure during resolution 128 | 129 | #### ❌ IPs are immediately dropped for: 130 | 131 | - `INVALID_DOMAIN`: FQDN format is invalid and cannot be resolved 132 | 133 | - `NXDOMAIN` (aka DOMAIN_NOT_FOUND): The domain does not exist (permanent failure) 134 | 135 | After the retention period (`retryTimeoutSeconds`), the FQDN will be removed from the active policy if resolution has 136 | not succeeded again. 137 | 138 | If you do **not** wish to retain IP addresses for potentially transient resolution failures, you can set 139 | `retryTimeoutSeconds` to zero. 140 | 141 | ### Status and Observability 142 | 143 | Each FQDN-based NetworkPolicy CR includes detailed status information to help you monitor behavior and troubleshoot 144 | DNS or policy issues. 145 | 146 | #### Key fields in .status: 147 | 148 | - `conditions[]`\ 149 | Standard Kubernetes conditions, including: 150 | 151 | - `Ready`: Whether the controller successfully applied the resolved IPs. 152 | 153 | - `Resolve`: Indicates whether the most recent DNS resolution attempt succeeded. Use this to quickly determine the 154 | health and reconciliation state of the policy. This is an aggregated summary of all FQDN lookups, surfacing the 155 | highest measurable error. 156 | 157 | - `fqdns[]`\ 158 | Per-FQDN resolution status: 159 | 160 | - `fqdn`: The domain name being resolved. 161 | 162 | - `lastSuccessfulTime`: Timestamp of the most recent successful resolution. 163 | 164 | - `resolveReason`: Result of the most recent DNS attempt (SUCCESS, TIMEOUT, NXDOMAIN, etc.). 165 | 166 | - `resolveMessage`: Human-readable message describing the result. 167 | 168 | - `addresses[]`: The current list of resolved IPs for that FQDN. 169 | 170 | Useful for debugging why traffic is or isn’t allowed, and for verifying DNS behavior. 171 | 172 | - `appliedAddressCount` 173 | Number of unique IPs currently applied to the underlying NetworkPolicy (after filtering, retries, and deduplication). 174 | 175 | - `totalAddressesCount` 176 | Total number of all resolved IPs, including ones filtered out due to blockPrivateIPs or other constraints (after deduplication). 177 | 178 | - `latestLookupTime` 179 | The last time this policy's FQDNs were resolved. Useful for tracking how fresh the IPs are. 180 | 181 | Some of these fields are also surfaced in `kubectl get` for quick inspection: 182 | 183 | ```bash 184 | kubectl get fqdn networkpolicy-sample 185 | NAME READY RESOLVED RESOLVED IPs APPLIED IPs LAST LOOKUP AGE 186 | networkpolicy-sample True False 5 3 31s 2m 187 | ``` 188 | 189 | ### Custom Resource Example 190 | 191 | ```yaml 192 | apiVersion: fqdn.konsole.is/v1alpha1 193 | kind: NetworkPolicy 194 | metadata: 195 | name: networkpolicy-sample 196 | namespace: default 197 | spec: 198 | podSelector: 199 | matchLabels: 200 | app: my-app 201 | enabledNetworkType: ipv4 202 | ttlSeconds: 60 203 | resolveTimeoutSeconds: 3 204 | retryTimeoutSeconds: 3600 205 | blockPrivateIPs: false 206 | egress: 207 | - toFQDNS: 208 | - api.example.com 209 | - github.com 210 | ports: 211 | - protocol: TCP 212 | port: 443 213 | - toFQDNS: 214 | - telemetry.example.net 215 | ports: 216 | - protocol: TCP 217 | port: 443 218 | blockPrivateIPs: true 219 | ``` 220 | 221 | ## 🚀 Installation 222 | 223 | ### Helm installation 224 | 225 | If you wish to manage the CRDs outside the helm chart you can install them from the release manifests. You must 226 | explicitly disable the crd installation in the helm chart if you prefer this, using the flag `--set crd.enable=false`. 227 | 228 | ```bash 229 | curl -sL https://github.com/konsole-is/fqdn-controller/releases/download//crds.yaml | kubectl apply -f - 230 | ``` 231 | 232 | Chart installation 233 | 234 | ```bash 235 | helm repo add fqdn-controller https://konsole-is.github.io/fqdn-controller/charts 236 | helm install fqdn-controller fqdn-controller/fqdn-controller --version 237 | ``` 238 | 239 | ### Kubectl installation 240 | 241 | ```bash 242 | curl -sL https://github.com/konsole-is/fqdn-controller/releases/download//install.yaml | kubectl apply -f - 243 | ``` 244 | 245 | Note: Will contain only 1 replica unless modified. 246 | 247 | ## 🧪 Development 248 | 249 | The repository is configured for [ASDF](https://asdf-vm.com/) to install project dependencies. 250 | 251 | Run `make help` for information on development commands. 252 | 253 | ## 📦 Releases 254 | 255 | - Helm Chart: [Artifact Hub]() 256 | - CRD Bundle: [GitHub Releases](https://github.com/konsole-is/fqdn-controller/releases) 257 | - Release manifest: [GitHub Releases](https://github.com/konsole-is/fqdn-controller/releases) 258 | 259 | ## 🤝 Contributing 260 | 261 | Contributions, bug reports, and feedback are welcome! 262 | Please open issues or pull requests as needed. 263 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------