├── hack └── boilerplate.go.txt ├── helm ├── kubernetes-operator │ ├── templates │ │ ├── NOTES.txt │ │ ├── secret.yaml │ │ ├── serviceaccount.yaml │ │ ├── nbpolicies.yaml │ │ ├── service.yaml │ │ ├── pre-delete.yaml │ │ ├── nbroutingpeers.yaml │ │ ├── kubernetes-nbresource.yaml │ │ ├── _helpers.tpl │ │ ├── rbac.yaml │ │ ├── webhook.yaml │ │ └── deployment.yaml │ ├── Chart.yaml │ ├── .helmignore │ ├── crds │ │ ├── netbird.io_nbgroups.yaml │ │ ├── netbird.io_nbpolicies.yaml │ │ └── netbird.io_nbresources.yaml │ └── values.yaml └── netbird-operator-config │ ├── Chart.yaml │ ├── .helmignore │ ├── templates │ ├── serviceaccount.yaml │ ├── nbpolicies.yaml │ ├── rbac.yaml │ ├── _helpers.tpl │ ├── nbroutingpeers.yaml │ └── kubernetes-nbresource.yaml │ └── values.yaml ├── .dockerignore ├── internal ├── util │ ├── ptr.go │ └── slices.go ├── webhook │ └── v1 │ │ ├── nbgroup_webhook.go │ │ ├── pod_webhook_test.go │ │ ├── pod_webhook.go │ │ ├── nbsetupkey_webhook.go │ │ ├── nbgroup_webhook_test.go │ │ ├── webhook_suite_test.go │ │ └── nbsetupkey_webhook_test.go └── controller │ ├── suite_test.go │ ├── nbsetupkey_controller.go │ ├── nbsetupkey_controller_test.go │ └── nbgroup_controller.go ├── examples ├── ingress │ ├── values-netbird-operator-config.yaml │ ├── values-kubernetes-operator.yaml │ └── exposed-nginx.yaml └── setup-keys │ └── example.yaml ├── Dockerfile.release ├── Dockerfile.kubectl ├── .github └── workflows │ ├── test.yml │ ├── lint.yml │ ├── test-e2e.yml │ ├── helm.yml │ ├── docker.yml │ ├── kubectl-docker.yml │ └── test-chart.yml ├── .gitignore ├── .golangci.yml ├── api └── v1 │ ├── groupversion_info.go │ ├── nbgroup_types.go │ ├── nbroutingpeer_types.go │ ├── nbpolicy_types.go │ ├── nbresource_types.go │ └── nbsetupkey_types.go ├── Dockerfile ├── LICENSE ├── PROJECT ├── go.mod ├── test ├── e2e │ └── e2e_suite_test.go └── utils │ └── utils.go ├── README.md ├── crds ├── netbird.io_nbgroups.yaml ├── netbird.io_nbsetupkeys.yaml ├── netbird.io_nbpolicies.yaml └── netbird.io_nbresources.yaml ├── CONTRIBUTOR_LICENSE_AGREEMENT.md └── Makefile /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | -------------------------------------------------------------------------------- /internal/util/ptr.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // Ptr return pointer to any value for API purposes 4 | func Ptr[T any, PT *T](x T) PT { 5 | return &x 6 | } 7 | -------------------------------------------------------------------------------- /examples/ingress/values-netbird-operator-config.yaml: -------------------------------------------------------------------------------- 1 | router: 2 | enabled: true 3 | policies: 4 | default: 5 | name: Kubernetes Default Policy 6 | sourceGroups: 7 | - All 8 | -------------------------------------------------------------------------------- /Dockerfile.release: -------------------------------------------------------------------------------- 1 | # This dockerfile is used for goreleaser 2 | 3 | FROM gcr.io/distroless/static:nonroot 4 | WORKDIR / 5 | COPY manager . 6 | USER 65532:65532 7 | 8 | ENTRYPOINT ["/manager"] 9 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: kubernetes-operator 3 | description: NetBird Kubernetes Operator 4 | type: application 5 | version: 0.2.0 6 | appVersion: "0.2.0" 7 | -------------------------------------------------------------------------------- /helm/netbird-operator-config/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: netbird-operator-config 3 | description: A Helm chart for Kubernetes 4 | type: application 5 | version: 0.1.0 6 | appVersion: "0.0.0" 7 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.netbirdAPI.key }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ include "kubernetes-operator.fullname" . }} 6 | labels: 7 | app.kubernetes.io/component: operator 8 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 9 | stringData: 10 | NB_API_KEY: {{ .Values.netbirdAPI.key }} 11 | {{- end }} -------------------------------------------------------------------------------- /Dockerfile.kubectl: -------------------------------------------------------------------------------- 1 | FROM alpine:3 AS builder 2 | 3 | RUN apk update && apk add curl bash 4 | RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \ 5 | chmod +x kubectl && \ 6 | mv kubectl /usr/local/bin/kubectl 7 | 8 | FROM alpine:3 AS final 9 | 10 | COPY --from=builder /usr/local/bin/kubectl /usr/local/bin/kubectl 11 | -------------------------------------------------------------------------------- /examples/ingress/values-kubernetes-operator.yaml: -------------------------------------------------------------------------------- 1 | #operator: 2 | # image: 3 | # tag: "0.1.0" 4 | ingress: 5 | enabled: true 6 | 7 | netbirdAPI: 8 | # Replace with valid NetBird Service Account token (PAT) 9 | # https://docs.netbird.io/how-to/access-netbird-public-api#creating-an-access-token 10 | #key: "nbp_m0LM9yZvDUzF8fpY20iChDOTxJgKFM3DIqmZ" 11 | # Use keyFromSecret instead of plain text secret 12 | keyFromSecret: "netbird-mgmt-api-key" -------------------------------------------------------------------------------- /helm/kubernetes-operator/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/netbird-operator-config/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | name: Run on Ubuntu 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Clone the code 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version-file: go.mod 19 | 20 | - name: Running Tests 21 | run: | 22 | go mod tidy 23 | make test 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | name: Run on Ubuntu 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Clone the code 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version-file: go.mod 19 | 20 | - name: Run linter 21 | uses: golangci/golangci-lint-action@v6 22 | with: 23 | version: v1.63.4 24 | -------------------------------------------------------------------------------- /helm/netbird-operator-config/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.kubernetesAPI.enabled .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "netbird-operator-config.serviceAccountName" . }} 6 | labels: 7 | {{- include "netbird-operator-config.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: true 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.operator.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "kubernetes-operator.serviceAccountName" . }} 6 | labels: 7 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 8 | {{- with .Values.operator.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.operator.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/nbpolicies.yaml: -------------------------------------------------------------------------------- 1 | {{- range $k, $v := $.Values.ingress.policies }} 2 | --- 3 | apiVersion: netbird.io/v1 4 | kind: NBPolicy 5 | metadata: 6 | finalizers: 7 | - netbird.io/cleanup 8 | labels: 9 | app.kubernetes.io/component: operator 10 | {{- include "kubernetes-operator.labels" $ | nindent 4 }} 11 | annotations: 12 | helm.sh/resource-policy: keep 13 | name: {{ $k }} 14 | spec: 15 | name: {{ $v.name }} 16 | sourceGroups: 17 | {{ toYaml $v.sourceGroups | nindent 4}} 18 | {{- if $v.description }} 19 | description: {{ $v.description }} 20 | {{- end }} 21 | {{- if $v.protocols }} 22 | protocols: {{ $v.protocols }} 23 | {{- end }} 24 | {{- if $v.ports }} 25 | ports: {{ $v.ports }} 26 | {{- end }} 27 | {{- if hasKey $v "bidirectional" }} 28 | bidirectional: {{ $v.bidirectional }} 29 | {{- end }} 30 | {{- end }} -------------------------------------------------------------------------------- /helm/netbird-operator-config/templates/nbpolicies.yaml: -------------------------------------------------------------------------------- 1 | {{- range $k, $v := $.Values.policies }} 2 | --- 3 | apiVersion: netbird.io/v1 4 | kind: NBPolicy 5 | metadata: 6 | annotations: 7 | helm.sh/resource-policy: keep 8 | finalizers: 9 | - netbird.io/cleanup 10 | labels: 11 | app.kubernetes.io/component: operator 12 | {{- include "netbird-operator-config.labels" $ | nindent 4 }} 13 | name: {{ $k }} 14 | spec: 15 | name: {{ $v.name }} 16 | sourceGroups: 17 | {{ toYaml $v.sourceGroups | nindent 4}} 18 | {{- if $v.description }} 19 | description: {{ $v.description }} 20 | {{- end }} 21 | {{- if $v.protocols }} 22 | protocols: {{ $v.protocols }} 23 | {{- end }} 24 | {{- if $v.ports }} 25 | ports: {{ $v.ports }} 26 | {{- end }} 27 | {{- if hasKey $v "bidirectional" }} 28 | bidirectional: {{ $v.bidirectional }} 29 | {{- end }} 30 | {{- end }} -------------------------------------------------------------------------------- /.github/workflows/test-e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test-e2e: 9 | name: Run on Ubuntu 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Clone the code 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version-file: go.mod 19 | 20 | - name: Install the latest version of kind 21 | run: | 22 | curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 23 | chmod +x ./kind 24 | sudo mv ./kind /usr/local/bin/kind 25 | 26 | - name: Verify kind installation 27 | run: kind version 28 | 29 | - name: Create kind cluster 30 | run: kind create cluster 31 | 32 | - name: Running Test e2e 33 | run: | 34 | go mod tidy 35 | make test-e2e 36 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | allow-parallel-runners: true 4 | 5 | issues: 6 | # don't skip warning about doc comments 7 | # don't exclude the default set of lint 8 | exclude-use-default: false 9 | # restore some of the defaults 10 | # (fill in the rest as needed) 11 | exclude-rules: 12 | - path: "api/*" 13 | linters: 14 | - lll 15 | - path: "internal/*" 16 | linters: 17 | - dupl 18 | - lll 19 | linters: 20 | disable-all: true 21 | enable: 22 | - dupl 23 | - errcheck 24 | - copyloopvar 25 | - ginkgolinter 26 | - goconst 27 | - gocyclo 28 | - gofmt 29 | - goimports 30 | - gosimple 31 | - govet 32 | - ineffassign 33 | - lll 34 | - misspell 35 | - nakedret 36 | - prealloc 37 | - revive 38 | - staticcheck 39 | - typecheck 40 | - unconvert 41 | - unparam 42 | - unused 43 | 44 | linters-settings: 45 | revive: 46 | rules: 47 | - name: comment-spacings 48 | -------------------------------------------------------------------------------- /examples/ingress/exposed-nginx.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: nginx 6 | name: nginx 7 | namespace: default 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: nginx 13 | strategy: 14 | rollingUpdate: 15 | maxSurge: 25% 16 | maxUnavailable: 25% 17 | type: RollingUpdate 18 | template: 19 | metadata: 20 | labels: 21 | app: nginx 22 | spec: 23 | containers: 24 | - image: nginx 25 | imagePullPolicy: Always 26 | name: nginx 27 | --- 28 | apiVersion: v1 29 | kind: Service 30 | metadata: 31 | annotations: 32 | netbird.io/expose: "true" 33 | netbird.io/policy: default 34 | netbird.io/resource-name: nginx 35 | netbird.io/groups: nginx-k8s-gke 36 | labels: 37 | app: nginx 38 | name: nginx 39 | namespace: default 40 | spec: 41 | ports: 42 | - name: http 43 | port: 80 44 | protocol: TCP 45 | targetPort: 80 46 | selector: 47 | app: nginx 48 | type: ClusterIP 49 | -------------------------------------------------------------------------------- /examples/setup-keys/example.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: test 5 | namespace: default 6 | stringData: 7 | SETUP_KEY: 50445ABC-8901-4050-8047-0A390658A79B # Replace with valid setup key 8 | --- 9 | apiVersion: netbird.io/v1 10 | kind: NBSetupKey 11 | metadata: 12 | name: test 13 | namespace: default 14 | spec: 15 | secretKeyRef: 16 | name: test 17 | key: SETUP_KEY 18 | --- 19 | apiVersion: apps/v1 20 | kind: Deployment 21 | metadata: 22 | labels: 23 | app: test 24 | name: test 25 | namespace: default 26 | spec: 27 | replicas: 1 28 | selector: 29 | matchLabels: 30 | app: test 31 | strategy: 32 | rollingUpdate: 33 | maxSurge: 25% 34 | maxUnavailable: 25% 35 | type: RollingUpdate 36 | template: 37 | metadata: 38 | labels: 39 | app: test 40 | annotations: 41 | netbird.io/setup-key: test 42 | spec: 43 | containers: 44 | - image: ubuntu 45 | imagePullPolicy: Always 46 | name: ubuntu 47 | command: 48 | - sleep 49 | - inf 50 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.operator.metrics.enabled }} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ include "kubernetes-operator.fullname" . }}-metrics 6 | labels: 7 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 8 | spec: 9 | type: {{ .Values.operator.metrics.type }} 10 | ports: 11 | - name: http 12 | port: {{ .Values.operator.metrics.port }} 13 | protocol: TCP 14 | targetPort: {{ .Values.operator.metrics.port }} 15 | selector: 16 | {{- include "kubernetes-operator.selectorLabels" . | nindent 4 }} 17 | {{- end }} 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: {{ include "kubernetes-operator.webhookService" . }} 23 | labels: 24 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 25 | spec: 26 | type: {{ .Values.webhook.service.type }} 27 | ports: 28 | - name: https 29 | port: {{ .Values.webhook.service.port }} 30 | protocol: TCP 31 | targetPort: {{ .Values.webhook.service.targetPort }} 32 | selector: 33 | {{- include "kubernetes-operator.selectorLabels" . | nindent 4 }} 34 | -------------------------------------------------------------------------------- /helm/netbird-operator-config/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: {{ include "netbird-operator-config.fullname" . }} 5 | labels: 6 | {{- include "netbird-operator-config.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - netbird.io 10 | resources: 11 | - nbresources 12 | verbs: 13 | - patch 14 | - update 15 | - list 16 | - watch 17 | - create 18 | - delete 19 | - apiGroups: 20 | - netbird.io 21 | resources: 22 | - nbroutingpeers 23 | verbs: 24 | - get 25 | - apiGroups: 26 | - netbird.io 27 | resources: 28 | - nbresources/finalizers 29 | verbs: 30 | - update 31 | --- 32 | apiVersion: rbac.authorization.k8s.io/v1 33 | kind: ClusterRoleBinding 34 | metadata: 35 | name: {{ include "netbird-operator-config.fullname" . }} 36 | labels: 37 | {{- include "netbird-operator-config.labels" . | nindent 4 }} 38 | roleRef: 39 | apiGroup: rbac.authorization.k8s.io 40 | kind: ClusterRole 41 | name: {{ include "netbird-operator-config.fullname" . }} 42 | subjects: 43 | - kind: ServiceAccount 44 | name: {{ include "netbird-operator-config.serviceAccountName" . }} 45 | namespace: {{ .Release.Namespace }} -------------------------------------------------------------------------------- /internal/util/slices.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | // Contains return if y is in slice x 6 | func Contains[T comparable](x []T, y T) bool { 7 | for _, v := range x { 8 | if v == y { 9 | return true 10 | } 11 | } 12 | return false 13 | } 14 | 15 | // Without return all of x in same order without y 16 | func Without[T comparable](x []T, y T) []T { 17 | var ret []T 18 | for _, v := range x { 19 | if v != y { 20 | ret = append(ret, v) 21 | } 22 | } 23 | return ret 24 | } 25 | 26 | // Equivalent return true if x and y are equal when sorted 27 | func Equivalent[T comparable](x, y []T) bool { 28 | if len(x) != len(y) { 29 | return false 30 | } 31 | 32 | mp := make(map[T]interface{}) 33 | for _, v := range x { 34 | mp[v] = nil 35 | } 36 | for _, v := range y { 37 | if _, ok := mp[v]; !ok { 38 | return false 39 | } 40 | } 41 | 42 | return true 43 | } 44 | 45 | // SplitTrim split string and trim whitespace 46 | func SplitTrim(str, sep string) []string { 47 | if len(str) == 0 { 48 | return nil 49 | } 50 | sp := strings.Split(str, sep) 51 | ret := make([]string, 0, len(sp)) 52 | for _, v := range sp { 53 | ret = append(ret, strings.TrimSpace(v)) 54 | } 55 | return ret 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/helm.yml: -------------------------------------------------------------------------------- 1 | name: Release Helm Chart 2 | 3 | on: 4 | push: 5 | paths: 6 | # update this file to trigger helm chart release 7 | - 'helm/kubernetes-operator/Chart.yaml' 8 | - 'helm/netbird-operator-config/Chart.yaml' 9 | branches: 10 | - main 11 | 12 | jobs: 13 | chart-release: 14 | runs-on: ubuntu-latest 15 | env: 16 | CHART_BASE_DIR: helm 17 | GH_PAGES_BRANCH: gh-pages 18 | permissions: 19 | contents: write 20 | pages: write 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3.1.0 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Configure Git 28 | run: | 29 | git config user.name "$GITHUB_ACTOR" 30 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 31 | 32 | - name: Install Helm 33 | uses: azure/setup-helm@v3.4 34 | with: 35 | version: v3.4.2 36 | 37 | - name: Run chart-releaser 38 | uses: helm/chart-releaser-action@v1.4.1 39 | with: 40 | charts_dir: helm 41 | env: 42 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 43 | CR_RELEASE_NAME_TEMPLATE: "helm-{{ .Name }}-v{{ .Version }}" 44 | -------------------------------------------------------------------------------- /api/v1/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 v1 contains API Schema definitions for the v1 API group. 18 | // +kubebuilder:object:generate=true 19 | // +groupName=netbird.io 20 | package v1 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: "netbird.io", Version: "v1"} 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 | -------------------------------------------------------------------------------- /helm/netbird-operator-config/values.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: "kubernetes" 3 | dns: "svc.cluster.local" 4 | 5 | # Create router per namespace, useful for strict networking requirements 6 | namespacedNetworks: false 7 | 8 | router: 9 | # Deploy routing peer(s) 10 | enabled: false 11 | # replicas: 3 12 | # resources: 13 | # requests: 14 | # cpu: 100m 15 | # memory: 100Mi 16 | # limits: 17 | # cpu: 100m 18 | # memory: 100Mi 19 | # labels: {} 20 | # annotations: {} 21 | # nodeSelector: {} 22 | # tolerations: [] 23 | # Only needed if namespacedNetworks is set to true 24 | namespaces: {} 25 | # default: 26 | # replicas: 3 27 | # resources: 28 | # requests: 29 | # cpu: 100m 30 | # memory: 100Mi 31 | # limits: 32 | # cpu: 100m 33 | # memory: 100Mi 34 | # labels: {} 35 | # annotations: {} 36 | # nodeSelector: {} 37 | # tolerations: [] 38 | # NetBird Policies for use with exposed services 39 | policies: {} 40 | # default: 41 | # name: Kubernetes Default Policy 42 | # sourceGroups: 43 | # - All 44 | 45 | kubernetesAPI: 46 | enabled: false 47 | groups: [] 48 | # - group1 49 | # - group2 50 | policies: [] 51 | # - default 52 | # resourceName: "my-cluster-kubernetes" 53 | 54 | serviceAccount: 55 | create: true 56 | name: "" 57 | annotations: {} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This dockerfile is used for tests and local builds 2 | 3 | # Build the manager binary 4 | FROM docker.io/golang:1.23 AS builder 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | 8 | WORKDIR /workspace 9 | # Copy the Go Modules manifests 10 | COPY go.mod go.mod 11 | COPY go.sum go.sum 12 | # cache deps before building and copying source so that we don't need to re-download as much 13 | # and so that source changes don't invalidate our downloaded layer 14 | RUN go mod download 15 | 16 | # Copy the go source 17 | COPY cmd/main.go cmd/main.go 18 | COPY api/ api/ 19 | COPY internal/ internal/ 20 | 21 | # Build 22 | # the GOARCH has not a default value to allow the binary be built according to the host where the command 23 | # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO 24 | # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, 25 | # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. 26 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -ldflags="-w -s" -a -o manager cmd/main.go 27 | 28 | # Use distroless as minimal base image to package the manager binary 29 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 30 | FROM gcr.io/distroless/static:nonroot 31 | WORKDIR / 32 | COPY --from=builder /workspace/manager . 33 | USER 65532:65532 34 | 35 | ENTRYPOINT ["/manager"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, netbirdio 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /api/v1/nbgroup_types.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/netbirdio/kubernetes-operator/internal/util" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // NBGroupSpec defines the desired state of NBGroup. 9 | type NBGroupSpec struct { 10 | // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" 11 | // +kubebuilder:validation:MinLength=1 12 | Name string `json:"name"` 13 | } 14 | 15 | // NBGroupStatus defines the observed state of NBGroup. 16 | type NBGroupStatus struct { 17 | // +optional 18 | GroupID *string `json:"groupID"` 19 | // +optional 20 | Conditions []NBCondition `json:"conditions,omitempty"` 21 | } 22 | 23 | // Equal returns if NBGroupStatus is equal to this one 24 | func (a NBGroupStatus) Equal(b NBGroupStatus) bool { 25 | return (a.GroupID == b.GroupID || (a.GroupID != nil && b.GroupID != nil && *a.GroupID == *b.GroupID)) && util.Equivalent(a.Conditions, b.Conditions) 26 | } 27 | 28 | // +kubebuilder:object:root=true 29 | // +kubebuilder:subresource:status 30 | 31 | // NBGroup is the Schema for the nbgroups API. 32 | type NBGroup struct { 33 | metav1.TypeMeta `json:",inline"` 34 | metav1.ObjectMeta `json:"metadata,omitempty"` 35 | 36 | Spec NBGroupSpec `json:"spec,omitempty"` 37 | Status NBGroupStatus `json:"status,omitempty"` 38 | } 39 | 40 | // +kubebuilder:object:root=true 41 | 42 | // NBGroupList contains a list of NBGroup. 43 | type NBGroupList struct { 44 | metav1.TypeMeta `json:",inline"` 45 | metav1.ListMeta `json:"metadata,omitempty"` 46 | Items []NBGroup `json:"items"` 47 | } 48 | 49 | func init() { 50 | SchemeBuilder.Register(&NBGroup{}, &NBGroupList{}) 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | branches: 8 | - main 9 | pull_request: 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | # This is used to complete the identity challenge 18 | # with sigstore/fulcio when running outside of PRs. 19 | id-token: write 20 | 21 | steps: 22 | - name: Docker meta 23 | id: meta 24 | uses: docker/metadata-action@v5 25 | with: 26 | # list of Docker images to use as base name for tags 27 | images: | 28 | netbirdio/kubernetes-operator 29 | # generate Docker tags based on the following events/attributes 30 | tags: | 31 | type=ref,event=pr 32 | type=ref,event=branch 33 | type=semver,pattern={{version}} 34 | 35 | - name: Login to Docker Hub 36 | if: github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKER_USER }} 40 | password: ${{ secrets.DOCKER_TOKEN }} 41 | 42 | - name: Set up QEMU 43 | uses: docker/setup-qemu-action@v3 44 | 45 | - name: Set up Docker Buildx 46 | uses: docker/setup-buildx-action@v3 47 | 48 | - name: Build and push 49 | uses: docker/build-push-action@v6 50 | with: 51 | platforms: linux/amd64,linux/arm64 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: | 55 | "org.opencontainers.image.created={{.Date}}" 56 | "org.opencontainers.image.title={{.ProjectName}}" 57 | "org.opencontainers.image.version={{.Version}}" 58 | "org.opencontainers.image.revision={{.FullCommit}}" 59 | "org.opencontainers.image.version={{.Version}}" 60 | "maintainer=dev@netbird.io" 61 | -------------------------------------------------------------------------------- /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 | domain: netbird.io 6 | layout: 7 | - helm.kubebuilder.io/v1-alpha 8 | - go.kubebuilder.io/v4 9 | plugins: 10 | helm.kubebuilder.io/v1-alpha: {} 11 | projectName: kubernetes-operator 12 | repo: github.com/netbirdio/kubernetes-operator 13 | resources: 14 | - api: 15 | crdVersion: v1 16 | namespaced: true 17 | controller: true 18 | domain: netbird.io 19 | kind: NBSetupKey 20 | path: github.com/netbirdio/kubernetes-operator/api/v1 21 | version: v1 22 | webhooks: 23 | validation: true 24 | webhookVersion: v1 25 | - external: true 26 | kind: Pod 27 | path: k8s.io/api/core/v1 28 | version: v1 29 | webhooks: 30 | defaulting: true 31 | webhookVersion: v1 32 | - api: 33 | crdVersion: v1 34 | namespaced: true 35 | controller: true 36 | domain: netbird.io 37 | kind: NBRoutingPeer 38 | path: github.com/netbirdio/kubernetes-operator/api/v1 39 | version: v1 40 | webhooks: 41 | validation: true 42 | webhookVersion: v1 43 | - controller: true 44 | external: true 45 | kind: Service 46 | path: k8s.io/api/core/v1 47 | version: v1 48 | - api: 49 | crdVersion: v1 50 | namespaced: true 51 | controller: true 52 | domain: netbird.io 53 | kind: NBResource 54 | path: github.com/netbirdio/kubernetes-operator/api/v1 55 | version: v1 56 | webhooks: 57 | validation: true 58 | webhookVersion: v1 59 | - api: 60 | crdVersion: v1 61 | namespaced: true 62 | controller: true 63 | domain: netbird.io 64 | kind: NBGroup 65 | path: github.com/netbirdio/kubernetes-operator/api/v1 66 | version: v1 67 | webhooks: 68 | validation: true 69 | webhookVersion: v1 70 | - api: 71 | crdVersion: v1 72 | namespaced: true 73 | controller: true 74 | domain: netbird.io 75 | kind: NBPolicy 76 | path: github.com/netbirdio/kubernetes-operator/api/v1 77 | version: v1 78 | version: "3" 79 | -------------------------------------------------------------------------------- /.github/workflows/kubectl-docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'Dockerfile.kubectl' 7 | tags: 8 | - "v*" 9 | branches: 10 | - main 11 | pull_request: 12 | 13 | jobs: 14 | kubectl-docker: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | packages: write 19 | # This is used to complete the identity challenge 20 | # with sigstore/fulcio when running outside of PRs. 21 | id-token: write 22 | 23 | steps: 24 | - name: Docker meta 25 | id: meta 26 | uses: docker/metadata-action@v5 27 | with: 28 | # list of Docker images to use as base name for tags 29 | images: | 30 | netbirdio/kubectl 31 | # generate Docker tags based on the following events/attributes 32 | tags: | 33 | type=ref,event=pr 34 | type=ref,event=branch 35 | type=semver,pattern={{version}} 36 | 37 | - name: Login to Docker Hub 38 | if: github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKER_USER }} 42 | password: ${{ secrets.DOCKER_TOKEN }} 43 | 44 | - name: Set up QEMU 45 | uses: docker/setup-qemu-action@v3 46 | 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@v3 49 | 50 | - name: Build and push 51 | uses: docker/build-push-action@v6 52 | with: 53 | platforms: linux/amd64,linux/arm64 54 | push: true 55 | tags: ${{ steps.meta.outputs.tags }} 56 | file: "Dockerfile.kubectl" 57 | labels: | 58 | "org.opencontainers.image.created={{.Date}}" 59 | "org.opencontainers.image.title={{.ProjectName}}" 60 | "org.opencontainers.image.version={{.Version}}" 61 | "org.opencontainers.image.revision={{.FullCommit}}" 62 | "org.opencontainers.image.version={{.Version}}" 63 | "maintainer=dev@netbird.io" 64 | -------------------------------------------------------------------------------- /helm/netbird-operator-config/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Create a default fully qualified app name. 3 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 4 | If release name contains chart name it will be used as a full name. 5 | */}} 6 | {{- define "netbird-operator-config.fullname" -}} 7 | {{- if .Values.fullnameOverride }} 8 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 9 | {{- else }} 10 | {{- $name := default .Chart.Name .Values.nameOverride }} 11 | {{- if contains $name .Release.Name }} 12 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 13 | {{- else }} 14 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 15 | {{- end }} 16 | {{- end }} 17 | {{- end }} 18 | 19 | {{/* 20 | Expand the name of the chart. 21 | */}} 22 | {{- define "netbird-operator-config.name" -}} 23 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "netbird-operator-config.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "netbird-operator-config.labels" -}} 37 | helm.sh/chart: {{ include "netbird-operator-config.chart" . }} 38 | {{ include "netbird-operator-config.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "netbird-operator-config.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "netbird-operator-config.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "netbird-operator-config.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "netbird-operator-config.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/pre-delete.yaml: -------------------------------------------------------------------------------- 1 | {{/*apiVersion: batch/v1*/}} 2 | {{/*kind: Job*/}} 3 | {{/*metadata:*/}} 4 | {{/* name: {{ include "kubernetes-operator.fullname" . }}-delete-router-deployments*/}} 5 | {{/* labels:*/}} 6 | {{/* app.kubernetes.io/component: operator*/}} 7 | {{/* {{- include "kubernetes-operator.labels" . | nindent 4 }}*/}} 8 | {{/* annotations:*/}} 9 | {{/* helm.sh/hook: pre-delete*/}} 10 | {{/* helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded*/}} 11 | {{/*spec:*/}} 12 | {{/* backoffLimit: 3*/}} 13 | {{/* template:*/}} 14 | {{/* metadata:*/}} 15 | {{/* name: {{ include "kubernetes-operator.fullname" . }}*/}} 16 | {{/* labels:*/}} 17 | {{/* app.kubernetes.io/component: operator*/}} 18 | {{/* {{- include "kubernetes-operator.labels" . | nindent 8 }}*/}} 19 | {{/* {{- with .Values.operator.podLabels }}*/}} 20 | {{/* {{- toYaml . | nindent 8 }}*/}} 21 | {{/* {{- end }}*/}} 22 | {{/* spec:*/}} 23 | {{/* containers:*/}} 24 | {{/* - name: pre-delete*/}} 25 | {{/* image: "netbirdio/kubectl:latest"*/}} 26 | {{/* imagePullPolicy: {{ .Values.operator.image.pullPolicy }}*/}} 27 | {{/* command:*/}} 28 | {{/* - sh*/}} 29 | {{/* - -c*/}} 30 | {{/* args:*/}} 31 | {{/* - kubectl get NBRoutingPeer -A --no-headers -o custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name | while read "L"; do kubectl patch --type=json -p '[{"op":"replace","path":"/spec/disableDeployment","value":true}]' NBRoutingPeer -n $(echo "$L" | awk '{print $1}') $(echo "$L" | awk '{print $2}'); done*/}} 32 | {{/* - name: delete-wait*/}} 33 | {{/* image: "netbirdio/kubectl:latest"*/}} 34 | {{/* imagePullPolicy: {{ .Values.operator.image.pullPolicy }}*/}} 35 | {{/* command:*/}} 36 | {{/* - sh*/}} 37 | {{/* - -c*/}} 38 | {{/* args:*/}} 39 | {{/* - kubectl get NBRoutingPeer -A --no-headers -o custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name | while read "L"; do kubectl wait --for=delete deployment -n $(echo "$L" | awk '{print $1}') $(echo "$L" | awk '{print $2}'); done*/}} 40 | {{/* serviceAccountName: {{ include "kubernetes-operator.serviceAccountName" . }}*/}} 41 | {{/* restartPolicy: Never*/}} 42 | -------------------------------------------------------------------------------- /api/v1/nbroutingpeer_types.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/netbirdio/kubernetes-operator/internal/util" 5 | corev1 "k8s.io/api/core/v1" 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | ) 8 | 9 | // NBRoutingPeerSpec defines the desired state of NBRoutingPeer. 10 | type NBRoutingPeerSpec struct { 11 | // +optional 12 | Replicas *int32 `json:"replicas"` 13 | // +optional 14 | Resources corev1.ResourceRequirements `json:"resources"` 15 | // +optional 16 | Labels map[string]string `json:"labels"` 17 | // +optional 18 | Annotations map[string]string `json:"annotations"` 19 | // +optional 20 | NodeSelector map[string]string `json:"nodeSelector"` 21 | // +optional 22 | Tolerations []corev1.Toleration `json:"tolerations"` 23 | // +optional 24 | Volumes []corev1.Volume `json:"volumes"` 25 | // +optional 26 | VolumeMounts []corev1.VolumeMount `json:"volumeMounts"` 27 | } 28 | 29 | // NBRoutingPeerStatus defines the observed state of NBRoutingPeer. 30 | type NBRoutingPeerStatus struct { 31 | // +optional 32 | NetworkID *string `json:"networkID"` 33 | // +optional 34 | SetupKeyID *string `json:"setupKeyID"` 35 | // +optional 36 | RouterID *string `json:"routerID"` 37 | // +optional 38 | Conditions []NBCondition `json:"conditions,omitempty"` 39 | } 40 | 41 | // Equal returns if NBRoutingPeerStatus is equal to this one 42 | func (a NBRoutingPeerStatus) Equal(b NBRoutingPeerStatus) bool { 43 | return a.NetworkID == b.NetworkID && 44 | a.SetupKeyID == b.SetupKeyID && 45 | a.RouterID == b.RouterID && 46 | util.Equivalent(a.Conditions, b.Conditions) 47 | } 48 | 49 | // +kubebuilder:object:root=true 50 | // +kubebuilder:subresource:status 51 | 52 | // NBRoutingPeer is the Schema for the nbroutingpeers API. 53 | type NBRoutingPeer struct { 54 | metav1.TypeMeta `json:",inline"` 55 | metav1.ObjectMeta `json:"metadata,omitempty"` 56 | 57 | Spec NBRoutingPeerSpec `json:"spec,omitempty"` 58 | Status NBRoutingPeerStatus `json:"status,omitempty"` 59 | } 60 | 61 | // +kubebuilder:object:root=true 62 | 63 | // NBRoutingPeerList contains a list of NBRoutingPeer. 64 | type NBRoutingPeerList struct { 65 | metav1.TypeMeta `json:",inline"` 66 | metav1.ListMeta `json:"metadata,omitempty"` 67 | Items []NBRoutingPeer `json:"items"` 68 | } 69 | 70 | func init() { 71 | SchemeBuilder.Register(&NBRoutingPeer{}, &NBRoutingPeerList{}) 72 | } 73 | -------------------------------------------------------------------------------- /.github/workflows/test-chart.yml: -------------------------------------------------------------------------------- 1 | name: Test Chart 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test-e2e: 9 | name: Run on Ubuntu 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Clone the code 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version-file: go.mod 19 | 20 | - name: Install the latest version of kind 21 | run: | 22 | curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 23 | chmod +x ./kind 24 | sudo mv ./kind /usr/local/bin/kind 25 | 26 | - name: Verify kind installation 27 | run: kind version 28 | 29 | - name: Create kind cluster 30 | run: kind create cluster 31 | 32 | - name: Prepare operator 33 | run: | 34 | go mod tidy 35 | make docker-build IMG=netbirdio/kubernetes-operator:debug 36 | kind load docker-image netbirdio/kubernetes-operator:debug 37 | 38 | - name: Install Helm 39 | run: | 40 | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash 41 | 42 | - name: Verify Helm installation 43 | run: helm version 44 | 45 | - name: Lint Helm Chart 46 | run: | 47 | helm lint ./helm/kubernetes-operator 48 | 49 | - name: Install cert-manager via Helm 50 | run: | 51 | helm repo add jetstack https://charts.jetstack.io 52 | helm repo update 53 | helm install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace --set installCRDs=true 54 | 55 | - name: Wait for cert-manager to be ready 56 | run: | 57 | kubectl wait --namespace cert-manager --for=condition=available --timeout=300s deployment/cert-manager 58 | kubectl wait --namespace cert-manager --for=condition=available --timeout=300s deployment/cert-manager-cainjector 59 | kubectl wait --namespace cert-manager --for=condition=available --timeout=300s deployment/cert-manager-webhook 60 | 61 | - name: Install Helm chart for project 62 | run: | 63 | helm install test-chart --create-namespace --namespace netbird --set 'operator.image.tag=debug' ./helm/kubernetes-operator 64 | 65 | - name: Check Helm release status 66 | run: | 67 | helm status test-chart --namespace netbird 68 | -------------------------------------------------------------------------------- /helm/netbird-operator-config/templates/nbroutingpeers.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.router.enabled }} 2 | {{- if .Values.namespacedNetworks }} 3 | {{ $defaults := .Values.router }} 4 | {{ range $k, $v := .Values.router.namespaces }} 5 | apiVersion: netbird.io/v1 6 | kind: NBRoutingPeer 7 | metadata: 8 | finalizers: 9 | - netbird.io/cleanup 10 | labels: 11 | app.kubernetes.io/component: operator 12 | {{- include "netbird-operator-config.labels" $ | nindent 4 }} 13 | name: router 14 | namespace: {{ $k }} 15 | {{ $spec := merge $defaults $v }} 16 | {{- if or (or (or $spec.replicas $spec.resources) (or $spec.labels $spec.annotations)) (or $spec.nodeSelector $spec.tolerations) }} 17 | spec: 18 | {{- if $spec.replicas }} 19 | replicas: {{ $spec.replicas }} 20 | {{- end }} 21 | {{- if $spec.resources }} 22 | resources: 23 | {{- toYaml $spec.resources | nindent 4 }} 24 | {{- end }} 25 | {{- if $spec.labels }} 26 | labels: 27 | {{- toYaml $spec.labels | nindent 4 }} 28 | {{- end }} 29 | {{- if $spec.annotations }} 30 | annotations: 31 | {{- toYaml $spec.annotations | nindent 4 }} 32 | {{- end }} 33 | {{- if $spec.nodeSelector }} 34 | nodeSelector: 35 | {{- toYaml $spec.nodeSelector | nindent 4 }} 36 | {{- end }} 37 | {{- if $spec.tolerations }} 38 | tolerations: 39 | {{- toYaml $spec.tolerations | nindent 4 }} 40 | {{- end }} 41 | {{- end }} 42 | --- 43 | {{- end }} 44 | {{- else }} 45 | {{- with .Values.router }} 46 | apiVersion: netbird.io/v1 47 | kind: NBRoutingPeer 48 | metadata: 49 | finalizers: 50 | - netbird.io/cleanup 51 | labels: 52 | app.kubernetes.io/component: operator 53 | {{- include "netbird-operator-config.labels" $ | nindent 4 }} 54 | name: router 55 | {{- if or (or (or .replicas .resources) (or .labels .annotations)) (or .nodeSelector .tolerations) }} 56 | spec: 57 | {{- if .replicas }} 58 | replicas: {{ .replicas }} 59 | {{- end }} 60 | {{- if .resources }} 61 | resources: 62 | {{- toYaml .resources | nindent 4 }} 63 | {{- end }} 64 | {{- if .labels }} 65 | labels: 66 | {{- toYaml .labels | nindent 4 }} 67 | {{- end }} 68 | {{- if .annotations }} 69 | annotations: 70 | {{- toYaml .annotations | nindent 4 }} 71 | {{- end }} 72 | {{- if .nodeSelector }} 73 | nodeSelector: 74 | {{- toYaml .nodeSelector | nindent 4 }} 75 | {{- end }} 76 | {{- if .tolerations }} 77 | tolerations: 78 | {{- toYaml .tolerations | nindent 4 }} 79 | {{- end }} 80 | {{- end }} 81 | {{- end }} 82 | {{- end }} 83 | {{- end }} -------------------------------------------------------------------------------- /api/v1/nbpolicy_types.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/netbirdio/kubernetes-operator/internal/util" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // NBPolicySpec defines the desired state of NBPolicy. 9 | type NBPolicySpec struct { 10 | // Name Policy name 11 | // +kubebuilder:validation:MinLength=1 12 | Name string `json:"name"` 13 | // +optional 14 | Description string `json:"description,omitempty"` 15 | // +optional 16 | // +kubebuilder:validation:items:MinLength=1 17 | SourceGroups []string `json:"sourceGroups,omitempty"` 18 | // +optional 19 | // +kubebuilder:validation:items:MinLength=1 20 | DestinationGroups []string `json:"destinationGroups,omitempty"` 21 | // +optional 22 | // +kubebuilder:validation:items:Enum=tcp;udp 23 | Protocols []string `json:"protocols,omitempty"` 24 | // +optional 25 | // +kubebuilder:validation:items:Minimum=0 26 | // +kubebuilder:validation:items:Maximum=65535 27 | Ports []int32 `json:"ports,omitempty"` 28 | // +optional 29 | // +default:value=true 30 | Bidirectional bool `json:"bidirectional"` 31 | } 32 | 33 | // NBPolicyStatus defines the observed state of NBPolicy. 34 | type NBPolicyStatus struct { 35 | // +optional 36 | TCPPolicyID *string `json:"tcpPolicyID"` 37 | // +optional 38 | UDPPolicyID *string `json:"udpPolicyID"` 39 | // +optional 40 | LastUpdatedAt *metav1.Time `json:"lastUpdatedAt"` 41 | // +optional 42 | ManagedServiceList []string `json:"managedServiceList"` 43 | // +optional 44 | Conditions []NBCondition `json:"conditions,omitempty"` 45 | } 46 | 47 | // Equal returns if NBPolicyStatus is equal to this one 48 | func (a NBPolicyStatus) Equal(b NBPolicyStatus) bool { 49 | return a.TCPPolicyID == b.TCPPolicyID && 50 | a.UDPPolicyID == b.UDPPolicyID && 51 | a.LastUpdatedAt == b.LastUpdatedAt && 52 | util.Equivalent(a.ManagedServiceList, b.ManagedServiceList) && 53 | util.Equivalent(a.Conditions, b.Conditions) 54 | } 55 | 56 | // +kubebuilder:object:root=true 57 | // +kubebuilder:subresource:status 58 | // +kubebuilder:resource:scope=Cluster 59 | 60 | // NBPolicy is the Schema for the nbpolicies API. 61 | type NBPolicy struct { 62 | metav1.TypeMeta `json:",inline"` 63 | metav1.ObjectMeta `json:"metadata,omitempty"` 64 | 65 | Spec NBPolicySpec `json:"spec,omitempty"` 66 | Status NBPolicyStatus `json:"status,omitempty"` 67 | } 68 | 69 | // +kubebuilder:object:root=true 70 | 71 | // NBPolicyList contains a list of NBPolicy. 72 | type NBPolicyList struct { 73 | metav1.TypeMeta `json:",inline"` 74 | metav1.ListMeta `json:"metadata,omitempty"` 75 | Items []NBPolicy `json:"items"` 76 | } 77 | 78 | func init() { 79 | SchemeBuilder.Register(&NBPolicy{}, &NBPolicyList{}) 80 | } 81 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/nbroutingpeers.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.ingress.enabled .Values.ingress.router.enabled }} 2 | {{- if .Values.ingress.namespacedNetworks }} 3 | {{ $defaults := .Values.ingress.router }} 4 | {{ range $k, $v := .Values.ingress.router.namespaces }} 5 | apiVersion: netbird.io/v1 6 | kind: NBRoutingPeer 7 | metadata: 8 | finalizers: 9 | - netbird.io/cleanup 10 | labels: 11 | app.kubernetes.io/component: operator 12 | {{- include "kubernetes-operator.labels" $ | nindent 4 }} 13 | annotations: 14 | helm.sh/resource-policy: keep 15 | name: router 16 | namespace: {{ $k }} 17 | {{ $spec := merge $defaults $v }} 18 | {{- if or (or (or $spec.replicas $spec.resources) (or $spec.labels $spec.annotations)) (or $spec.nodeSelector $spec.tolerations) }} 19 | spec: 20 | {{- if $spec.replicas }} 21 | replicas: {{ $spec.replicas }} 22 | {{- end }} 23 | {{- if $spec.resources }} 24 | resources: 25 | {{- toYaml $spec.resources | nindent 4 }} 26 | {{- end }} 27 | {{- if $spec.labels }} 28 | labels: 29 | {{- toYaml $spec.labels | nindent 4 }} 30 | {{- end }} 31 | {{- if $spec.annotations }} 32 | annotations: 33 | {{- toYaml $spec.annotations | nindent 4 }} 34 | {{- end }} 35 | {{- if $spec.nodeSelector }} 36 | nodeSelector: 37 | {{- toYaml $spec.nodeSelector | nindent 4 }} 38 | {{- end }} 39 | {{- if $spec.tolerations }} 40 | tolerations: 41 | {{- toYaml $spec.tolerations | nindent 4 }} 42 | {{- end }} 43 | {{- end }} 44 | --- 45 | {{- end }} 46 | {{- else }} 47 | {{- with .Values.ingress.router }} 48 | apiVersion: netbird.io/v1 49 | kind: NBRoutingPeer 50 | metadata: 51 | finalizers: 52 | - netbird.io/cleanup 53 | labels: 54 | app.kubernetes.io/component: operator 55 | {{- include "kubernetes-operator.labels" $ | nindent 4 }} 56 | annotations: 57 | helm.sh/resource-policy: keep 58 | name: router 59 | {{- if or (or (or .replicas .resources) (or .labels .annotations)) (or .nodeSelector .tolerations) }} 60 | spec: 61 | {{- if .replicas }} 62 | replicas: {{ .replicas }} 63 | {{- end }} 64 | {{- if .resources }} 65 | resources: 66 | {{- toYaml .resources | nindent 4 }} 67 | {{- end }} 68 | {{- if .labels }} 69 | labels: 70 | {{- toYaml .labels | nindent 4 }} 71 | {{- end }} 72 | {{- if .annotations }} 73 | annotations: 74 | {{- toYaml .annotations | nindent 4 }} 75 | {{- end }} 76 | {{- if .nodeSelector }} 77 | nodeSelector: 78 | {{- toYaml .nodeSelector | nindent 4 }} 79 | {{- end }} 80 | {{- if .tolerations }} 81 | tolerations: 82 | {{- toYaml .tolerations | nindent 4 }} 83 | {{- end }} 84 | {{- else }} 85 | spec: {} 86 | {{- end }} 87 | {{- end }} 88 | {{- end }} 89 | {{- end }} -------------------------------------------------------------------------------- /helm/netbird-operator-config/templates/kubernetes-nbresource.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.enabled .Values.kubernetesAPI.enabled }} 2 | {{- $routerNS := .Release.Namespace }} 3 | {{- if .Values.namespacedNetworks }} 4 | {{- $routerNS = "default" }} 5 | {{- end }} 6 | apiVersion: batch/v1 7 | kind: Job 8 | metadata: 9 | name: {{ include "netbird-operator-config.fullname" . }}-kubernetes-service-expose 10 | labels: 11 | app.kubernetes.io/component: operator 12 | {{- include "netbird-operator-config.labels" . | nindent 4 }} 13 | annotations: 14 | helm.sh/hook: post-upgrade,post-install 15 | helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded 16 | spec: 17 | backoffLimit: 3 18 | template: 19 | metadata: 20 | name: {{ include "netbird-operator-config.fullname" . }} 21 | labels: 22 | app.kubernetes.io/component: operator 23 | {{- include "netbird-operator-config.labels" . | nindent 8 }} 24 | {{- with .Values.operator.podLabels }} 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | spec: 28 | initContainers: 29 | - name: wait-network-ready 30 | image: "netbirdio/kubectl:latest" 31 | command: 32 | - bash 33 | - -c 34 | args: 35 | - kubectl wait --for 'jsonpath={.status.networkID}' -n {{ $routerNS }} nbroutingpeer router; 36 | containers: 37 | - name: apply-nbresource 38 | image: "netbirdio/kubectl:latest" 39 | env: 40 | - name: NBRESOURCE_VALUE 41 | value: | 42 | apiVersion: netbird.io/v1 43 | kind: NBResource 44 | metadata: 45 | finalizers: 46 | - netbird.io/cleanup 47 | name: kubernetes 48 | namespace: default 49 | spec: 50 | address: kubernetes.default.{{.Values.cluster.dns}} 51 | groups: 52 | {{- if .Values.kubernetesAPI.groups }} 53 | {{ toYaml .Values.kubernetesAPI.groups }} 54 | {{- else }} 55 | - {{ .Values.cluster.name }}-default-api-access 56 | {{- end }} 57 | name: {{ .Values.kubernetesAPI.resourceName | default "default-kubernetes-api" }} 58 | networkID: ${NETWORK_ID} 59 | {{- if .Values.kubernetesAPI.policies }} 60 | policyName: "{{ join "," .Values.kubernetesAPI.policies }}" 61 | {{- end }} 62 | tcpPorts: 63 | - 443 64 | command: 65 | - bash 66 | - -c 67 | args: 68 | - kubectl delete NBResource --ignore-not-found -n default kubernetes; export NETWORK_ID=$(kubectl get NBRoutingPeer -n {{ $routerNS }} router -o 'jsonpath={.status.networkID}'); echo "$NBRESOURCE_VALUE" | envsubst | kubectl apply -f - 69 | serviceAccountName: {{ include "netbird-operator-config.serviceAccountName" . }} 70 | restartPolicy: Never 71 | {{- end }} 72 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/kubernetes-nbresource.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.ingress.enabled .Values.ingress.kubernetesAPI.enabled }} 2 | {{- $routerNS := .Release.Namespace }} 3 | {{- if .Values.ingress.namespacedNetworks }} 4 | {{- $routerNS = "default" }} 5 | {{- end }} 6 | apiVersion: batch/v1 7 | kind: Job 8 | metadata: 9 | name: {{ include "kubernetes-operator.fullname" . }}-kubernetes-service-expose 10 | labels: 11 | app.kubernetes.io/component: operator 12 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 13 | annotations: 14 | helm.sh/hook: post-upgrade,post-install 15 | helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded 16 | spec: 17 | backoffLimit: 3 18 | template: 19 | metadata: 20 | name: {{ include "kubernetes-operator.fullname" . }} 21 | labels: 22 | app.kubernetes.io/component: operator 23 | {{- include "kubernetes-operator.labels" . | nindent 8 }} 24 | {{- with .Values.operator.podLabels }} 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | spec: 28 | initContainers: 29 | - name: wait-network-ready 30 | image: "netbirdio/kubectl:latest" 31 | command: 32 | - bash 33 | - -c 34 | args: 35 | - kubectl wait --for 'jsonpath={.status.networkID}' -n {{ $routerNS }} nbroutingpeer router; 36 | containers: 37 | - name: apply-nbresource 38 | image: "netbirdio/kubectl:latest" 39 | env: 40 | - name: NBRESOURCE_VALUE 41 | value: | 42 | apiVersion: netbird.io/v1 43 | kind: NBResource 44 | metadata: 45 | finalizers: 46 | - netbird.io/cleanup 47 | name: kubernetes 48 | namespace: default 49 | spec: 50 | address: kubernetes.default.{{.Values.cluster.dns}} 51 | groups: 52 | {{- if .Values.ingress.kubernetesAPI.groups }} 53 | {{ toYaml .Values.ingress.kubernetesAPI.groups }} 54 | {{- else }} 55 | - {{ .Values.cluster.name }}-default-api-access 56 | {{- end }} 57 | name: {{ .Values.ingress.kubernetesAPI.resourceName | default "default-kubernetes-api" }} 58 | networkID: ${NETWORK_ID} 59 | {{- if .Values.ingress.kubernetesAPI.policies }} 60 | policyName: "{{ join "," .Values.ingress.kubernetesAPI.policies }}" 61 | {{- end }} 62 | tcpPorts: 63 | - 443 64 | command: 65 | - bash 66 | - -c 67 | args: 68 | - kubectl delete NBResource --ignore-not-found -n default kubernetes; export NETWORK_ID=$(kubectl get NBRoutingPeer -n {{ $routerNS }} router -o 'jsonpath={.status.networkID}'); echo "$NBRESOURCE_VALUE" | envsubst | kubectl apply -f - 69 | serviceAccountName: {{ include "kubernetes-operator.serviceAccountName" . }} 70 | restartPolicy: Never 71 | {{- end }} 72 | -------------------------------------------------------------------------------- /internal/webhook/v1/nbgroup_webhook.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "k8s.io/apimachinery/pkg/api/errors" 8 | "k8s.io/apimachinery/pkg/runtime" 9 | "k8s.io/apimachinery/pkg/types" 10 | ctrl "sigs.k8s.io/controller-runtime" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | logf "sigs.k8s.io/controller-runtime/pkg/log" 13 | "sigs.k8s.io/controller-runtime/pkg/webhook" 14 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 15 | 16 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 17 | ) 18 | 19 | // nolint:unused 20 | // log is for logging in this package. 21 | var nbgrouplog = logf.Log.WithName("nbgroup-resource") 22 | 23 | // SetupNBGroupWebhookWithManager registers the webhook for NBGroup in the manager. 24 | func SetupNBGroupWebhookWithManager(mgr ctrl.Manager) error { 25 | return ctrl.NewWebhookManagedBy(mgr).For(&netbirdiov1.NBGroup{}). 26 | WithValidator(&NBGroupCustomValidator{client: mgr.GetClient()}). 27 | Complete() 28 | } 29 | 30 | // NBGroupCustomValidator struct is responsible for validating the NBGroup resource 31 | // when it is created, updated, or deleted. 32 | type NBGroupCustomValidator struct { 33 | client client.Client 34 | } 35 | 36 | var _ webhook.CustomValidator = &NBGroupCustomValidator{} 37 | 38 | // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type NBGroup. 39 | func (v *NBGroupCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 40 | return nil, nil 41 | } 42 | 43 | // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type NBGroup. 44 | func (v *NBGroupCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { 45 | return nil, nil 46 | } 47 | 48 | // ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type NBGroup. 49 | func (v *NBGroupCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 50 | nbgroup, ok := obj.(*netbirdiov1.NBGroup) 51 | if !ok { 52 | return nil, fmt.Errorf("expected a NBGroup object but got %T", obj) 53 | } 54 | nbgrouplog.Info("Validation for NBGroup upon deletion", "name", nbgroup.GetName()) 55 | 56 | for _, o := range nbgroup.OwnerReferences { 57 | if o.Kind == (&netbirdiov1.NBResource{}).Kind { 58 | var nbResource netbirdiov1.NBResource 59 | err := v.client.Get(ctx, types.NamespacedName{Namespace: nbgroup.Namespace, Name: o.Name}, &nbResource) 60 | if err != nil && !errors.IsNotFound(err) { 61 | return nil, err 62 | } 63 | if err == nil && nbResource.DeletionTimestamp == nil { 64 | return nil, fmt.Errorf("group attached to NBResource %s/%s", nbgroup.Namespace, o.Name) 65 | } 66 | } 67 | if o.Kind == (&netbirdiov1.NBRoutingPeer{}).Kind { 68 | var nbResource netbirdiov1.NBRoutingPeer 69 | err := v.client.Get(ctx, types.NamespacedName{Namespace: nbgroup.Namespace, Name: o.Name}, &nbResource) 70 | if err != nil && !errors.IsNotFound(err) { 71 | return nil, err 72 | } 73 | if err == nil && nbResource.DeletionTimestamp == nil { 74 | return nil, fmt.Errorf("group attached to NBRoutingPeer %s/%s", nbgroup.Namespace, o.Name) 75 | } 76 | } 77 | } 78 | 79 | return nil, nil 80 | } 81 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/netbirdio/kubernetes-operator 2 | 3 | go 1.23.0 4 | 5 | godebug default=go1.23 6 | 7 | require ( 8 | github.com/go-logr/logr v1.4.2 9 | github.com/google/uuid v1.6.0 10 | github.com/netbirdio/netbird v0.36.7 11 | github.com/onsi/ginkgo/v2 v2.21.0 12 | github.com/onsi/gomega v1.35.1 13 | k8s.io/api v0.32.0 14 | k8s.io/apimachinery v0.32.0 15 | k8s.io/client-go v0.32.0 16 | sigs.k8s.io/controller-runtime v0.20.0 17 | ) 18 | 19 | require ( 20 | github.com/beorn7/perks v1.0.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 23 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 24 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 25 | github.com/fsnotify/fsnotify v1.7.0 // indirect 26 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 27 | github.com/go-logr/zapr v1.3.0 // indirect 28 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 29 | github.com/go-openapi/jsonreference v0.20.2 // indirect 30 | github.com/go-openapi/swag v0.23.0 // indirect 31 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 32 | github.com/gogo/protobuf v1.3.2 // indirect 33 | github.com/golang/protobuf v1.5.4 // indirect 34 | github.com/google/btree v1.1.3 // indirect 35 | github.com/google/gnostic-models v0.6.8 // indirect 36 | github.com/google/go-cmp v0.6.0 // indirect 37 | github.com/google/gofuzz v1.2.0 // indirect 38 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect 39 | github.com/josharian/intern v1.0.0 // indirect 40 | github.com/json-iterator/go v1.1.12 // indirect 41 | github.com/mailru/easyjson v0.7.7 // indirect 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 43 | github.com/modern-go/reflect2 v1.0.2 // indirect 44 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 45 | github.com/pkg/errors v0.9.1 // indirect 46 | github.com/prometheus/client_golang v1.19.1 // indirect 47 | github.com/prometheus/client_model v0.6.1 // indirect 48 | github.com/prometheus/common v0.55.0 // indirect 49 | github.com/prometheus/procfs v0.15.1 // indirect 50 | github.com/sirupsen/logrus v1.9.3 // indirect 51 | github.com/spf13/pflag v1.0.5 // indirect 52 | github.com/x448/float16 v0.8.4 // indirect 53 | go.uber.org/multierr v1.11.0 // indirect 54 | go.uber.org/zap v1.27.0 // indirect 55 | golang.org/x/net v0.38.0 // indirect 56 | golang.org/x/oauth2 v0.27.0 // indirect 57 | golang.org/x/sync v0.12.0 // indirect 58 | golang.org/x/sys v0.31.0 // indirect 59 | golang.org/x/term v0.30.0 // indirect 60 | golang.org/x/text v0.23.0 // indirect 61 | golang.org/x/time v0.7.0 // indirect 62 | golang.org/x/tools v0.26.0 // indirect 63 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 64 | google.golang.org/protobuf v1.35.1 // indirect 65 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 66 | gopkg.in/inf.v0 v0.9.1 // indirect 67 | gopkg.in/yaml.v3 v3.0.1 // indirect 68 | k8s.io/apiextensions-apiserver v0.32.0 // indirect 69 | k8s.io/klog/v2 v2.130.1 // indirect 70 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect 71 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 72 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 73 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect 74 | sigs.k8s.io/yaml v1.4.0 // indirect 75 | ) 76 | -------------------------------------------------------------------------------- /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/netbirdio/kubernetes-operator/test/utils" 29 | ) 30 | 31 | var ( 32 | // Optional Environment Variables: 33 | // - CERT_MANAGER_INSTALL_SKIP=true: Skips CertManager installation during test setup. 34 | // This variable is 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 = "docker.io/netbirdio/kubernetes-operator:e2e" 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 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 operator 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 | if !skipCertManagerInstall { 66 | By("checking if cert manager is installed already") 67 | isCertManagerAlreadyInstalled = utils.IsCertManagerCRDsInstalled() 68 | if !isCertManagerAlreadyInstalled { 69 | _, _ = fmt.Fprintf(GinkgoWriter, "Installing CertManager...\n") 70 | Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") 71 | } else { 72 | _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: CertManager is already installed. Skipping installation...\n") 73 | } 74 | } 75 | }) 76 | 77 | var _ = AfterSuite(func() { 78 | // Teardown CertManager after the suite if not skipped and if it was not already installed 79 | if !skipCertManagerInstall && !isCertManagerAlreadyInstalled { 80 | _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling CertManager...\n") 81 | utils.UninstallCertManager() 82 | } 83 | }) 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBird Kubernetes Operator 2 | For easily provisioning access to Kubernetes resources using NetBird. 3 | 4 | https://github.com/user-attachments/assets/5472a499-e63d-4301-a513-ad84cfe5ca7b 5 | 6 | ## Description 7 | 8 | This operator easily provides NetBird access on Kubernetes clusters, allowing users to access internal resources directly. 9 | 10 | ## Getting Started 11 | 12 | ### Prerequisites 13 | - (Recommended) helm version 3+ 14 | - kubectl version v1.11.3+. 15 | - Access to a Kubernetes v1.11.3+ cluster. 16 | - (Recommended) Cert Manager. 17 | 18 | 19 | ### Deployment 20 | 21 | 1. Add helm repository. 22 | ```sh 23 | helm repo add netbirdio https://netbirdio.github.io/kubernetes-operator 24 | ``` 25 | 2. (Recommended) Install [cert-manager](https://cert-manager.io/docs/installation/#default-static-install) for k8s API to communicate with the NetBird operator. 26 | ```sh 27 | kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml 28 | ``` 29 | 3. Add NetBird API token 30 | ```shell 31 | kubectl create namespace netbird 32 | kubectl -n netbird create secret generic netbird-mgmt-api-key --from-literal=NB_API_KEY=$(cat ~/nb-pat.secret) 33 | ``` 34 | 4. (Recommended) Create a [`values.yaml`](examples/ingress/values.yaml) file, check `helm show values netbirdio/kubernetes-operator` for more info. 35 | 5. Install using `helm install --create-namespace -f values.yaml -n netbird netbird-operator netbirdio/kubernetes-operator`. 36 | 6. (Recommended) Check pod status using `kubectl get pods -n netbird`. 37 | 6. (Optional) Create an [`exposed-nginx.yaml`](examples/ingress/exposed-nginx.yaml) file to create a Nginx service for testing. 38 | 7. (Optional) Apply the Nginx service: 39 | ```sh 40 | kubectl apply -f exposed-nginx.yaml 41 | ``` 42 | 43 | > Learn more about the values.yaml options [here](helm/kubernetes-operator/values.yaml) and [Granting controller access to NetBird Management](docs/usage.md#granting-controller-access-to-netbird-management). 44 | 45 | ### Uninstallation 46 | 47 | > [!IMPORTANT] 48 | > Most operator resources are annotated with finalizers, attempting to delete the namespace will result in hanged deletion. 49 | 50 | 1. (If ingress mode is enabled) Remove all instances of `netbird.io/expose` annotation on Services. 51 | 2. Run `helm uninstall -n netbird netbird-operator`. 52 | 3. Wait for all deletion jobs to finish. 53 | 54 | ### Version 55 | We have developed and executed tests against Kubernetes v1.31, but it should work with most recent Kubernetes version. 56 | 57 | Latest operator version: v0.1.1. 58 | 59 | Tested against: 60 | |Distribution|Test status|Kubernetes Version| 61 | |---|---|---| 62 | |Google GKE|Pass|1.31.5| 63 | |AWS EKS|Pass|1.31| 64 | |Azure AKS|Not tested|N/A| 65 | |OpenShift|Not tested|N/A| 66 | 67 | > We would love community feedback to improve the test matrix. Please submit a PR with your test results. 68 | 69 | ### Usage 70 | 71 | Check the usage of [usage.md](docs/usage.md) and examples. 72 | 73 | ## Contributing 74 | 75 | ### Prerequisites 76 | 77 | To be able to develop this project, you need to have the following tools installed: 78 | 79 | - [Git](https://git-scm.com/). 80 | - [Make](https://www.gnu.org/software/make/). 81 | - [Go programming language](https://golang.org/dl/). 82 | - [Docker CE](https://www.docker.com/community-edition). 83 | - [Kubernetes cluster (v1.16+)](https://kubernetes.io/docs/setup/). [KIND](https://github.com/kubernetes-sigs/kind) is recommended. 84 | - [Kubebuilder](https://book.kubebuilder.io/). 85 | 86 | ### Running tests 87 | 88 | **Running unit tests** 89 | ```sh 90 | make test 91 | ``` 92 | 93 | **Running E2E tests** 94 | ```sh 95 | kind create cluster # If not already created, you can check with `kind get clusters` 96 | make test-e2e 97 | ``` -------------------------------------------------------------------------------- /api/v1/nbresource_types.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "maps" 5 | 6 | "github.com/netbirdio/kubernetes-operator/internal/util" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | ) 9 | 10 | // NBResourceSpec defines the desired state of NBResource. 11 | type NBResourceSpec struct { 12 | // +kubebuilder:validation:MinLength=1 13 | Name string `json:"name"` 14 | // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" 15 | NetworkID string `json:"networkID"` 16 | // +kubebuilder:validation:MinLength=1 17 | Address string `json:"address"` 18 | // +kubebuilder:validation:items:MinLength=1 19 | Groups []string `json:"groups"` 20 | // +optional 21 | PolicyName string `json:"policyName,omitempty"` 22 | // +optional 23 | PolicySourceGroups []string `json:"policySourceGroups,omitempty"` 24 | // +optional 25 | PolicyFriendlyName map[string]string `json:"policyFriendlyName,omitempty"` 26 | // +optional 27 | TCPPorts []int32 `json:"tcpPorts,omitempty"` 28 | // +optional 29 | UDPPorts []int32 `json:"udpPorts,omitempty"` 30 | } 31 | 32 | // Equal returns if NBResource is equal to this one 33 | func (a NBResourceSpec) Equal(b NBResourceSpec) bool { 34 | return a.Name == b.Name && 35 | a.NetworkID == b.NetworkID && 36 | a.Address == b.Address && 37 | util.Equivalent(a.Groups, b.Groups) && 38 | a.PolicyName == b.PolicyName && 39 | util.Equivalent(a.TCPPorts, b.TCPPorts) && 40 | util.Equivalent(a.UDPPorts, b.UDPPorts) && 41 | util.Equivalent(a.PolicySourceGroups, b.PolicySourceGroups) 42 | } 43 | 44 | // NBResourceStatus defines the observed state of NBResource. 45 | type NBResourceStatus struct { 46 | // +optional 47 | NetworkResourceID *string `json:"networkResourceID,omitempty"` 48 | // +optional 49 | PolicyName *string `json:"policyName,omitempty"` 50 | // +optional 51 | TCPPorts []int32 `json:"tcpPorts,omitempty"` 52 | // +optional 53 | UDPPorts []int32 `json:"udpPorts,omitempty"` 54 | // +optional 55 | Groups []string `json:"groups,omitempty"` 56 | // +optional 57 | PolicySourceGroups []string `json:"policySourceGroups,omitempty"` 58 | // +optional 59 | PolicyFriendlyName map[string]string `json:"policyFriendlyName,omitempty"` 60 | // +optional 61 | Conditions []NBCondition `json:"conditions,omitempty"` 62 | // +optional 63 | PolicyNameMapping map[string]string `json:"policyNameMapping"` 64 | } 65 | 66 | // Equal returns if NBResourceStatus is equal to this one 67 | func (a NBResourceStatus) Equal(b NBResourceStatus) bool { 68 | return a.NetworkResourceID == b.NetworkResourceID && 69 | a.PolicyName == b.PolicyName && 70 | util.Equivalent(a.TCPPorts, b.TCPPorts) && 71 | util.Equivalent(a.UDPPorts, b.UDPPorts) && 72 | util.Equivalent(a.Groups, b.Groups) && 73 | util.Equivalent(a.Conditions, b.Conditions) && 74 | util.Equivalent(a.PolicySourceGroups, b.PolicySourceGroups) && 75 | maps.Equal(a.PolicyFriendlyName, b.PolicyFriendlyName) && 76 | maps.Equal(a.PolicyNameMapping, b.PolicyNameMapping) 77 | } 78 | 79 | // +kubebuilder:object:root=true 80 | // +kubebuilder:subresource:status 81 | 82 | // NBResource is the Schema for the nbresources API. 83 | type NBResource struct { 84 | metav1.TypeMeta `json:",inline"` 85 | metav1.ObjectMeta `json:"metadata,omitempty"` 86 | 87 | Spec NBResourceSpec `json:"spec,omitempty"` 88 | Status NBResourceStatus `json:"status,omitempty"` 89 | } 90 | 91 | // +kubebuilder:object:root=true 92 | 93 | // NBResourceList contains a list of NBResource. 94 | type NBResourceList struct { 95 | metav1.TypeMeta `json:",inline"` 96 | metav1.ListMeta `json:"metadata,omitempty"` 97 | Items []NBResource `json:"items"` 98 | } 99 | 100 | func init() { 101 | SchemeBuilder.Register(&NBResource{}, &NBResourceList{}) 102 | } 103 | -------------------------------------------------------------------------------- /crds/netbird.io_nbgroups.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: nbgroups.netbird.io 8 | spec: 9 | group: netbird.io 10 | names: 11 | kind: NBGroup 12 | listKind: NBGroupList 13 | plural: nbgroups 14 | singular: nbgroup 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: NBGroup is the Schema for the nbgroups API. 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: NBGroupSpec defines the desired state of NBGroup. 41 | properties: 42 | name: 43 | minLength: 1 44 | type: string 45 | x-kubernetes-validations: 46 | - message: Value is immutable 47 | rule: self == oldSelf 48 | required: 49 | - name 50 | type: object 51 | status: 52 | description: NBGroupStatus defines the observed state of NBGroup. 53 | properties: 54 | conditions: 55 | items: 56 | description: NBCondition defines a condition in NBSetupKey status. 57 | properties: 58 | lastProbeTime: 59 | description: Last time we probed the condition. 60 | format: date-time 61 | type: string 62 | lastTransitionTime: 63 | description: Last time the condition transitioned from one status 64 | to another. 65 | format: date-time 66 | type: string 67 | message: 68 | description: Human-readable message indicating details about 69 | last transition. 70 | type: string 71 | reason: 72 | description: Unique, one-word, CamelCase reason for the condition's 73 | last transition. 74 | type: string 75 | status: 76 | description: |- 77 | Status is the status of the condition. 78 | Can be True, False, Unknown. 79 | type: string 80 | type: 81 | description: Type is the type of the condition. 82 | type: string 83 | required: 84 | - status 85 | - type 86 | type: object 87 | type: array 88 | groupID: 89 | type: string 90 | type: object 91 | type: object 92 | served: true 93 | storage: true 94 | subresources: 95 | status: {} 96 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/crds/netbird.io_nbgroups.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: nbgroups.netbird.io 8 | spec: 9 | group: netbird.io 10 | names: 11 | kind: NBGroup 12 | listKind: NBGroupList 13 | plural: nbgroups 14 | singular: nbgroup 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: NBGroup is the Schema for the nbgroups API. 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: NBGroupSpec defines the desired state of NBGroup. 41 | properties: 42 | name: 43 | minLength: 1 44 | type: string 45 | x-kubernetes-validations: 46 | - message: Value is immutable 47 | rule: self == oldSelf 48 | required: 49 | - name 50 | type: object 51 | status: 52 | description: NBGroupStatus defines the observed state of NBGroup. 53 | properties: 54 | conditions: 55 | items: 56 | description: NBCondition defines a condition in NBSetupKey status. 57 | properties: 58 | lastProbeTime: 59 | description: Last time we probed the condition. 60 | format: date-time 61 | type: string 62 | lastTransitionTime: 63 | description: Last time the condition transitioned from one status 64 | to another. 65 | format: date-time 66 | type: string 67 | message: 68 | description: Human-readable message indicating details about 69 | last transition. 70 | type: string 71 | reason: 72 | description: Unique, one-word, CamelCase reason for the condition's 73 | last transition. 74 | type: string 75 | status: 76 | description: |- 77 | Status is the status of the condition. 78 | Can be True, False, Unknown. 79 | type: string 80 | type: 81 | description: Type is the type of the condition. 82 | type: string 83 | required: 84 | - status 85 | - type 86 | type: object 87 | type: array 88 | groupID: 89 | type: string 90 | type: object 91 | type: object 92 | served: true 93 | storage: true 94 | subresources: 95 | status: {} 96 | -------------------------------------------------------------------------------- /internal/webhook/v1/pod_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 v1 18 | 19 | import ( 20 | "context" 21 | 22 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 23 | . "github.com/onsi/ginkgo/v2" 24 | . "github.com/onsi/gomega" 25 | 26 | corev1 "k8s.io/api/core/v1" 27 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 28 | ) 29 | 30 | var _ = Describe("Pod Webhook", func() { 31 | var ( 32 | obj *corev1.Pod 33 | defaulter PodNetbirdInjector 34 | ) 35 | 36 | BeforeEach(func() { 37 | obj = &corev1.Pod{ 38 | ObjectMeta: v1.ObjectMeta{ 39 | Name: "test", 40 | Namespace: "test", 41 | Annotations: make(map[string]string), 42 | }, 43 | Spec: corev1.PodSpec{ 44 | Containers: []corev1.Container{ 45 | { 46 | Name: "test", 47 | }, 48 | }, 49 | }, 50 | } 51 | defaulter = PodNetbirdInjector{ 52 | client: k8sClient, 53 | managementURL: "https://api.netbird.io", 54 | clientImage: "netbirdio/netbird:latest", 55 | } 56 | Expect(defaulter).NotTo(BeNil(), "Expected defaulter to be initialized") 57 | Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") 58 | }) 59 | 60 | AfterEach(func() { 61 | }) 62 | 63 | Context("When creating Pod without annotation", func() { 64 | It("Should not modify anything", func() { 65 | err := defaulter.Default(context.Background(), obj) 66 | Expect(err).NotTo(HaveOccurred()) 67 | Expect(obj.Spec.Containers).To(HaveLen(1)) 68 | }) 69 | }) 70 | 71 | Context("When creating Pod with annotation", func() { 72 | BeforeEach(func() { 73 | obj.Annotations[setupKeyAnnotation] = "test" 74 | }) 75 | 76 | When("NBSetupKey doesn't exist", func() { 77 | It("Should fail", func() { 78 | Expect(defaulter.Default(context.Background(), obj)).To(HaveOccurred()) 79 | Expect(obj.Spec.Containers).To(HaveLen(1)) 80 | }) 81 | }) 82 | 83 | When("NBSetupKey exists", Ordered, func() { 84 | BeforeAll(func() { 85 | sk := netbirdiov1.NBSetupKey{ 86 | ObjectMeta: v1.ObjectMeta{ 87 | Name: "test", 88 | Namespace: "test", 89 | }, 90 | Spec: netbirdiov1.NBSetupKeySpec{ 91 | SecretKeyRef: corev1.SecretKeySelector{ 92 | LocalObjectReference: corev1.LocalObjectReference{ 93 | Name: "test", 94 | }, 95 | Key: "test", 96 | }, 97 | }, 98 | } 99 | 100 | err := k8sClient.Create(context.Background(), &corev1.Namespace{ 101 | ObjectMeta: v1.ObjectMeta{ 102 | Name: "test", 103 | }, 104 | }) 105 | Expect(err).NotTo(HaveOccurred()) 106 | 107 | err = k8sClient.Create(context.Background(), &sk) 108 | Expect(err).NotTo(HaveOccurred()) 109 | 110 | sk.Status = netbirdiov1.NBSetupKeyStatus{ 111 | Conditions: []netbirdiov1.NBCondition{ 112 | { 113 | Type: netbirdiov1.NBSetupKeyReady, 114 | Status: corev1.ConditionTrue, 115 | }, 116 | }, 117 | } 118 | 119 | err = k8sClient.Status().Update(context.Background(), &sk) 120 | Expect(err).NotTo(HaveOccurred()) 121 | }) 122 | 123 | It("Should inject NB container", func() { 124 | Expect(defaulter.Default(context.Background(), obj)).NotTo(HaveOccurred()) 125 | Expect(obj.Spec.Containers).To(HaveLen(2)) 126 | Expect(obj.Spec.Containers[1].Name).To(Equal("netbird")) 127 | }) 128 | }) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /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 | foobarv1 "k8s.io/api/core/v1" 36 | 37 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 38 | // +kubebuilder:scaffold:imports 39 | ) 40 | 41 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 42 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 43 | 44 | var ( 45 | ctx context.Context 46 | cancel context.CancelFunc 47 | testEnv *envtest.Environment 48 | cfg *rest.Config 49 | k8sClient client.Client 50 | ) 51 | 52 | func TestControllers(t *testing.T) { 53 | RegisterFailHandler(Fail) 54 | 55 | RunSpecs(t, "Controller Suite") 56 | } 57 | 58 | var _ = BeforeSuite(func() { 59 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 60 | 61 | ctx, cancel = context.WithCancel(context.TODO()) 62 | 63 | var err error 64 | err = netbirdiov1.AddToScheme(scheme.Scheme) 65 | Expect(err).NotTo(HaveOccurred()) 66 | 67 | err = foobarv1.AddToScheme(scheme.Scheme) 68 | Expect(err).NotTo(HaveOccurred()) 69 | 70 | // +kubebuilder:scaffold:scheme 71 | 72 | By("bootstrapping test environment") 73 | testEnv = &envtest.Environment{ 74 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "helm", "kubernetes-operator", "crds")}, 75 | ErrorIfCRDPathMissing: true, 76 | } 77 | 78 | // Retrieve the first found binary directory to allow running tests from IDEs 79 | if getFirstFoundEnvTestBinaryDir() != "" { 80 | testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() 81 | } 82 | 83 | // cfg is defined in this file globally. 84 | cfg, err = testEnv.Start() 85 | Expect(err).NotTo(HaveOccurred()) 86 | Expect(cfg).NotTo(BeNil()) 87 | 88 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 89 | Expect(err).NotTo(HaveOccurred()) 90 | Expect(k8sClient).NotTo(BeNil()) 91 | }) 92 | 93 | var _ = AfterSuite(func() { 94 | By("tearing down the test environment") 95 | cancel() 96 | err := testEnv.Stop() 97 | Expect(err).NotTo(HaveOccurred()) 98 | }) 99 | 100 | // getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. 101 | // ENVTEST-based tests depend on specific binaries, usually located in paths set by 102 | // controller-runtime. When running tests directly (e.g., via an IDE) without using 103 | // Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. 104 | // 105 | // This function streamlines the process by finding the required binaries, similar to 106 | // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are 107 | // properly set up, run 'make setup-envtest' beforehand. 108 | func getFirstFoundEnvTestBinaryDir() string { 109 | basePath := filepath.Join("..", "..", "bin", "k8s") 110 | entries, err := os.ReadDir(basePath) 111 | if err != nil { 112 | logf.Log.Error(err, "Failed to read directory", "path", basePath) 113 | return "" 114 | } 115 | for _, entry := range entries { 116 | if entry.IsDir() { 117 | return filepath.Join(basePath, entry.Name()) 118 | } 119 | } 120 | return "" 121 | } 122 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "kubernetes-operator.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "kubernetes-operator.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "kubernetes-operator.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "kubernetes-operator.labels" -}} 37 | helm.sh/chart: {{ include "kubernetes-operator.chart" . }} 38 | {{ include "kubernetes-operator.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- if .Values.general.labels }} 44 | {{- range $key, $val := .Values.general.labels }} 45 | {{ $key }}: "{{ $val }}" 46 | {{- end }} 47 | {{- end }} 48 | {{- end }} 49 | 50 | {{/* 51 | Selector labels 52 | */}} 53 | {{- define "kubernetes-operator.selectorLabels" -}} 54 | app.kubernetes.io/name: {{ include "kubernetes-operator.name" . }} 55 | app.kubernetes.io/instance: {{ .Release.Name }} 56 | {{- end }} 57 | 58 | {{/* 59 | Create the name of the service account to use 60 | */}} 61 | {{- define "kubernetes-operator.serviceAccountName" -}} 62 | {{- if .Values.operator.serviceAccount.create }} 63 | {{- default (include "kubernetes-operator.fullname" .) .Values.operator.serviceAccount.name }} 64 | {{- else }} 65 | {{- default "default" .Values.operator.serviceAccount.name }} 66 | {{- end }} 67 | {{- end }} 68 | 69 | 70 | {{/* 71 | Create the name of the webhook service 72 | */}} 73 | {{- define "kubernetes-operator.webhookService" -}} 74 | {{- printf "%s-webhook-service" (include "kubernetes-operator.fullname" .) -}} 75 | {{- end -}} 76 | 77 | {{/* 78 | Create the name of the webhook cert secret 79 | */}} 80 | {{- define "kubernetes-operator.webhookCertSecret" -}} 81 | {{- printf "%s-tls" (include "kubernetes-operator.fullname" .) -}} 82 | {{- end -}} 83 | 84 | {{/* 85 | Generate certificates for webhook 86 | */}} 87 | {{- define "kubernetes-operator.webhookCerts" -}} 88 | {{- $serviceName := (include "kubernetes-operator.webhookService" .) -}} 89 | {{- $secretName := (include "kubernetes-operator.webhookCertSecret" .) -}} 90 | {{- $secret := lookup "v1" "Secret" .Release.Namespace $secretName -}} 91 | {{- if (and .Values.webhook.tls.caCert .Values.webhook.tls.cert .Values.webhook.tls.key) -}} 92 | caCert: {{ .Values.webhook.tls.caCert | b64enc }} 93 | clientCert: {{ .Values.webhook.tls.cert | b64enc }} 94 | clientKey: {{ .Values.webhook.tls.key | b64enc }} 95 | {{- else if and .Values.keepTLSSecret $secret -}} 96 | caCert: {{ index $secret.data "ca.crt" }} 97 | clientCert: {{ index $secret.data "tls.crt" }} 98 | clientKey: {{ index $secret.data "tls.key" }} 99 | {{- else -}} 100 | {{- $altNames := list (printf "%s.%s" $serviceName .Release.Namespace) (printf "%s.%s.svc" $serviceName .Release.Namespace) (printf "%s.%s.%s" $serviceName .Release.Namespace .Values.cluster.dns) -}} 101 | {{- $ca := genCA "kubernetes-operator-ca" 3650 -}} 102 | {{- $cert := genSignedCert (include "kubernetes-operator.fullname" .) nil $altNames 3650 $ca -}} 103 | caCert: {{ $ca.Cert | b64enc }} 104 | clientCert: {{ $cert.Cert | b64enc }} 105 | clientKey: {{ $cert.Key | b64enc }} 106 | {{- end -}} 107 | {{- end -}} -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: {{ include "kubernetes-operator.fullname" . }} 5 | labels: 6 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - netbird.io 10 | resources: 11 | - nbsetupkeys 12 | verbs: 13 | - get 14 | - list 15 | - watch 16 | - apiGroups: 17 | - netbird.io 18 | resources: 19 | - nbsetupkeys/finalizers 20 | verbs: 21 | - update 22 | - apiGroups: 23 | - netbird.io 24 | resources: 25 | - nbsetupkeys/status 26 | verbs: 27 | - get 28 | - patch 29 | - update 30 | {{- if or .Values.netbirdAPI.key .Values.netbirdAPI.keyFromSecret }} 31 | - apiGroups: 32 | - netbird.io 33 | resources: 34 | - nbgroups 35 | - nbresources 36 | - nbroutingpeers 37 | - nbpolicies 38 | verbs: 39 | - get 40 | - patch 41 | - update 42 | - list 43 | - watch 44 | - create 45 | - delete 46 | - apiGroups: 47 | - netbird.io 48 | resources: 49 | - nbgroups/status 50 | - nbresources/status 51 | - nbroutingpeers/status 52 | - nbpolicies/status 53 | verbs: 54 | - get 55 | - patch 56 | - update 57 | - apiGroups: 58 | - netbird.io 59 | resources: 60 | - nbgroups/finalizers 61 | - nbresources/finalizers 62 | - nbroutingpeers/finalizers 63 | - nbpolicies/finalizers 64 | verbs: 65 | - update 66 | - apiGroups: 67 | - "" 68 | resources: 69 | - services 70 | verbs: 71 | - get 72 | - list 73 | - watch 74 | - update 75 | - patch 76 | - apiGroups: 77 | - "" 78 | resources: 79 | - namespaces 80 | verbs: 81 | - get 82 | - list 83 | - watch 84 | - apiGroups: 85 | - "" 86 | resources: 87 | - services/finalizers 88 | verbs: 89 | - update 90 | - apiGroups: 91 | - apps 92 | resources: 93 | - deployments 94 | verbs: 95 | - get 96 | - patch 97 | - update 98 | - list 99 | - watch 100 | - create 101 | - delete 102 | {{- end }} 103 | - apiGroups: 104 | - "" 105 | resources: 106 | - pods 107 | verbs: 108 | - get 109 | - list 110 | - watch 111 | {{- if or (or .Values.netbirdAPI.key .Values.netbirdAPI.keyFromSecret) .Values.clusterSecretsPermissions.allowAllSecrets }} 112 | - apiGroups: 113 | - "" 114 | resources: 115 | - secrets 116 | verbs: 117 | - get 118 | - list 119 | - watch 120 | {{- if or .Values.netbirdAPI.key .Values.netbirdAPI.keyFromSecret }} 121 | - patch 122 | - update 123 | - create 124 | - delete 125 | {{- end }} 126 | {{- end }} 127 | --- 128 | apiVersion: rbac.authorization.k8s.io/v1 129 | kind: ClusterRoleBinding 130 | metadata: 131 | name: {{ include "kubernetes-operator.fullname" . }} 132 | labels: 133 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 134 | roleRef: 135 | apiGroup: rbac.authorization.k8s.io 136 | kind: ClusterRole 137 | name: {{ include "kubernetes-operator.fullname" . }} 138 | subjects: 139 | - kind: ServiceAccount 140 | name: {{ include "kubernetes-operator.serviceAccountName" . }} 141 | namespace: {{ .Release.Namespace }} 142 | --- 143 | apiVersion: rbac.authorization.k8s.io/v1 144 | kind: Role 145 | metadata: 146 | name: {{ include "kubernetes-operator.fullname" . }} 147 | labels: 148 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 149 | rules: 150 | - apiGroups: 151 | - "" 152 | resources: 153 | - configmaps 154 | verbs: 155 | - get 156 | - list 157 | - watch 158 | - create 159 | - update 160 | - patch 161 | - delete 162 | - apiGroups: 163 | - coordination.k8s.io 164 | resources: 165 | - leases 166 | verbs: 167 | - get 168 | - list 169 | - watch 170 | - create 171 | - update 172 | - patch 173 | - delete 174 | - apiGroups: 175 | - "" 176 | resources: 177 | - events 178 | verbs: 179 | - create 180 | - patch 181 | --- 182 | apiVersion: rbac.authorization.k8s.io/v1 183 | kind: RoleBinding 184 | metadata: 185 | name: {{ include "kubernetes-operator.fullname" . }} 186 | labels: 187 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 188 | roleRef: 189 | apiGroup: rbac.authorization.k8s.io 190 | kind: Role 191 | name: {{ include "kubernetes-operator.fullname" . }} 192 | subjects: 193 | - kind: ServiceAccount 194 | name: {{ include "kubernetes-operator.serviceAccountName" . }} 195 | namespace: {{ .Release.Namespace }} 196 | -------------------------------------------------------------------------------- /api/v1/nbsetupkey_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 v1 18 | 19 | import ( 20 | corev1 "k8s.io/api/core/v1" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | // NBConditionType is a valid value for PodCondition.Type 25 | type NBConditionType string 26 | 27 | // These are built-in conditions of pod. An application may use a custom condition not listed here. 28 | const ( 29 | // NBSetupKeyReady indicates whether NBSetupKey is valid and ready to use. 30 | NBSetupKeyReady NBConditionType = "Ready" 31 | ) 32 | 33 | // NBSetupKeySpec defines the desired state of NBSetupKey. 34 | type NBSetupKeySpec struct { 35 | // SecretKeyRef is a reference to the secret containing the setup key 36 | SecretKeyRef corev1.SecretKeySelector `json:"secretKeyRef"` 37 | // ManagementURL optional, override operator management URL 38 | ManagementURL string `json:"managementURL,omitempty"` 39 | // Volumes optional, additional volumes for NetBird container 40 | // +optional 41 | Volumes []corev1.Volume `json:"volumes,omitempty"` 42 | // VolumeMounts optional, additional volumeMounts for NetBird container 43 | // +optional 44 | VolumeMounts []corev1.VolumeMount `json:"volumeMounts,omitempty"` 45 | } 46 | 47 | // NBSetupKeyStatus defines the observed state of NBSetupKey. 48 | type NBSetupKeyStatus struct { 49 | // +optional 50 | Conditions []NBCondition `json:"conditions,omitempty"` 51 | } 52 | 53 | // NBCondition defines a condition in NBSetupKey status. 54 | type NBCondition struct { 55 | // Type is the type of the condition. 56 | Type NBConditionType `json:"type"` 57 | // Status is the status of the condition. 58 | // Can be True, False, Unknown. 59 | Status corev1.ConditionStatus `json:"status"` 60 | // Last time we probed the condition. 61 | // +optional 62 | LastProbeTime metav1.Time `json:"lastProbeTime,omitempty"` 63 | // Last time the condition transitioned from one status to another. 64 | // +optional 65 | LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` 66 | // Unique, one-word, CamelCase reason for the condition's last transition. 67 | // +optional 68 | Reason string `json:"reason,omitempty"` 69 | // Human-readable message indicating details about last transition. 70 | // +optional 71 | Message string `json:"message,omitempty"` 72 | } 73 | 74 | // NBConditionTrue returns default true condition 75 | func NBConditionTrue() []NBCondition { 76 | return []NBCondition{ 77 | { 78 | Type: NBSetupKeyReady, 79 | LastProbeTime: metav1.Now(), 80 | LastTransitionTime: metav1.Now(), 81 | Status: corev1.ConditionTrue, 82 | }, 83 | } 84 | } 85 | 86 | // NBConditionFalse returns default false condition 87 | func NBConditionFalse(reason, msg string) []NBCondition { 88 | return []NBCondition{ 89 | { 90 | Type: NBSetupKeyReady, 91 | LastProbeTime: metav1.Now(), 92 | LastTransitionTime: metav1.Now(), 93 | Status: corev1.ConditionFalse, 94 | Reason: reason, 95 | Message: msg, 96 | }, 97 | } 98 | } 99 | 100 | // +kubebuilder:object:root=true 101 | // +kubebuilder:subresource:status 102 | 103 | // NBSetupKey is the Schema for the nbsetupkeys API. 104 | type NBSetupKey struct { 105 | metav1.TypeMeta `json:",inline"` 106 | metav1.ObjectMeta `json:"metadata,omitempty"` 107 | 108 | Spec NBSetupKeySpec `json:"spec,omitempty"` 109 | Status NBSetupKeyStatus `json:"status,omitempty"` 110 | } 111 | 112 | // +kubebuilder:object:root=true 113 | 114 | // NBSetupKeyList contains a list of NBSetupKey. 115 | type NBSetupKeyList struct { 116 | metav1.TypeMeta `json:",inline"` 117 | metav1.ListMeta `json:"metadata,omitempty"` 118 | Items []NBSetupKey `json:"items"` 119 | } 120 | 121 | func init() { 122 | SchemeBuilder.Register(&NBSetupKey{}, &NBSetupKeyList{}) 123 | } 124 | -------------------------------------------------------------------------------- /internal/webhook/v1/pod_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 v1 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | corev1 "k8s.io/api/core/v1" 24 | "k8s.io/apimachinery/pkg/runtime" 25 | "k8s.io/apimachinery/pkg/types" 26 | ctrl "sigs.k8s.io/controller-runtime" 27 | "sigs.k8s.io/controller-runtime/pkg/client" 28 | logf "sigs.k8s.io/controller-runtime/pkg/log" 29 | "sigs.k8s.io/controller-runtime/pkg/webhook" 30 | 31 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 32 | ) 33 | 34 | const ( 35 | setupKeyAnnotation = "netbird.io/setup-key" 36 | ) 37 | 38 | // nolint:unused 39 | // log is for logging in this package. 40 | var podlog = logf.Log.WithName("pod-resource") 41 | 42 | // SetupPodWebhookWithManager registers the webhook for Pod in the manager. 43 | func SetupPodWebhookWithManager(mgr ctrl.Manager, managementURL, clientImage string) error { 44 | return ctrl.NewWebhookManagedBy(mgr).For(&corev1.Pod{}). 45 | WithDefaulter(&PodNetbirdInjector{ 46 | client: mgr.GetClient(), 47 | managementURL: managementURL, 48 | clientImage: clientImage, 49 | }). 50 | Complete() 51 | } 52 | 53 | // PodNetbirdInjector struct is responsible for setting default values on the custom resource of the 54 | // Kind Pod when those are created or updated. 55 | type PodNetbirdInjector struct { 56 | client client.Client 57 | managementURL string 58 | clientImage string 59 | } 60 | 61 | var _ webhook.CustomDefaulter = &PodNetbirdInjector{} 62 | 63 | // Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind Pod. 64 | func (d *PodNetbirdInjector) Default(ctx context.Context, obj runtime.Object) error { 65 | pod, ok := obj.(*corev1.Pod) 66 | if !ok { 67 | return fmt.Errorf("expected a Pod object but got %T", obj) 68 | } 69 | podlog.Info("Defaulting for Pod", "name", pod.GetName()) 70 | 71 | // if the setup key annotation is missing, do nothing. 72 | if pod.Annotations == nil || pod.Annotations[setupKeyAnnotation] == "" { 73 | return nil 74 | } 75 | 76 | // retrieve the NBSetupKey resource 77 | var nbSetupKey netbirdiov1.NBSetupKey 78 | err := d.client.Get(ctx, types.NamespacedName{Namespace: pod.Namespace, Name: pod.Annotations[setupKeyAnnotation]}, &nbSetupKey) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | // ensure the NBSetupKey is ready. 84 | ready := false 85 | for _, c := range nbSetupKey.Status.Conditions { 86 | if c.Type == netbirdiov1.NBSetupKeyReady { 87 | ready = c.Status == corev1.ConditionTrue 88 | } 89 | } 90 | if !ready { 91 | return fmt.Errorf("NBSetupKey is not ready") 92 | } 93 | 94 | managementURL := d.managementURL 95 | if nbSetupKey.Spec.ManagementURL != "" { 96 | managementURL = nbSetupKey.Spec.ManagementURL 97 | } 98 | 99 | // build environment variables 100 | envVars := []corev1.EnvVar{ 101 | { 102 | Name: "NB_SETUP_KEY", 103 | ValueFrom: &corev1.EnvVarSource{ 104 | SecretKeyRef: &nbSetupKey.Spec.SecretKeyRef, 105 | }, 106 | }, 107 | { 108 | Name: "NB_MANAGEMENT_URL", 109 | Value: managementURL, 110 | }, 111 | } 112 | 113 | // check for extra DNS labels in annotations and add as environment variable 114 | if pod.Annotations != nil { 115 | if extra, ok := pod.Annotations["netbird.io/extra-dns-labels"]; ok && extra != "" { 116 | podlog.Info("Found extra DNS labels", "extra", extra) 117 | envVars = append(envVars, corev1.EnvVar{ 118 | Name: "NB_EXTRA_DNS_LABELS", 119 | Value: extra, 120 | }) 121 | } 122 | } 123 | 124 | // Append the netbird container with the constructed env vars. 125 | pod.Spec.Containers = append(pod.Spec.Containers, corev1.Container{ 126 | Name: "netbird", 127 | Image: d.clientImage, 128 | Env: envVars, 129 | SecurityContext: &corev1.SecurityContext{ 130 | Capabilities: &corev1.Capabilities{ 131 | Add: []corev1.Capability{"NET_ADMIN"}, 132 | }, 133 | }, 134 | VolumeMounts: nbSetupKey.Spec.VolumeMounts, 135 | }) 136 | 137 | pod.Spec.Volumes = append(pod.Spec.Volumes, nbSetupKey.Spec.Volumes...) 138 | 139 | return nil 140 | } 141 | -------------------------------------------------------------------------------- /internal/webhook/v1/nbsetupkey_webhook.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | corev1 "k8s.io/api/core/v1" 9 | "k8s.io/apimachinery/pkg/api/errors" 10 | "k8s.io/apimachinery/pkg/runtime" 11 | "k8s.io/apimachinery/pkg/types" 12 | ctrl "sigs.k8s.io/controller-runtime" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | logf "sigs.k8s.io/controller-runtime/pkg/log" 15 | "sigs.k8s.io/controller-runtime/pkg/webhook" 16 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 17 | 18 | "github.com/google/uuid" 19 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 20 | ) 21 | 22 | // nolint:unused 23 | // log is for logging in this package. 24 | var nbsetupkeylog = logf.Log.WithName("nbsetupkey-resource") 25 | 26 | // SetupNBSetupKeyWebhookWithManager registers the webhook for NBSetupKey in the manager. 27 | func SetupNBSetupKeyWebhookWithManager(mgr ctrl.Manager) error { 28 | return ctrl.NewWebhookManagedBy(mgr).For(&netbirdiov1.NBSetupKey{}). 29 | WithValidator(&NBSetupKeyCustomValidator{client: mgr.GetClient()}). 30 | Complete() 31 | } 32 | 33 | // NBSetupKeyCustomValidator struct is responsible for validating the NBSetupKey resource 34 | // when it is created, updated, or deleted. 35 | type NBSetupKeyCustomValidator struct { 36 | client client.Client 37 | } 38 | 39 | var _ webhook.CustomValidator = &NBSetupKeyCustomValidator{} 40 | 41 | // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type NBSetupKey. 42 | func (v *NBSetupKeyCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 43 | nbSetupKey, ok := obj.(*netbirdiov1.NBSetupKey) 44 | if !ok { 45 | return nil, fmt.Errorf("expected a NBSetupKey object but got %T", obj) 46 | } 47 | nbsetupkeylog.Info("Validating NBSetupKey", "namespace", nbSetupKey.Namespace, "name", nbSetupKey.Name) 48 | 49 | if nbSetupKey.Spec.SecretKeyRef.Name == "" { 50 | return nil, fmt.Errorf("spec.secretKeyRef.name is required") 51 | } 52 | 53 | if nbSetupKey.Spec.SecretKeyRef.Key == "" { 54 | return nil, fmt.Errorf("spec.secretKeyRef.key is required") 55 | } 56 | 57 | var secret corev1.Secret 58 | err := v.client.Get(ctx, types.NamespacedName{Namespace: nbSetupKey.Namespace, Name: nbSetupKey.Spec.SecretKeyRef.Name}, &secret) 59 | if err != nil { 60 | if errors.IsNotFound(err) { 61 | return admission.Warnings{fmt.Sprintf("secret %s/%s not found", nbSetupKey.Namespace, nbSetupKey.Spec.SecretKeyRef.Name)}, nil 62 | } 63 | return nil, err 64 | } 65 | 66 | uuidBytes, ok := secret.Data[nbSetupKey.Spec.SecretKeyRef.Key] 67 | if !ok { 68 | return admission.Warnings{fmt.Sprintf("key %s in secret %s/%s not found", nbSetupKey.Spec.SecretKeyRef.Key, nbSetupKey.Namespace, nbSetupKey.Spec.SecretKeyRef.Name)}, nil 69 | } 70 | 71 | _, err = uuid.Parse(string(uuidBytes)) 72 | if err != nil { 73 | return admission.Warnings{fmt.Sprintf("setupkey %s in secret %s/%s is not a valid setup key", nbSetupKey.Spec.SecretKeyRef.Key, nbSetupKey.Namespace, nbSetupKey.Spec.SecretKeyRef.Name)}, nil 74 | } 75 | 76 | return nil, nil 77 | } 78 | 79 | // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type NBSetupKey. 80 | func (v *NBSetupKeyCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { 81 | return v.ValidateCreate(ctx, newObj) 82 | } 83 | 84 | // ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type NBSetupKey. 85 | func (v *NBSetupKeyCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { 86 | nbSetupKey, ok := obj.(*netbirdiov1.NBSetupKey) 87 | if !ok { 88 | return nil, fmt.Errorf("expected a NBSetupKey object but got %T", obj) 89 | } 90 | nbsetupkeylog.Info("Validating NBSetupKey deletion", "namespace", nbSetupKey.Namespace, "name", nbSetupKey.Name) 91 | 92 | var pods corev1.PodList 93 | err := v.client.List(ctx, &pods, client.InNamespace(nbSetupKey.Namespace)) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | //nolint:prealloc 99 | var invalidPods []string 100 | for _, p := range pods.Items { 101 | // If annotation doesn't exist, or doesn't match NBSetupKey being deleted, ignore 102 | if v, ok := p.Annotations[setupKeyAnnotation]; !ok || v != nbSetupKey.Name { 103 | continue 104 | } 105 | invalidPods = append(invalidPods, p.Name) 106 | } 107 | 108 | if len(invalidPods) > 0 { 109 | return nil, fmt.Errorf("NBSetupKey is in-use by %d pods: %s", len(invalidPods), strings.Join(invalidPods, ",")) 110 | } 111 | 112 | return nil, nil 113 | } 114 | -------------------------------------------------------------------------------- /crds/netbird.io_nbsetupkeys.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: nbsetupkeys.netbird.io 8 | spec: 9 | group: netbird.io 10 | names: 11 | kind: NBSetupKey 12 | listKind: NBSetupKeyList 13 | plural: nbsetupkeys 14 | singular: nbsetupkey 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: NBSetupKey is the Schema for the nbsetupkeys API. 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: NBSetupKeySpec defines the desired state of NBSetupKey. 41 | properties: 42 | managementURL: 43 | description: ManagementURL optional, override operator management 44 | URL 45 | type: string 46 | secretKeyRef: 47 | description: SecretKeyRef is a reference to the secret containing 48 | the setup key 49 | properties: 50 | key: 51 | description: The key of the secret to select from. Must be a 52 | valid secret key. 53 | type: string 54 | name: 55 | default: "" 56 | description: |- 57 | Name of the referent. 58 | This field is effectively required, but due to backwards compatibility is 59 | allowed to be empty. Instances of this type with an empty value here are 60 | almost certainly wrong. 61 | More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 62 | type: string 63 | optional: 64 | description: Specify whether the Secret or its key must be defined 65 | type: boolean 66 | required: 67 | - key 68 | type: object 69 | x-kubernetes-map-type: atomic 70 | required: 71 | - secretKeyRef 72 | type: object 73 | status: 74 | description: NBSetupKeyStatus defines the observed state of NBSetupKey. 75 | properties: 76 | conditions: 77 | items: 78 | description: NBCondition defines a condition in NBSetupKey status. 79 | properties: 80 | lastProbeTime: 81 | description: Last time we probed the condition. 82 | format: date-time 83 | type: string 84 | lastTransitionTime: 85 | description: Last time the condition transitioned from one status 86 | to another. 87 | format: date-time 88 | type: string 89 | message: 90 | description: Human-readable message indicating details about 91 | last transition. 92 | type: string 93 | reason: 94 | description: Unique, one-word, CamelCase reason for the condition's 95 | last transition. 96 | type: string 97 | status: 98 | description: |- 99 | Status is the status of the condition. 100 | Can be True, False, Unknown. 101 | type: string 102 | type: 103 | description: Type is the type of the condition. 104 | type: string 105 | required: 106 | - status 107 | - type 108 | type: object 109 | type: array 110 | type: object 111 | type: object 112 | served: true 113 | storage: true 114 | subresources: 115 | status: {} 116 | -------------------------------------------------------------------------------- /crds/netbird.io_nbpolicies.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: nbpolicies.netbird.io 8 | spec: 9 | group: netbird.io 10 | names: 11 | kind: NBPolicy 12 | listKind: NBPolicyList 13 | plural: nbpolicies 14 | singular: nbpolicy 15 | scope: Cluster 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: NBPolicy is the Schema for the nbpolicies API. 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: NBPolicySpec defines the desired state of NBPolicy. 41 | properties: 42 | bidirectional: 43 | default: true 44 | type: boolean 45 | description: 46 | type: string 47 | destinationGroups: 48 | items: 49 | minLength: 1 50 | type: string 51 | type: array 52 | name: 53 | description: Name Policy name 54 | minLength: 1 55 | type: string 56 | ports: 57 | items: 58 | format: int32 59 | maximum: 65535 60 | minimum: 0 61 | type: integer 62 | type: array 63 | protocols: 64 | items: 65 | enum: 66 | - tcp 67 | - udp 68 | type: string 69 | type: array 70 | sourceGroups: 71 | items: 72 | minLength: 1 73 | type: string 74 | type: array 75 | required: 76 | - name 77 | type: object 78 | status: 79 | description: NBPolicyStatus defines the observed state of NBPolicy. 80 | properties: 81 | conditions: 82 | items: 83 | description: NBCondition defines a condition in NBSetupKey status. 84 | properties: 85 | lastProbeTime: 86 | description: Last time we probed the condition. 87 | format: date-time 88 | type: string 89 | lastTransitionTime: 90 | description: Last time the condition transitioned from one status 91 | to another. 92 | format: date-time 93 | type: string 94 | message: 95 | description: Human-readable message indicating details about 96 | last transition. 97 | type: string 98 | reason: 99 | description: Unique, one-word, CamelCase reason for the condition's 100 | last transition. 101 | type: string 102 | status: 103 | description: |- 104 | Status is the status of the condition. 105 | Can be True, False, Unknown. 106 | type: string 107 | type: 108 | description: Type is the type of the condition. 109 | type: string 110 | required: 111 | - status 112 | - type 113 | type: object 114 | type: array 115 | lastUpdatedAt: 116 | format: date-time 117 | type: string 118 | managedServiceList: 119 | items: 120 | type: string 121 | type: array 122 | tcpPolicyID: 123 | type: string 124 | udpPolicyID: 125 | type: string 126 | type: object 127 | type: object 128 | served: true 129 | storage: true 130 | subresources: 131 | status: {} 132 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/crds/netbird.io_nbpolicies.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: nbpolicies.netbird.io 8 | spec: 9 | group: netbird.io 10 | names: 11 | kind: NBPolicy 12 | listKind: NBPolicyList 13 | plural: nbpolicies 14 | singular: nbpolicy 15 | scope: Cluster 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: NBPolicy is the Schema for the nbpolicies API. 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: NBPolicySpec defines the desired state of NBPolicy. 41 | properties: 42 | bidirectional: 43 | default: true 44 | type: boolean 45 | description: 46 | type: string 47 | destinationGroups: 48 | items: 49 | minLength: 1 50 | type: string 51 | type: array 52 | name: 53 | description: Name Policy name 54 | minLength: 1 55 | type: string 56 | ports: 57 | items: 58 | format: int32 59 | maximum: 65535 60 | minimum: 0 61 | type: integer 62 | type: array 63 | protocols: 64 | items: 65 | enum: 66 | - tcp 67 | - udp 68 | type: string 69 | type: array 70 | sourceGroups: 71 | items: 72 | minLength: 1 73 | type: string 74 | type: array 75 | required: 76 | - name 77 | type: object 78 | status: 79 | description: NBPolicyStatus defines the observed state of NBPolicy. 80 | properties: 81 | conditions: 82 | items: 83 | description: NBCondition defines a condition in NBSetupKey status. 84 | properties: 85 | lastProbeTime: 86 | description: Last time we probed the condition. 87 | format: date-time 88 | type: string 89 | lastTransitionTime: 90 | description: Last time the condition transitioned from one status 91 | to another. 92 | format: date-time 93 | type: string 94 | message: 95 | description: Human-readable message indicating details about 96 | last transition. 97 | type: string 98 | reason: 99 | description: Unique, one-word, CamelCase reason for the condition's 100 | last transition. 101 | type: string 102 | status: 103 | description: |- 104 | Status is the status of the condition. 105 | Can be True, False, Unknown. 106 | type: string 107 | type: 108 | description: Type is the type of the condition. 109 | type: string 110 | required: 111 | - status 112 | - type 113 | type: object 114 | type: array 115 | lastUpdatedAt: 116 | format: date-time 117 | type: string 118 | managedServiceList: 119 | items: 120 | type: string 121 | type: array 122 | tcpPolicyID: 123 | type: string 124 | udpPolicyID: 125 | type: string 126 | type: object 127 | type: object 128 | served: true 129 | storage: true 130 | subresources: 131 | status: {} 132 | -------------------------------------------------------------------------------- /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 | "fmt" 21 | "os" 22 | "os/exec" 23 | "strings" 24 | 25 | . "github.com/onsi/ginkgo/v2" //nolint:golint,revive 26 | ) 27 | 28 | const ( 29 | certmanagerVersion = "v1.16.3" 30 | certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml" 31 | ) 32 | 33 | func warnError(err error) { 34 | _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) 35 | } 36 | 37 | // Run executes the provided command within this context 38 | func Run(cmd *exec.Cmd) (string, error) { 39 | dir, _ := GetProjectDir() 40 | cmd.Dir = dir 41 | 42 | if err := os.Chdir(cmd.Dir); err != nil { 43 | _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) 44 | } 45 | 46 | cmd.Env = append(os.Environ(), "GO111MODULE=on") 47 | command := strings.Join(cmd.Args, " ") 48 | _, _ = fmt.Fprintf(GinkgoWriter, "running: %s\n", command) 49 | output, err := cmd.CombinedOutput() 50 | if err != nil { 51 | return string(output), fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) 52 | } 53 | 54 | return string(output), nil 55 | } 56 | 57 | // UninstallCertManager uninstalls the cert manager 58 | func UninstallCertManager() { 59 | url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) 60 | cmd := exec.Command("kubectl", "delete", "-f", url) 61 | if _, err := Run(cmd); err != nil { 62 | warnError(err) 63 | } 64 | 65 | cmd = exec.Command( 66 | "kubectl", "delete", "--ignore-not-found", 67 | "leases.coordination.k8s.io", 68 | "-n", "kube-system", 69 | "cert-manager-controller", "cert-manager-cainjector-leader-election", 70 | ) 71 | 72 | if _, err := Run(cmd); err != nil { 73 | warnError(err) 74 | } 75 | } 76 | 77 | // InstallCertManager installs the cert manager bundle. 78 | func InstallCertManager() error { 79 | url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) 80 | cmd := exec.Command("kubectl", "apply", "-f", url) 81 | if _, err := Run(cmd); err != nil { 82 | return err 83 | } 84 | // Wait for cert-manager-webhook to be ready, which can take time if cert-manager 85 | // was re-installed after uninstalling on a cluster. 86 | cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", 87 | "--for", "condition=Available", 88 | "--namespace", "cert-manager", 89 | "--timeout", "5m", 90 | ) 91 | 92 | _, err := Run(cmd) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | cmd = exec.Command("kubectl", "wait", "leases/cert-manager-controller", 98 | "--for", "jsonpath={.spec.holderIdentity}", 99 | "-n", "kube-system", 100 | "--timeout", "5m", 101 | ) 102 | 103 | _, err = Run(cmd) 104 | return err 105 | } 106 | 107 | // IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed 108 | // by verifying the existence of key CRDs related to Cert Manager. 109 | func IsCertManagerCRDsInstalled() bool { 110 | // List of common Cert Manager CRDs 111 | certManagerCRDs := []string{ 112 | "certificates.cert-manager.io", 113 | "issuers.cert-manager.io", 114 | "clusterissuers.cert-manager.io", 115 | "certificaterequests.cert-manager.io", 116 | "orders.acme.cert-manager.io", 117 | "challenges.acme.cert-manager.io", 118 | } 119 | 120 | // Execute the kubectl command to get all CRDs 121 | cmd := exec.Command("kubectl", "get", "crds") 122 | output, err := Run(cmd) 123 | if err != nil { 124 | return false 125 | } 126 | 127 | // Check if any of the Cert Manager CRDs are present 128 | crdList := GetNonEmptyLines(output) 129 | for _, crd := range certManagerCRDs { 130 | for _, line := range crdList { 131 | if strings.Contains(line, crd) { 132 | return true 133 | } 134 | } 135 | } 136 | 137 | return false 138 | } 139 | 140 | // LoadImageToKindClusterWithName loads a local docker image to the kind cluster 141 | func LoadImageToKindClusterWithName(name string) error { 142 | cluster := "kind" 143 | if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { 144 | cluster = v 145 | } 146 | kindOptions := []string{"load", "docker-image", name, "--name", cluster} 147 | cmd := exec.Command("kind", kindOptions...) 148 | _, err := Run(cmd) 149 | return err 150 | } 151 | 152 | // GetNonEmptyLines converts given command output string into individual objects 153 | // according to line breakers, and ignores the empty elements in it. 154 | func GetNonEmptyLines(output string) []string { 155 | var res []string 156 | elements := strings.Split(output, "\n") 157 | for _, element := range elements { 158 | if element != "" { 159 | res = append(res, element) 160 | } 161 | } 162 | 163 | return res 164 | } 165 | 166 | // GetProjectDir will return the directory where the project is 167 | func GetProjectDir() (string, error) { 168 | wd, err := os.Getwd() 169 | if err != nil { 170 | return wd, err 171 | } 172 | wd = strings.Replace(wd, "/test/e2e", "", -1) 173 | return wd, nil 174 | } 175 | -------------------------------------------------------------------------------- /internal/webhook/v1/nbgroup_webhook_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | "k8s.io/apimachinery/pkg/api/errors" 7 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/types" 9 | 10 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 11 | ) 12 | 13 | var _ = Describe("NBGroup Webhook", func() { 14 | var ( 15 | obj *netbirdiov1.NBGroup 16 | oldObj *netbirdiov1.NBGroup 17 | validator NBGroupCustomValidator 18 | ) 19 | 20 | BeforeEach(func() { 21 | obj = &netbirdiov1.NBGroup{} 22 | oldObj = &netbirdiov1.NBGroup{} 23 | validator = NBGroupCustomValidator{ 24 | client: k8sClient, 25 | } 26 | Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") 27 | Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") 28 | Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") 29 | }) 30 | 31 | AfterEach(func() { 32 | }) 33 | 34 | Context("When creating or updating NBGroup under Validating Webhook", func() { 35 | It("should allow creation", func() { 36 | Expect(validator.ValidateCreate(ctx, obj)).Error().NotTo(HaveOccurred()) 37 | }) 38 | It("should allow update", func() { 39 | Expect(validator.ValidateUpdate(ctx, oldObj, obj)).Error().NotTo(HaveOccurred()) 40 | }) 41 | When("There are no owners", func() { 42 | It("should allow deletion", func() { 43 | obj = &netbirdiov1.NBGroup{ 44 | ObjectMeta: v1.ObjectMeta{ 45 | Name: "test", 46 | Namespace: "default", 47 | OwnerReferences: nil, 48 | }, 49 | } 50 | Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) 51 | }) 52 | }) 53 | When("There deleted owners", func() { 54 | It("should allow deletion", func() { 55 | obj = &netbirdiov1.NBGroup{ 56 | ObjectMeta: v1.ObjectMeta{ 57 | Name: "test", 58 | Namespace: "default", 59 | OwnerReferences: []v1.OwnerReference{ 60 | { 61 | APIVersion: netbirdiov1.GroupVersion.Identifier(), 62 | Kind: "NBResource", 63 | Name: "notexist", 64 | UID: obj.UID, 65 | }, 66 | }, 67 | }, 68 | } 69 | Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) 70 | }) 71 | }) 72 | When("NBResource owner exists", func() { 73 | BeforeEach(func() { 74 | nbResource := &netbirdiov1.NBResource{ 75 | ObjectMeta: v1.ObjectMeta{ 76 | Name: "isexist", 77 | Namespace: "default", 78 | }, 79 | Spec: netbirdiov1.NBResourceSpec{ 80 | Name: "test1", 81 | NetworkID: "test2", 82 | Address: "test3", 83 | Groups: []string{"test"}, 84 | }, 85 | } 86 | 87 | Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) 88 | 89 | obj = &netbirdiov1.NBGroup{ 90 | ObjectMeta: v1.ObjectMeta{ 91 | Name: "test", 92 | Namespace: "default", 93 | OwnerReferences: []v1.OwnerReference{ 94 | { 95 | APIVersion: netbirdiov1.GroupVersion.Identifier(), 96 | Kind: nbResource.Kind, 97 | Name: nbResource.Name, 98 | UID: nbResource.UID, 99 | }, 100 | }, 101 | }, 102 | } 103 | }) 104 | AfterEach(func() { 105 | nbResource := &netbirdiov1.NBResource{} 106 | err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "isexist"}, nbResource) 107 | if !errors.IsNotFound(err) { 108 | Expect(err).NotTo(HaveOccurred()) 109 | if len(nbResource.Finalizers) > 0 { 110 | nbResource.Finalizers = nil 111 | Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) 112 | } 113 | err = k8sClient.Delete(ctx, nbResource) 114 | if !errors.IsNotFound(err) { 115 | Expect(err).NotTo(HaveOccurred()) 116 | } 117 | } 118 | }) 119 | It("should deny deletion", func() { 120 | Expect(validator.ValidateDelete(ctx, obj)).Error().To(HaveOccurred()) 121 | }) 122 | }) 123 | When("NBRoutingPeer owner exists", func() { 124 | BeforeEach(func() { 125 | nbrp := &netbirdiov1.NBRoutingPeer{ 126 | ObjectMeta: v1.ObjectMeta{ 127 | Name: "isexist", 128 | Namespace: "default", 129 | }, 130 | Spec: netbirdiov1.NBRoutingPeerSpec{}, 131 | } 132 | 133 | Expect(k8sClient.Create(ctx, nbrp)).To(Succeed()) 134 | 135 | obj = &netbirdiov1.NBGroup{ 136 | ObjectMeta: v1.ObjectMeta{ 137 | Name: "test", 138 | Namespace: "default", 139 | OwnerReferences: []v1.OwnerReference{ 140 | { 141 | APIVersion: netbirdiov1.GroupVersion.Identifier(), 142 | Kind: nbrp.Kind, 143 | Name: nbrp.Name, 144 | UID: nbrp.UID, 145 | }, 146 | }, 147 | }, 148 | } 149 | }) 150 | AfterEach(func() { 151 | nbrp := &netbirdiov1.NBRoutingPeer{} 152 | err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "isexist"}, nbrp) 153 | if !errors.IsNotFound(err) { 154 | Expect(err).NotTo(HaveOccurred()) 155 | if len(nbrp.Finalizers) > 0 { 156 | nbrp.Finalizers = nil 157 | Expect(k8sClient.Update(ctx, nbrp)).To(Succeed()) 158 | } 159 | err = k8sClient.Delete(ctx, nbrp) 160 | if !errors.IsNotFound(err) { 161 | Expect(err).NotTo(HaveOccurred()) 162 | } 163 | } 164 | }) 165 | It("should deny deletion", func() { 166 | Expect(validator.ValidateDelete(ctx, obj)).Error().To(HaveOccurred()) 167 | }) 168 | }) 169 | }) 170 | 171 | }) 172 | -------------------------------------------------------------------------------- /crds/netbird.io_nbresources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: nbresources.netbird.io 8 | spec: 9 | group: netbird.io 10 | names: 11 | kind: NBResource 12 | listKind: NBResourceList 13 | plural: nbresources 14 | singular: nbresource 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: NBResource is the Schema for the nbresources API. 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: NBResourceSpec defines the desired state of NBResource. 41 | properties: 42 | address: 43 | minLength: 1 44 | type: string 45 | groups: 46 | items: 47 | minLength: 1 48 | type: string 49 | type: array 50 | name: 51 | minLength: 1 52 | type: string 53 | networkID: 54 | type: string 55 | x-kubernetes-validations: 56 | - message: Value is immutable 57 | rule: self == oldSelf 58 | policyFriendlyName: 59 | additionalProperties: 60 | type: string 61 | type: object 62 | policyName: 63 | type: string 64 | policySourceGroups: 65 | items: 66 | type: string 67 | type: array 68 | tcpPorts: 69 | items: 70 | format: int32 71 | type: integer 72 | type: array 73 | udpPorts: 74 | items: 75 | format: int32 76 | type: integer 77 | type: array 78 | required: 79 | - address 80 | - groups 81 | - name 82 | - networkID 83 | type: object 84 | status: 85 | description: NBResourceStatus defines the observed state of NBResource. 86 | properties: 87 | conditions: 88 | items: 89 | description: NBCondition defines a condition in NBSetupKey status. 90 | properties: 91 | lastProbeTime: 92 | description: Last time we probed the condition. 93 | format: date-time 94 | type: string 95 | lastTransitionTime: 96 | description: Last time the condition transitioned from one status 97 | to another. 98 | format: date-time 99 | type: string 100 | message: 101 | description: Human-readable message indicating details about 102 | last transition. 103 | type: string 104 | reason: 105 | description: Unique, one-word, CamelCase reason for the condition's 106 | last transition. 107 | type: string 108 | status: 109 | description: |- 110 | Status is the status of the condition. 111 | Can be True, False, Unknown. 112 | type: string 113 | type: 114 | description: Type is the type of the condition. 115 | type: string 116 | required: 117 | - status 118 | - type 119 | type: object 120 | type: array 121 | groups: 122 | items: 123 | type: string 124 | type: array 125 | networkResourceID: 126 | type: string 127 | policyFriendlyName: 128 | additionalProperties: 129 | type: string 130 | type: object 131 | policyName: 132 | type: string 133 | policyNameMapping: 134 | additionalProperties: 135 | type: string 136 | type: object 137 | policySourceGroups: 138 | items: 139 | type: string 140 | type: array 141 | tcpPorts: 142 | items: 143 | format: int32 144 | type: integer 145 | type: array 146 | udpPorts: 147 | items: 148 | format: int32 149 | type: integer 150 | type: array 151 | type: object 152 | type: object 153 | served: true 154 | storage: true 155 | subresources: 156 | status: {} 157 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/crds/netbird.io_nbresources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: nbresources.netbird.io 8 | spec: 9 | group: netbird.io 10 | names: 11 | kind: NBResource 12 | listKind: NBResourceList 13 | plural: nbresources 14 | singular: nbresource 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: NBResource is the Schema for the nbresources API. 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: NBResourceSpec defines the desired state of NBResource. 41 | properties: 42 | address: 43 | minLength: 1 44 | type: string 45 | groups: 46 | items: 47 | minLength: 1 48 | type: string 49 | type: array 50 | name: 51 | minLength: 1 52 | type: string 53 | networkID: 54 | type: string 55 | x-kubernetes-validations: 56 | - message: Value is immutable 57 | rule: self == oldSelf 58 | policyFriendlyName: 59 | additionalProperties: 60 | type: string 61 | type: object 62 | policyName: 63 | type: string 64 | policySourceGroups: 65 | items: 66 | type: string 67 | type: array 68 | tcpPorts: 69 | items: 70 | format: int32 71 | type: integer 72 | type: array 73 | udpPorts: 74 | items: 75 | format: int32 76 | type: integer 77 | type: array 78 | required: 79 | - address 80 | - groups 81 | - name 82 | - networkID 83 | type: object 84 | status: 85 | description: NBResourceStatus defines the observed state of NBResource. 86 | properties: 87 | conditions: 88 | items: 89 | description: NBCondition defines a condition in NBSetupKey status. 90 | properties: 91 | lastProbeTime: 92 | description: Last time we probed the condition. 93 | format: date-time 94 | type: string 95 | lastTransitionTime: 96 | description: Last time the condition transitioned from one status 97 | to another. 98 | format: date-time 99 | type: string 100 | message: 101 | description: Human-readable message indicating details about 102 | last transition. 103 | type: string 104 | reason: 105 | description: Unique, one-word, CamelCase reason for the condition's 106 | last transition. 107 | type: string 108 | status: 109 | description: |- 110 | Status is the status of the condition. 111 | Can be True, False, Unknown. 112 | type: string 113 | type: 114 | description: Type is the type of the condition. 115 | type: string 116 | required: 117 | - status 118 | - type 119 | type: object 120 | type: array 121 | groups: 122 | items: 123 | type: string 124 | type: array 125 | networkResourceID: 126 | type: string 127 | policyFriendlyName: 128 | additionalProperties: 129 | type: string 130 | type: object 131 | policyName: 132 | type: string 133 | policyNameMapping: 134 | additionalProperties: 135 | type: string 136 | type: object 137 | policySourceGroups: 138 | items: 139 | type: string 140 | type: array 141 | tcpPorts: 142 | items: 143 | format: int32 144 | type: integer 145 | type: array 146 | udpPorts: 147 | items: 148 | format: int32 149 | type: integer 150 | type: array 151 | type: object 152 | type: object 153 | served: true 154 | storage: true 155 | subresources: 156 | status: {} 157 | -------------------------------------------------------------------------------- /internal/webhook/v1/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 v1 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 | admissionv1 "k8s.io/api/admission/v1" 33 | k8siov1 "k8s.io/api/core/v1" 34 | apimachineryruntime "k8s.io/apimachinery/pkg/runtime" 35 | "k8s.io/client-go/rest" 36 | ctrl "sigs.k8s.io/controller-runtime" 37 | "sigs.k8s.io/controller-runtime/pkg/client" 38 | "sigs.k8s.io/controller-runtime/pkg/envtest" 39 | logf "sigs.k8s.io/controller-runtime/pkg/log" 40 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 41 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 42 | "sigs.k8s.io/controller-runtime/pkg/webhook" 43 | 44 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 45 | // +kubebuilder:scaffold:imports 46 | ) 47 | 48 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 49 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 50 | 51 | var ( 52 | ctx context.Context 53 | cancel context.CancelFunc 54 | k8sClient client.Client 55 | cfg *rest.Config 56 | testEnv *envtest.Environment 57 | ) 58 | 59 | func TestAPIs(t *testing.T) { 60 | RegisterFailHandler(Fail) 61 | 62 | RunSpecs(t, "Webhook Suite") 63 | } 64 | 65 | var _ = BeforeSuite(func() { 66 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 67 | 68 | ctx, cancel = context.WithCancel(context.TODO()) 69 | 70 | var err error 71 | scheme := apimachineryruntime.NewScheme() 72 | err = k8siov1.AddToScheme(scheme) 73 | Expect(err).NotTo(HaveOccurred()) 74 | 75 | err = admissionv1.AddToScheme(scheme) 76 | Expect(err).NotTo(HaveOccurred()) 77 | 78 | err = netbirdiov1.AddToScheme(scheme) 79 | Expect(err).NotTo(HaveOccurred()) 80 | 81 | // +kubebuilder:scaffold:scheme 82 | 83 | By("bootstrapping test environment") 84 | testEnv = &envtest.Environment{ 85 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "helm", "kubernetes-operator", "crds")}, 86 | ErrorIfCRDPathMissing: false, 87 | 88 | // WebhookInstallOptions: envtest.WebhookInstallOptions{ 89 | // Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, 90 | // }, 91 | } 92 | 93 | // Retrieve the first found binary directory to allow running tests from IDEs 94 | if getFirstFoundEnvTestBinaryDir() != "" { 95 | testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() 96 | } 97 | 98 | // cfg is defined in this file globally. 99 | cfg, err = testEnv.Start() 100 | Expect(err).NotTo(HaveOccurred()) 101 | Expect(cfg).NotTo(BeNil()) 102 | 103 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme}) 104 | Expect(err).NotTo(HaveOccurred()) 105 | Expect(k8sClient).NotTo(BeNil()) 106 | 107 | // start webhook server using Manager. 108 | webhookInstallOptions := &testEnv.WebhookInstallOptions 109 | mgr, err := ctrl.NewManager(cfg, ctrl.Options{ 110 | Scheme: scheme, 111 | WebhookServer: webhook.NewServer(webhook.Options{ 112 | Host: webhookInstallOptions.LocalServingHost, 113 | Port: webhookInstallOptions.LocalServingPort, 114 | CertDir: webhookInstallOptions.LocalServingCertDir, 115 | }), 116 | LeaderElection: false, 117 | Metrics: metricsserver.Options{BindAddress: "0"}, 118 | }) 119 | Expect(err).NotTo(HaveOccurred()) 120 | 121 | err = SetupPodWebhookWithManager(mgr, "", "") 122 | Expect(err).NotTo(HaveOccurred()) 123 | 124 | err = SetupNBSetupKeyWebhookWithManager(mgr) 125 | Expect(err).NotTo(HaveOccurred()) 126 | 127 | err = SetupNBGroupWebhookWithManager(mgr) 128 | Expect(err).NotTo(HaveOccurred()) 129 | 130 | // +kubebuilder:scaffold:webhook 131 | 132 | go func() { 133 | defer GinkgoRecover() 134 | err = mgr.Start(ctx) 135 | Expect(err).NotTo(HaveOccurred()) 136 | }() 137 | 138 | // wait for the webhook server to get ready. 139 | dialer := &net.Dialer{Timeout: time.Second} 140 | addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) 141 | Eventually(func() error { 142 | conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | return conn.Close() 148 | }).Should(Succeed()) 149 | }) 150 | 151 | var _ = AfterSuite(func() { 152 | By("tearing down the test environment") 153 | cancel() 154 | err := testEnv.Stop() 155 | Expect(err).NotTo(HaveOccurred()) 156 | }) 157 | 158 | // getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. 159 | // ENVTEST-based tests depend on specific binaries, usually located in paths set by 160 | // controller-runtime. When running tests directly (e.g., via an IDE) without using 161 | // Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. 162 | // 163 | // This function streamlines the process by finding the required binaries, similar to 164 | // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are 165 | // properly set up, run 'make setup-envtest' beforehand. 166 | func getFirstFoundEnvTestBinaryDir() string { 167 | basePath := filepath.Join("..", "..", "..", "bin", "k8s") 168 | entries, err := os.ReadDir(basePath) 169 | if err != nil { 170 | logf.Log.Error(err, "Failed to read directory", "path", basePath) 171 | return "" 172 | } 173 | for _, entry := range entries { 174 | if entry.IsDir() { 175 | return filepath.Join(basePath, entry.Name()) 176 | } 177 | } 178 | return "" 179 | } 180 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/webhook.yaml: -------------------------------------------------------------------------------- 1 | {{ $tls := fromYaml ( include "kubernetes-operator.webhookCerts" . ) }} 2 | --- 3 | apiVersion: admissionregistration.k8s.io/v1 4 | kind: MutatingWebhookConfiguration 5 | metadata: 6 | {{- if $.Values.webhook.enableCertManager }} 7 | annotations: 8 | cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ template "kubernetes-operator.fullname" . }}-serving-cert 9 | {{- end }} 10 | name: {{ include "kubernetes-operator.fullname" . }}-mpod-webhook 11 | labels: 12 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 13 | webhooks: 14 | - clientConfig: 15 | {{- if not $.Values.webhook.enableCertManager }} 16 | caBundle: {{ $tls.caCert }} 17 | {{ end }} 18 | service: 19 | name: {{ template "kubernetes-operator.webhookService" . }} 20 | namespace: {{ $.Release.Namespace }} 21 | path: /mutate--v1-pod 22 | failurePolicy: {{ .Values.webhook.failurePolicy }} 23 | name: mpod-v1.netbird.io 24 | admissionReviewVersions: 25 | - v1 26 | {{- if .Values.webhook.namespaceSelectors }} 27 | namespaceSelector: 28 | matchExpressions: 29 | {{ toYaml .Values.webhook.namespaceSelectors | nindent 4 }} 30 | {{ end }} 31 | objectSelector: 32 | matchExpressions: 33 | - key: app.kubernetes.io/name 34 | operator: NotIn 35 | values: 36 | - {{ include "kubernetes-operator.name" . }} 37 | {{- if .Values.webhook.objectSelector.matchExpressions }} 38 | {{- toYaml .Values.webhook.objectSelector.matchExpressions | nindent 4 }} 39 | {{- end }} 40 | {{- if .Values.webhook.objectSelector.matchLabels }} 41 | matchLabels: 42 | {{- toYaml .Values.webhook.objectSelector.matchLabels | nindent 6 }} 43 | {{- end }} 44 | rules: 45 | - apiGroups: 46 | - "" 47 | apiVersions: 48 | - v1 49 | operations: 50 | - CREATE 51 | resources: 52 | - pods 53 | sideEffects: None 54 | --- 55 | apiVersion: admissionregistration.k8s.io/v1 56 | kind: ValidatingWebhookConfiguration 57 | metadata: 58 | {{- if $.Values.webhook.enableCertManager }} 59 | annotations: 60 | cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ template "kubernetes-operator.fullname" . }}-serving-cert 61 | {{- end }} 62 | name: {{ include "kubernetes-operator.fullname" . }}-vnbsetupkey-webhook 63 | labels: 64 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 65 | webhooks: 66 | - clientConfig: 67 | {{- if not $.Values.webhook.enableCertManager }} 68 | caBundle: {{ $tls.caCert }} 69 | {{ end }} 70 | service: 71 | name: {{ template "kubernetes-operator.webhookService" . }} 72 | namespace: {{ $.Release.Namespace }} 73 | path: /validate-netbird-io-v1-nbsetupkey 74 | failurePolicy: {{ .Values.webhook.failurePolicy }} 75 | name: vnbsetupkey-v1.netbird.io 76 | admissionReviewVersions: 77 | - v1 78 | {{- if .Values.webhook.namespaceSelectors }} 79 | namespaceSelector: 80 | matchExpressions: 81 | {{ toYaml .Values.webhook.namespaceSelectors | nindent 4 }} 82 | {{ end }} 83 | rules: 84 | - apiGroups: 85 | - netbird.io 86 | apiVersions: 87 | - v1 88 | operations: 89 | - CREATE 90 | - UPDATE 91 | resources: 92 | - "nbsetupkeys" 93 | sideEffects: None 94 | {{- if and $.Values.ingress.enabled (or .Values.netbirdAPI.key .Values.netbirdAPI.keyFromSecret) }} 95 | --- 96 | apiVersion: admissionregistration.k8s.io/v1 97 | kind: ValidatingWebhookConfiguration 98 | metadata: 99 | {{- if $.Values.webhook.enableCertManager }} 100 | annotations: 101 | cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ template "kubernetes-operator.fullname" . }}-serving-cert 102 | {{- end }} 103 | name: {{ include "kubernetes-operator.fullname" . }}-vnbgroup-webhook 104 | labels: 105 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 106 | webhooks: 107 | - clientConfig: 108 | {{- if not $.Values.webhook.enableCertManager }} 109 | caBundle: {{ $tls.caCert }} 110 | {{ end }} 111 | service: 112 | name: {{ template "kubernetes-operator.webhookService" . }} 113 | namespace: {{ $.Release.Namespace }} 114 | path: /validate-netbird-io-v1-nbgroup 115 | failurePolicy: {{ .Values.webhook.failurePolicy }} 116 | name: vnbgroup-v1.netbird.io 117 | admissionReviewVersions: 118 | - v1 119 | {{- if .Values.webhook.namespaceSelectors }} 120 | namespaceSelector: 121 | matchExpressions: 122 | {{ toYaml .Values.webhook.namespaceSelectors | nindent 4 }} 123 | {{ end }} 124 | rules: 125 | - apiGroups: 126 | - netbird.io 127 | apiVersions: 128 | - v1 129 | operations: 130 | - DELETE 131 | resources: 132 | - "nbgroups" 133 | sideEffects: None 134 | {{- end }} 135 | --- 136 | {{- if not $.Values.webhook.enableCertManager }} 137 | apiVersion: v1 138 | kind: Secret 139 | metadata: 140 | name: {{ template "kubernetes-operator.webhookCertSecret" . }} 141 | namespace: {{ .Release.Namespace }} 142 | labels: 143 | {{ include "kubernetes-operator.labels" . | indent 4 }} 144 | type: kubernetes.io/tls 145 | data: 146 | ca.crt: {{ $tls.caCert }} 147 | tls.crt: {{ $tls.clientCert }} 148 | tls.key: {{ $tls.clientKey }} 149 | {{- else }} 150 | apiVersion: cert-manager.io/v1 151 | kind: Certificate 152 | metadata: 153 | name: {{ template "kubernetes-operator.fullname" . }}-serving-cert 154 | namespace: {{ .Release.Namespace }} 155 | labels: 156 | {{ include "kubernetes-operator.labels" . | indent 4 }} 157 | spec: 158 | dnsNames: 159 | - {{ template "kubernetes-operator.webhookService" . }}.{{ .Release.Namespace }}.svc 160 | - {{ template "kubernetes-operator.webhookService" . }}.{{ .Release.Namespace }}.{{ .Values.cluster.dns }} 161 | issuerRef: 162 | kind: Issuer 163 | name: {{ template "kubernetes-operator.fullname" . }}-selfsigned-issuer 164 | secretName: {{ template "kubernetes-operator.webhookCertSecret" . }} 165 | --- 166 | apiVersion: cert-manager.io/v1 167 | kind: Issuer 168 | metadata: 169 | name: {{ template "kubernetes-operator.fullname" . }}-selfsigned-issuer 170 | namespace: {{ .Release.Namespace }} 171 | labels: 172 | {{ include "kubernetes-operator.labels" . | indent 4 }} 173 | spec: 174 | selfSigned: {} 175 | {{- end }} -------------------------------------------------------------------------------- /helm/kubernetes-operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "kubernetes-operator.fullname" . }} 5 | labels: 6 | app.kubernetes.io/component: operator 7 | {{- include "kubernetes-operator.labels" . | nindent 4 }} 8 | spec: 9 | replicas: {{ .Values.operator.replicaCount }} 10 | selector: 11 | matchLabels: 12 | {{- include "kubernetes-operator.selectorLabels" . | nindent 6 }} 13 | template: 14 | metadata: 15 | {{- with .Values.operator.podAnnotations }} 16 | annotations: 17 | {{- toYaml . | nindent 8 }} 18 | {{- end }} 19 | labels: 20 | app.kubernetes.io/component: operator 21 | {{- include "kubernetes-operator.labels" . | nindent 8 }} 22 | {{- with .Values.operator.podLabels }} 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | spec: 26 | {{- with .Values.operator.imagePullSecrets }} 27 | imagePullSecrets: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | serviceAccountName: {{ include "kubernetes-operator.serviceAccountName" . }} 31 | securityContext: 32 | {{- toYaml .Values.operator.podSecurityContext | nindent 8 }} 33 | containers: 34 | - name: {{ .Chart.Name }} 35 | securityContext: 36 | {{- toYaml .Values.operator.securityContext | nindent 12 }} 37 | image: "{{ .Values.operator.image.registry }}/{{ .Values.operator.image.repository }}:{{ .Values.operator.image.tag | default .Chart.AppVersion }}" 38 | imagePullPolicy: {{ .Values.operator.image.pullPolicy }} 39 | command: 40 | - /manager 41 | args: 42 | {{- if .Values.operator.metrics.enabled }} 43 | - --metrics-bind-address=:{{ .Values.operator.metrics.port}} 44 | {{- end }} 45 | - --leader-elect 46 | - --health-probe-bind-address=:{{ .Values.operator.livenessProbe.port }} 47 | - --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs 48 | {{- if .Values.managementURL }} 49 | - --netbird-management-url={{.Values.managementURL}} 50 | {{- end }} 51 | {{- if .Values.cluster.name }} 52 | - --cluster-name={{.Values.cluster.name}} 53 | {{- end }} 54 | {{- if .Values.ingress.namespacedNetworks }} 55 | - --namespaced-networks={{.Values.ingress.namespacedNetworks}} 56 | {{- end }} 57 | {{- if .Values.cluster.dns }} 58 | - --cluster-dns={{.Values.cluster.dns}} 59 | {{- end }} 60 | {{- if or .Values.netbirdAPI.key .Values.netbirdAPI.keyFromSecret }} 61 | - --netbird-api-key=$(NB_API_KEY) 62 | {{- end }} 63 | {{- if .Values.ingress.allowAutomaticPolicyCreation }} 64 | - --allow-automatic-policy-creation 65 | {{- end }} 66 | {{- if .Values.routingClientImage }} 67 | - --netbird-client-image={{.Values.routingClientImage}} 68 | {{- end }} 69 | {{- if .Values.general.labels }} 70 | {{- $list := list }} 71 | {{- range $k, $v := .Values.general.labels }} 72 | {{- $list = append $list (printf "%s=%s" $k $v) }} 73 | {{- end }} 74 | - --default-labels="{{ join ", " $list }}" 75 | {{- end }} 76 | ports: 77 | - name: webhook-server 78 | containerPort: {{ .Values.webhook.service.port }} 79 | protocol: TCP 80 | livenessProbe: 81 | failureThreshold: 3 82 | httpGet: 83 | path: /healthz 84 | port: {{ .Values.operator.livenessProbe.port }} 85 | scheme: HTTP 86 | initialDelaySeconds: {{ .Values.operator.livenessProbe.initialDelaySeconds }} 87 | periodSeconds: {{ .Values.operator.livenessProbe.periodSeconds }} 88 | successThreshold: {{ .Values.operator.livenessProbe.successThreshold }} 89 | timeoutSeconds: {{ .Values.operator.livenessProbe.timeoutSeconds }} 90 | {{- if or .Values.netbirdAPI.key .Values.netbirdAPI.keyFromSecret }} 91 | env: 92 | - name: NB_API_KEY 93 | valueFrom: 94 | secretKeyRef: 95 | {{- if .Values.netbirdAPI.keyFromSecret }} 96 | name: {{ .Values.netbirdAPI.keyFromSecret.name }} 97 | key: {{ .Values.netbirdAPI.keyFromSecret.key }} 98 | {{- else }} 99 | name: {{ include "kubernetes-operator.fullname" . }} 100 | key: NB_API_KEY 101 | {{- end }} 102 | {{- end }} 103 | readinessProbe: 104 | failureThreshold: 3 105 | httpGet: 106 | path: /readyz 107 | port: {{ .Values.operator.readinessProbe.port }} 108 | scheme: HTTP 109 | initialDelaySeconds: {{ .Values.operator.readinessProbe.initialDelaySeconds }} 110 | periodSeconds: {{ .Values.operator.readinessProbe.periodSeconds }} 111 | successThreshold: {{ .Values.operator.readinessProbe.successThreshold }} 112 | timeoutSeconds: {{ .Values.operator.readinessProbe.timeoutSeconds }} 113 | resources: 114 | {{- toYaml .Values.operator.resources | nindent 12 }} 115 | volumeMounts: 116 | - mountPath: /tmp/k8s-webhook-server/serving-certs 117 | name: webhook-certs 118 | readOnly: true 119 | {{- with .Values.operator.volumeMounts }} 120 | {{- toYaml . | nindent 12 }} 121 | {{- end }} 122 | volumes: 123 | - name: webhook-certs 124 | secret: 125 | defaultMode: 420 126 | secretName: {{ template "kubernetes-operator.webhookCertSecret" . }} 127 | {{- with .Values.operator.volumes }} 128 | {{- toYaml . | nindent 8 }} 129 | {{- end }} 130 | {{- with .Values.operator.nodeSelector }} 131 | nodeSelector: 132 | {{- toYaml . | nindent 8 }} 133 | {{- end }} 134 | {{- with .Values.operator.affinity }} 135 | affinity: 136 | {{- toYaml . | nindent 8 }} 137 | {{- end }} 138 | {{- with .Values.operator.tolerations }} 139 | tolerations: 140 | {{- toYaml . | nindent 8 }} 141 | {{- end }} 142 | -------------------------------------------------------------------------------- /internal/controller/nbsetupkey_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 | "fmt" 22 | 23 | "github.com/google/uuid" 24 | corev1 "k8s.io/api/core/v1" 25 | "k8s.io/apimachinery/pkg/api/errors" 26 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | "k8s.io/apimachinery/pkg/runtime" 28 | "k8s.io/apimachinery/pkg/types" 29 | ctrl "sigs.k8s.io/controller-runtime" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | "sigs.k8s.io/controller-runtime/pkg/handler" 32 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 33 | 34 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 35 | ) 36 | 37 | // NBSetupKeyReconciler reconciles a NBSetupKey object 38 | type NBSetupKeyReconciler struct { 39 | client.Client 40 | Scheme *runtime.Scheme 41 | ReferencedSecrets map[string]types.NamespacedName 42 | } 43 | 44 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 45 | // move the current state of the cluster closer to the desired state. 46 | func (r *NBSetupKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 47 | logger := ctrl.Log.WithName("NBSetupKey").WithValues("namespace", req.Namespace, "name", req.Name) 48 | logger.Info("Reconciling NBSetupKey") 49 | 50 | nbSetupKey := netbirdiov1.NBSetupKey{} 51 | err := r.Get(ctx, req.NamespacedName, &nbSetupKey) 52 | if err != nil { 53 | logger.Error(fmt.Errorf("internalError"), "error getting NBSetupKey", "err", err) 54 | return ctrl.Result{}, nil 55 | } 56 | 57 | if nbSetupKey.Spec.SecretKeyRef.Name == "" || nbSetupKey.Spec.SecretKeyRef.Key == "" { 58 | logger.Error(fmt.Errorf("invalid NBSetupKey"), "secretKeyRef must contain both secret name and secret key") 59 | return ctrl.Result{}, r.setStatus(ctx, &nbSetupKey, netbirdiov1.NBSetupKeyStatus{ 60 | Conditions: []netbirdiov1.NBCondition{ 61 | { 62 | Type: netbirdiov1.NBSetupKeyReady, 63 | Status: corev1.ConditionFalse, 64 | LastProbeTime: v1.Now(), 65 | Reason: "InvalidConfig", 66 | Message: "secretKeyRef must contain both secret name and secret key.", 67 | }, 68 | }, 69 | }) 70 | } 71 | 72 | // Handle updated secret name 73 | for k, v := range r.ReferencedSecrets { 74 | if v == req.NamespacedName { 75 | delete(r.ReferencedSecrets, k) 76 | break 77 | } 78 | } 79 | r.ReferencedSecrets[fmt.Sprintf("%s/%s", nbSetupKey.Namespace, nbSetupKey.Spec.SecretKeyRef.Name)] = req.NamespacedName 80 | 81 | secret := corev1.Secret{} 82 | err = r.Get(ctx, types.NamespacedName{Namespace: nbSetupKey.Namespace, Name: nbSetupKey.Spec.SecretKeyRef.Name}, &secret) 83 | if err != nil { 84 | if !errors.IsNotFound(err) { 85 | logger.Error(fmt.Errorf("internalError"), "error getting secret", "err", err) 86 | return ctrl.Result{}, err 87 | } 88 | logger.Error(fmt.Errorf("invalid NBSetupKey"), "secret referenced not found", "err", err) 89 | return ctrl.Result{}, r.setStatus(ctx, &nbSetupKey, netbirdiov1.NBSetupKeyStatus{Conditions: []netbirdiov1.NBCondition{{ 90 | Type: netbirdiov1.NBSetupKeyReady, 91 | Status: corev1.ConditionFalse, 92 | LastProbeTime: v1.Now(), 93 | Reason: "SecretNotExists", 94 | Message: "Referenced secret does not exist", 95 | }}}) 96 | } 97 | 98 | uuidBytes, ok := secret.Data[nbSetupKey.Spec.SecretKeyRef.Key] 99 | if !ok { 100 | logger.Error(fmt.Errorf("invalid NBSetupKey"), "secret key referenced not found") 101 | return ctrl.Result{}, r.setStatus(ctx, &nbSetupKey, netbirdiov1.NBSetupKeyStatus{Conditions: []netbirdiov1.NBCondition{{ 102 | Type: netbirdiov1.NBSetupKeyReady, 103 | Status: corev1.ConditionFalse, 104 | LastProbeTime: v1.Now(), 105 | Reason: "SecretKeyNotExists", 106 | Message: "Referenced secret key does not exist", 107 | }}}) 108 | } 109 | 110 | _, err = uuid.Parse(string(uuidBytes)) 111 | if err != nil { 112 | logger.Error(fmt.Errorf("invalid NBSetupKey"), "setupKey is not a valid UUID", "err", err) 113 | return ctrl.Result{}, r.setStatus(ctx, &nbSetupKey, netbirdiov1.NBSetupKeyStatus{Conditions: []netbirdiov1.NBCondition{{ 114 | Type: netbirdiov1.NBSetupKeyReady, 115 | Status: corev1.ConditionFalse, 116 | LastProbeTime: v1.Now(), 117 | Reason: "InvalidSetupKey", 118 | Message: "Referenced secret is not a valid SetupKey", 119 | }}}) 120 | } 121 | return ctrl.Result{}, r.setStatus(ctx, &nbSetupKey, netbirdiov1.NBSetupKeyStatus{Conditions: []netbirdiov1.NBCondition{{ 122 | Type: netbirdiov1.NBSetupKeyReady, 123 | Status: corev1.ConditionTrue, 124 | LastProbeTime: v1.Now(), 125 | }}}) 126 | } 127 | 128 | func (r *NBSetupKeyReconciler) setStatus(ctx context.Context, nbsetupkey *netbirdiov1.NBSetupKey, status netbirdiov1.NBSetupKeyStatus) error { 129 | nbsetupkey.Status = status 130 | err := r.Status().Update(ctx, nbsetupkey) 131 | return err 132 | } 133 | 134 | // SetupWithManager sets up the controller with the Manager. 135 | func (r *NBSetupKeyReconciler) SetupWithManager(mgr ctrl.Manager) error { 136 | r.ReferencedSecrets = make(map[string]types.NamespacedName) 137 | 138 | return ctrl.NewControllerManagedBy(mgr). 139 | For(&netbirdiov1.NBSetupKey{}). 140 | Named("nbsetupkey"). 141 | Watches( 142 | &corev1.Secret{}, 143 | handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { 144 | if v, ok := r.ReferencedSecrets[fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetName())]; ok { 145 | return []reconcile.Request{ 146 | { 147 | NamespacedName: v, 148 | }, 149 | } 150 | } 151 | 152 | return nil 153 | }), 154 | ). // Trigger reconciliation when a referenced secret changes 155 | Complete(r) 156 | } 157 | -------------------------------------------------------------------------------- /internal/webhook/v1/nbsetupkey_webhook_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | corev1 "k8s.io/api/core/v1" 9 | "k8s.io/apimachinery/pkg/api/errors" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/types" 12 | 13 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 14 | ) 15 | 16 | var _ = Describe("NBSetupKey Webhook", func() { 17 | var ( 18 | obj *netbirdiov1.NBSetupKey 19 | validator NBSetupKeyCustomValidator 20 | resourceName = "test" 21 | secret *corev1.Secret 22 | ) 23 | 24 | BeforeEach(func() { 25 | obj = &netbirdiov1.NBSetupKey{ 26 | ObjectMeta: metav1.ObjectMeta{ 27 | Name: "test", 28 | Namespace: "default", 29 | }, 30 | } 31 | validator = NBSetupKeyCustomValidator{ 32 | client: k8sClient, 33 | } 34 | }) 35 | 36 | Context("When creating or updating NBSetupKey under Validating Webhook", func() { 37 | When("secretKeyRef is empty", func() { 38 | It("Should fail", func() { 39 | obj.Spec = netbirdiov1.NBSetupKeySpec{} 40 | warnings, err := validator.ValidateCreate(context.Background(), obj) 41 | Expect(err).To(HaveOccurred()) 42 | Expect(warnings).To(BeEmpty()) 43 | }) 44 | }) 45 | 46 | When("secret doesn't exist", func() { 47 | It("Should fail", func() { 48 | obj.Spec = netbirdiov1.NBSetupKeySpec{ 49 | SecretKeyRef: corev1.SecretKeySelector{ 50 | LocalObjectReference: corev1.LocalObjectReference{ 51 | Name: resourceName, 52 | }, 53 | Key: "setupkey", 54 | }, 55 | } 56 | warnings, err := validator.ValidateCreate(context.Background(), obj) 57 | Expect(err).NotTo(HaveOccurred()) 58 | Expect(warnings).NotTo(BeEmpty()) 59 | }) 60 | }) 61 | 62 | Context("secret exists", Ordered, func() { 63 | createSecret := func(secretkey, setupkey string) { 64 | resource := &corev1.Secret{ 65 | ObjectMeta: metav1.ObjectMeta{ 66 | Namespace: "default", 67 | Name: resourceName, 68 | }, 69 | Data: map[string][]byte{ 70 | secretkey: []byte(setupkey), 71 | }, 72 | } 73 | 74 | secret = &corev1.Secret{} 75 | err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: resourceName}, secret) 76 | if err == nil { 77 | Expect(k8sClient.Delete(ctx, secret)).To(Succeed()) 78 | } 79 | Expect(k8sClient.Create(ctx, resource)).To(Succeed()) 80 | } 81 | 82 | BeforeEach(func() { 83 | obj.Spec = netbirdiov1.NBSetupKeySpec{ 84 | SecretKeyRef: corev1.SecretKeySelector{ 85 | LocalObjectReference: corev1.LocalObjectReference{ 86 | Name: resourceName, 87 | }, 88 | Key: "setupkey", 89 | }, 90 | } 91 | }) 92 | 93 | When("secret key doesn't exist", func() { 94 | It("Should fail", func() { 95 | createSecret("wrongkey", "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE") 96 | warnings, err := validator.ValidateCreate(context.Background(), obj) 97 | Expect(err).NotTo(HaveOccurred()) 98 | Expect(warnings).NotTo(BeEmpty()) 99 | }) 100 | }) 101 | When("setup key is invalid", func() { 102 | It("Should fail", func() { 103 | createSecret("setupkey", "EEEEEEEE") 104 | warnings, err := validator.ValidateCreate(context.Background(), obj) 105 | Expect(err).NotTo(HaveOccurred()) 106 | Expect(warnings).NotTo(BeEmpty()) 107 | }) 108 | }) 109 | When("setup key is valid", func() { 110 | It("Should allow creation", func() { 111 | createSecret("setupkey", "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE") 112 | warnings, err := validator.ValidateCreate(context.Background(), obj) 113 | Expect(err).NotTo(HaveOccurred()) 114 | Expect(warnings).To(BeEmpty()) 115 | }) 116 | }) 117 | }) 118 | 119 | Context("Delete", func() { 120 | When("No pods exist with annotation", func() { 121 | BeforeEach(func() { 122 | pod := &corev1.Pod{ 123 | ObjectMeta: metav1.ObjectMeta{ 124 | Namespace: "default", 125 | Name: "notannotated", 126 | }, 127 | Spec: corev1.PodSpec{ 128 | Containers: []corev1.Container{ 129 | {Name: "test", Image: "test"}, 130 | }, 131 | }, 132 | } 133 | Expect(k8sClient.Create(ctx, pod)).To(Succeed()) 134 | }) 135 | 136 | AfterEach(func() { 137 | pod := &corev1.Pod{} 138 | err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "notannotated"}, pod) 139 | if !errors.IsNotFound(err) { 140 | Expect(err).NotTo(HaveOccurred()) 141 | if len(pod.Finalizers) > 0 { 142 | pod.Finalizers = nil 143 | Expect(k8sClient.Update(ctx, pod)).To(Succeed()) 144 | } 145 | err = k8sClient.Delete(ctx, pod) 146 | if !errors.IsNotFound(err) { 147 | Expect(err).NotTo(HaveOccurred()) 148 | } 149 | } 150 | }) 151 | 152 | It("should allow delete", func() { 153 | obj = &netbirdiov1.NBSetupKey{ 154 | ObjectMeta: metav1.ObjectMeta{ 155 | Name: "test", 156 | Namespace: "default", 157 | }, 158 | } 159 | Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) 160 | }) 161 | }) 162 | When("Pods exist with annotation", func() { 163 | BeforeEach(func() { 164 | pod := &corev1.Pod{ 165 | ObjectMeta: metav1.ObjectMeta{ 166 | Namespace: "default", 167 | Name: "annotated", 168 | Annotations: map[string]string{ 169 | setupKeyAnnotation: "test", 170 | }, 171 | }, 172 | Spec: corev1.PodSpec{ 173 | Containers: []corev1.Container{ 174 | {Name: "test", Image: "test"}, 175 | }, 176 | }, 177 | } 178 | Expect(k8sClient.Create(ctx, pod)).To(Succeed()) 179 | }) 180 | 181 | AfterEach(func() { 182 | pod := &corev1.Pod{} 183 | err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "annotated"}, pod) 184 | if !errors.IsNotFound(err) { 185 | Expect(err).NotTo(HaveOccurred()) 186 | if len(pod.Finalizers) > 0 { 187 | pod.Finalizers = nil 188 | Expect(k8sClient.Update(ctx, pod)).To(Succeed()) 189 | } 190 | err = k8sClient.Delete(ctx, pod) 191 | if !errors.IsNotFound(err) { 192 | Expect(err).NotTo(HaveOccurred()) 193 | } 194 | } 195 | }) 196 | 197 | It("should deny delete", func() { 198 | obj = &netbirdiov1.NBSetupKey{ 199 | ObjectMeta: metav1.ObjectMeta{ 200 | Name: "test", 201 | Namespace: "default", 202 | }, 203 | } 204 | Expect(validator.ValidateDelete(ctx, obj)).Error().To(HaveOccurred()) 205 | }) 206 | }) 207 | }) 208 | }) 209 | 210 | }) 211 | -------------------------------------------------------------------------------- /helm/kubernetes-operator/values.yaml: -------------------------------------------------------------------------------- 1 | clusterSecretsPermissions: 2 | # Required for NBSetupKey validation 3 | # Required for Ingress functionality to create and validate secrets for routing peers 4 | allowAllSecrets: true 5 | 6 | webhook: 7 | service: 8 | type: ClusterIP 9 | port: 443 10 | targetPort: 9443 11 | 12 | # TLS configuration for webhook 13 | # Optional, unused if webhook.enableCertManager is set to true 14 | tls: {} 15 | 16 | # Use cert-manager to provision webhook certificates (recommended) 17 | enableCertManager: true 18 | 19 | # Narrow down validation and mutation webhooks namespaces 20 | namespaceSelectors: [] 21 | # - key: foo 22 | # operator: In 23 | # values: 24 | # - bar 25 | 26 | # Narrow down validation and mutation webhooks objects 27 | objectSelector: 28 | matchExpressions: [] 29 | # - key: app.kubernetes.io/name 30 | # operator: NotIn 31 | # values: 32 | # - foo 33 | 34 | # Failure Policy for webhook 35 | failurePolicy: Fail 36 | 37 | operator: 38 | # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ 39 | replicaCount: 1 40 | 41 | # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ 42 | image: 43 | # Set operator image registry 44 | registry: docker.io 45 | # Set operator image repository 46 | repository: netbirdio/kubernetes-operator 47 | # This sets the pull policy for images. 48 | pullPolicy: IfNotPresent 49 | # Overrides the image tag whose default is the chart appVersion. 50 | tag: "" 51 | 52 | metrics: 53 | enabled: true 54 | type: ClusterIP 55 | port: 8080 56 | 57 | # This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ 58 | imagePullSecrets: [] 59 | # This is to override the chart name. 60 | nameOverride: "" 61 | fullnameOverride: "" 62 | 63 | #This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ 64 | serviceAccount: 65 | # Specifies whether a service account should be created 66 | create: true 67 | # Automatically mount a ServiceAccount's API credentials? 68 | automount: true 69 | # Annotations to add to the service account 70 | annotations: {} 71 | # The name of the service account to use. 72 | # If not set and create is true, a name is generated using the fullname template 73 | name: "" 74 | 75 | # This is for setting Kubernetes Annotations to a Pod. 76 | # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ 77 | podAnnotations: {} 78 | # This is for setting Kubernetes Labels to a Pod. 79 | # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ 80 | podLabels: {} 81 | 82 | securityContext: 83 | allowPrivilegeEscalation: false 84 | capabilities: 85 | drop: 86 | - ALL 87 | 88 | podSecurityContext: 89 | runAsNonRoot: true 90 | seccompProfile: 91 | type: RuntimeDefault 92 | 93 | 94 | # This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ 95 | service: 96 | # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types 97 | type: ClusterIP 98 | # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports 99 | port: 9443 100 | 101 | resources: {} 102 | # limits: 103 | # cpu: 100m 104 | # memory: 128Mi 105 | # requests: 106 | # cpu: 100m 107 | # memory: 128Mi 108 | 109 | # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ 110 | livenessProbe: 111 | port: 8081 112 | initialDelaySeconds: 15 113 | periodSeconds: 20 114 | successThreshold: 1 115 | timeoutSeconds: 1 116 | 117 | readinessProbe: 118 | port: 8081 119 | initialDelaySeconds: 5 120 | periodSeconds: 10 121 | successThreshold: 1 122 | timeoutSeconds: 1 123 | 124 | # Additional volumes on the output Deployment definition. 125 | volumes: [] 126 | 127 | # Additional volumeMounts on the output Deployment definition. 128 | volumeMounts: [] 129 | 130 | nodeSelector: {} 131 | 132 | tolerations: [] 133 | 134 | affinity: {} 135 | 136 | ingress: 137 | # Enable ingress capabilities to expose services 138 | enabled: false 139 | # Create router per namespace, useful for strict networking requirements 140 | namespacedNetworks: false 141 | # Allow creating policies through Service annotations 142 | allowAutomaticPolicyCreation: false 143 | kubernetesAPI: # DEPRECATED: Use netbirdio/netbird-operator-configs Chart instead 144 | enabled: false 145 | groups: [] 146 | # - group1 147 | # - group2 148 | policies: [] 149 | # - default 150 | router: # DEPRECATED: Use netbirdio/netbird-operator-configs Chart instead 151 | # Deploy routing peer(s) 152 | enabled: false 153 | # replicas: 3 154 | # resources: 155 | # requests: 156 | # cpu: 100m 157 | # memory: 100Mi 158 | # limits: 159 | # cpu: 100m 160 | # memory: 100Mi 161 | # labels: {} 162 | # annotations: {} 163 | # nodeSelector: {} 164 | # tolerations: [] 165 | # Only needed if namespacedNetworks is set to true 166 | namespaces: {} 167 | # default: 168 | # replicas: 3 169 | # resources: 170 | # requests: 171 | # cpu: 100m 172 | # memory: 100Mi 173 | # limits: 174 | # cpu: 100m 175 | # memory: 100Mi 176 | # labels: {} 177 | # annotations: {} 178 | # nodeSelector: {} 179 | # tolerations: [] 180 | # NetBird Policies for use with exposed services 181 | policies: {} # DEPRECATED: Use netbirdio/netbird-operator-configs Chart instead 182 | # default: 183 | # name: Kubernetes Default Policy 184 | # sourceGroups: 185 | # - All 186 | 187 | cluster: 188 | # Cluster DNS name (used for webhooks certificates and for network resource DNS names) 189 | dns: svc.cluster.local 190 | # Cluster name (used for generating network and network resource names in NetBird) 191 | name: kubernetes 192 | 193 | netbirdAPI: {} 194 | # NetBird Service Account Token 195 | # key: "nbp_m0LM9ZZvDUzFO0pY50iChDOTxJgKFM3DIqmZ" 196 | #keyFromSecret: 197 | # name: "Secret name" 198 | # key: "NB_API_KEY" 199 | 200 | #routingClientImage: "netbirdio/netbird:latest" 201 | 202 | general: 203 | # General labels, applied to all created K8s resources 204 | labels: {} 205 | # acme_com_managed_by: platform-engineering 206 | # acme_com_owned_by: release-engineering -------------------------------------------------------------------------------- /internal/controller/nbsetupkey_controller_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 | 22 | . "github.com/onsi/ginkgo/v2" 23 | . "github.com/onsi/gomega" 24 | v1 "k8s.io/api/core/v1" 25 | "k8s.io/apimachinery/pkg/types" 26 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 27 | 28 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 | 30 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 31 | ) 32 | 33 | var _ = Describe("NBSetupKey Controller", func() { 34 | Context("When reconciling a resource", func() { 35 | const resourceName = "test-resource" 36 | 37 | ctx := context.Background() 38 | 39 | typeNamespacedName := types.NamespacedName{ 40 | Name: resourceName, 41 | Namespace: "default", 42 | } 43 | nbsetupkey := &netbirdiov1.NBSetupKey{} 44 | secret := &v1.Secret{} 45 | 46 | BeforeEach(func() { 47 | By("creating the custom resource for the Kind NBSetupKey") 48 | err := k8sClient.Get(ctx, typeNamespacedName, nbsetupkey) 49 | resource := &netbirdiov1.NBSetupKey{ 50 | ObjectMeta: metav1.ObjectMeta{ 51 | Name: resourceName, 52 | Namespace: "default", 53 | }, 54 | Spec: netbirdiov1.NBSetupKeySpec{ 55 | SecretKeyRef: v1.SecretKeySelector{ 56 | LocalObjectReference: v1.LocalObjectReference{ 57 | Name: resourceName, 58 | }, 59 | Key: "setupkey", 60 | }, 61 | }, 62 | } 63 | if err == nil { 64 | Expect(k8sClient.Delete(ctx, nbsetupkey)).To(Succeed()) 65 | } 66 | Expect(k8sClient.Create(ctx, resource)).To(Succeed()) 67 | }) 68 | 69 | AfterEach(func() { 70 | resource := &netbirdiov1.NBSetupKey{} 71 | err := k8sClient.Get(ctx, typeNamespacedName, resource) 72 | Expect(err).NotTo(HaveOccurred()) 73 | 74 | By("Cleanup the specific resource instance NBSetupKey") 75 | Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) 76 | }) 77 | 78 | When("No secret present", func() { 79 | It("should set status to not ready", func() { 80 | controllerReconciler := &NBSetupKeyReconciler{ 81 | Client: k8sClient, 82 | Scheme: k8sClient.Scheme(), 83 | ReferencedSecrets: make(map[string]types.NamespacedName), 84 | } 85 | 86 | _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ 87 | NamespacedName: typeNamespacedName, 88 | }) 89 | Expect(err).NotTo(HaveOccurred()) 90 | 91 | err = k8sClient.Get(ctx, typeNamespacedName, nbsetupkey) 92 | Expect(err).NotTo(HaveOccurred()) 93 | 94 | Expect(nbsetupkey.Status.Conditions).NotTo(BeNil()) 95 | Expect(nbsetupkey.Status.Conditions).To(HaveLen(1)) 96 | Expect(nbsetupkey.Status.Conditions[0].Status).To(Equal(v1.ConditionFalse)) 97 | Expect(nbsetupkey.Status.Conditions[0].Reason).To(Equal("SecretNotExists")) 98 | Expect(controllerReconciler.ReferencedSecrets).To(HaveKey("default/test-resource")) 99 | }) 100 | }) 101 | 102 | When("Secret present", Ordered, func() { 103 | createSecret := func(secretkey, setupkey string) { 104 | resource := &v1.Secret{ 105 | ObjectMeta: metav1.ObjectMeta{ 106 | Namespace: "default", 107 | Name: resourceName, 108 | }, 109 | Data: map[string][]byte{ 110 | secretkey: []byte(setupkey), 111 | }, 112 | } 113 | 114 | secret = &v1.Secret{} 115 | err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: resourceName}, secret) 116 | if err == nil { 117 | Expect(k8sClient.Delete(ctx, secret)).To(Succeed()) 118 | } 119 | Expect(k8sClient.Create(ctx, resource)).To(Succeed()) 120 | } 121 | 122 | When("secret is invalid", func() { 123 | It("should set status to not ready", func() { 124 | createSecret("setupkey", "invalid-key") 125 | 126 | controllerReconciler := &NBSetupKeyReconciler{ 127 | Client: k8sClient, 128 | Scheme: k8sClient.Scheme(), 129 | ReferencedSecrets: make(map[string]types.NamespacedName), 130 | } 131 | 132 | _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ 133 | NamespacedName: typeNamespacedName, 134 | }) 135 | Expect(err).NotTo(HaveOccurred()) 136 | 137 | err = k8sClient.Get(ctx, typeNamespacedName, nbsetupkey) 138 | Expect(err).NotTo(HaveOccurred()) 139 | 140 | Expect(nbsetupkey.Status.Conditions).NotTo(BeNil()) 141 | Expect(nbsetupkey.Status.Conditions).To(HaveLen(1)) 142 | Expect(nbsetupkey.Status.Conditions[0].Status).To(Equal(v1.ConditionFalse)) 143 | Expect(nbsetupkey.Status.Conditions[0].Reason).To(Equal("InvalidSetupKey")) 144 | Expect(controllerReconciler.ReferencedSecrets).To(HaveKey("default/test-resource")) 145 | }) 146 | }) 147 | 148 | When("secret key is missing", func() { 149 | It("should set status to not ready", func() { 150 | createSecret("key", "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE") 151 | 152 | controllerReconciler := &NBSetupKeyReconciler{ 153 | Client: k8sClient, 154 | Scheme: k8sClient.Scheme(), 155 | ReferencedSecrets: make(map[string]types.NamespacedName), 156 | } 157 | 158 | _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ 159 | NamespacedName: typeNamespacedName, 160 | }) 161 | Expect(err).NotTo(HaveOccurred()) 162 | 163 | err = k8sClient.Get(ctx, typeNamespacedName, nbsetupkey) 164 | Expect(err).NotTo(HaveOccurred()) 165 | 166 | Expect(nbsetupkey.Status.Conditions).NotTo(BeNil()) 167 | Expect(nbsetupkey.Status.Conditions).To(HaveLen(1)) 168 | Expect(nbsetupkey.Status.Conditions[0].Status).To(Equal(v1.ConditionFalse)) 169 | Expect(nbsetupkey.Status.Conditions[0].Reason).To(Equal("SecretKeyNotExists")) 170 | Expect(controllerReconciler.ReferencedSecrets).To(HaveKey("default/test-resource")) 171 | }) 172 | }) 173 | 174 | When("secret is valid", func() { 175 | It("should set status to ready", func() { 176 | createSecret("setupkey", "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE") 177 | 178 | controllerReconciler := &NBSetupKeyReconciler{ 179 | Client: k8sClient, 180 | Scheme: k8sClient.Scheme(), 181 | ReferencedSecrets: make(map[string]types.NamespacedName), 182 | } 183 | 184 | _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ 185 | NamespacedName: typeNamespacedName, 186 | }) 187 | Expect(err).NotTo(HaveOccurred()) 188 | 189 | err = k8sClient.Get(ctx, typeNamespacedName, nbsetupkey) 190 | Expect(err).NotTo(HaveOccurred()) 191 | 192 | Expect(nbsetupkey.Status.Conditions).NotTo(BeNil()) 193 | Expect(nbsetupkey.Status.Conditions).To(HaveLen(1)) 194 | Expect(nbsetupkey.Status.Conditions[0].Status).To(Equal(v1.ConditionTrue)) 195 | Expect(controllerReconciler.ReferencedSecrets).To(HaveKey("default/test-resource")) 196 | }) 197 | }) 198 | }) 199 | }) 200 | }) 201 | -------------------------------------------------------------------------------- /CONTRIBUTOR_LICENSE_AGREEMENT.md: -------------------------------------------------------------------------------- 1 | ## Contributor License Agreement 2 | 3 | This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual 4 | submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany, 5 | referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions 6 | under which NetBird may utilize software contributions provided by the Contributor for inclusion in 7 | its software development projects. By submitting this Agreement, the Contributor confirms their acceptance 8 | of the terms and conditions outlined below. The Contributor further represents that they are authorized to 9 | complete this process as described herein. 10 | 11 | 12 | ## 1 Preamble 13 | In order to clarify the IP Rights situation with regard to Contributions from any person or entity, NetBird 14 | must have a contributor license agreement on file to be signed by each Contributor, containing the license 15 | terms below. This license serves as protection for both the Contributor as well as NetBird and its software users; 16 | it does not change Contributor’s rights to use his/her own Contributions for any other purpose. 17 | 18 | ## 2 Definitions 19 | 2.1 “IP Rights” shall mean all industrial and intellectual property rights, whether registered or not registered, whether created by Contributor or acquired by Contributor from third parties, and similar rights, including (but not limited to) semiconductor property rights, design rights, copyrights (including in the form of database rights and rights to software), all neighbouring rights (Leistungsschutzrechte), trademarks, service marks, titles, internet domain names, trade names and other labelling rights, rights deriving from corresponding applications and registrations of such rights as well as any licenses (Nutzungsrechte) under and entitlements to any such intellectual and industrial property rights. 20 | 21 | 2.2 "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is or previously has been intentionally Submitted by Contributor to NetBird for inclusion in, or documentation of any Work. 22 | 23 | 2.3 "Contributor" shall mean the copyright owner or legal entity authorized by the copyright owner that is concluding this Agreement with NetBird. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | 2.4 "Submitted" shall mean any form of electronic, verbal, or written communication sent to NetBird or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, NetBird for the purpose of discussing and improving the Work, but excluding communication that is marked or otherwise designated in writing by Contributor as "Not a Contribution". 26 | 27 | 2.5 "Work" means any of the products owned or managed by NetBird, in particular, but not exclusively, software. 28 | 29 | ## 3 Licenses 30 | 3.1 Subject to the terms and conditions of this agreement, Contributor hereby grants to NetBird and to recipients of software distributed by NetBird a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to reproduce by any means and in any form, in whole or in part, permanently or temporarily, the Contributions (including loading, displaying, executing, transmitting or storing works for the purpose of executing and processing data or transferring them to video, audio and other data carriers), including the right to distribute, display and present such Contributions and make them available to the public (e.g. via the internet) and to transmit and display such Contributions by any means. The license also includes the right to modify, translate, adapt, edit and otherwise alter the Contributions and to use these results in the same manner as the original Contributions and derivative works. Except for licenses in patents acc. to Sec. 3, such license refers to any IP Rights in the Contributions and derivative works. The Contributor acknowledges that NetBird is not required to credit them by name for their Contribution and agrees to waive any moral rights associated with their Contribution in relation to NetBird or its sublicensees. 31 | 32 | 3.2 Subject to the terms and conditions of this agreement, Contributor hereby grants to NetBird and to recipients of software distributed by NetBird a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license in the Contributions to make, have made, use, sell, offer to sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by the Contributor which are necessarily infringed by Contributor‘s Contribution(s) alone or by combination of Contributor’s Contribution(s) with the Work to which such Contribution(s) was Submitted. 33 | 34 | 3.3 NetBird hereby accepts such licenses. 35 | 36 | ## 4 Contributor’s Representations 37 | 4.1 Contributor represents that Contributor is legally entitled to grant the above license. If Contributor’s employer has IP Rights to Contributor’s Contributions, Contributor represent that he/she has received permission to make Contributions on behalf of such employer, that such employer has waived such IP Rights to the Contributions of Contributor to NetBird, or that such employer has executed a separate contributor license agreement with NetBird. 38 | 39 | 4.2 Contributor represents that any Contribution is his/her original creation. 40 | 41 | 4.3 Contributor represents to his/her best knowledge that any Contribution does not violate any third party IP Rights. 42 | 43 | 4.4 Contributor represents that any Contribution submission includes complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which Contributor is personally aware and which are associated with any part of the Contribution. 44 | 45 | 4.5 The Contributor represents that their Contribution does not include any work distributed under a copyleft license. 46 | 47 | ## 5 Information obligation 48 | Contributor agrees to notify NetBird of any facts or circumstances of which Contributor become aware that would make these representations inaccurate in any respect. 49 | 50 | ## 6 Submission of Third-Party works 51 | Should Contributor wish to submit work that is not Contributor’s original creation, Contributor may submit it to NetBird separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which Contributor are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". 52 | 53 | ## 7 No Consideration 54 | Unless compensation is mandatory under statutory law, no compensation for any license under this agreement shall be payable. 55 | 56 | ## 8 Final Provisions 57 | 8.1 Laws. This Agreement is governed by the laws of the Federal Republic of Germany. 58 | 59 | 8.2 Venue. Place of jurisdiction shall, to the extent legally permissible, be Berlin, Germany. 60 | 61 | 8.3 Severability. If any provision in this agreement is unlawful, invalid or ineffective, it shall not affect the enforceability or effectiveness of the remainder of this agreement. The parties agree to replace any unlawful, invalid or ineffective provision with a provision that comes as close as possible to the commercial intent and purpose of the original provision. This section also applies accordingly to any gaps in the contract. 62 | 63 | 8.4 Variations. Any variations, amendments or supplements to this Agreement must be in writing. This also applies to any variation of this Section 8.4. 64 | 65 | -------------------------------------------------------------------------------- /internal/controller/nbgroup_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "k8s.io/apimachinery/pkg/api/errors" 10 | "k8s.io/apimachinery/pkg/runtime" 11 | ctrl "sigs.k8s.io/controller-runtime" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | 14 | "github.com/go-logr/logr" 15 | netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" 16 | "github.com/netbirdio/kubernetes-operator/internal/util" 17 | netbird "github.com/netbirdio/netbird/management/client/rest" 18 | "github.com/netbirdio/netbird/management/server/http/api" 19 | ) 20 | 21 | // NBGroupReconciler reconciles a NBGroup object 22 | type NBGroupReconciler struct { 23 | client.Client 24 | Scheme *runtime.Scheme 25 | APIKey string 26 | ManagementURL string 27 | netbird *netbird.Client 28 | } 29 | 30 | const ( 31 | // defaultRequeueAfter default requeue duration 32 | // due to controller-runtime limitations, sync periods may reach up to 10 hours if no changes are detected 33 | // in watched resources. 34 | // This may cause issues when NetBird-side resources are out-of-sync and need to be reconciled, this is a temporary 35 | // fix to this issue by syncing with NetBird more frequently. 36 | defaultRequeueAfter = 15 * time.Minute 37 | ) 38 | 39 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 40 | // move the current state of the cluster closer to the desired state. 41 | func (r *NBGroupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) { 42 | logger := ctrl.Log.WithName("NBGroup").WithValues("namespace", req.Namespace, "name", req.Name) 43 | logger.Info("Reconciling NBGroup") 44 | 45 | nbGroup := netbirdiov1.NBGroup{} 46 | err = r.Client.Get(ctx, req.NamespacedName, &nbGroup) 47 | if err != nil { 48 | if !errors.IsNotFound(err) { 49 | logger.Error(errKubernetesAPI, "error getting NBGroup", "err", err) 50 | } 51 | return ctrl.Result{RequeueAfter: defaultRequeueAfter}, nil 52 | } 53 | 54 | originalGroup := nbGroup.DeepCopy() 55 | defer func() { 56 | if err != nil { 57 | // double check result is nil, otherwise error is not printed 58 | // and exponential backoff doesn't work properly 59 | res = ctrl.Result{} 60 | return 61 | } 62 | if !originalGroup.Status.Equal(nbGroup.Status) { 63 | updateErr := r.Client.Status().Update(ctx, &nbGroup) 64 | if updateErr != nil { 65 | err = updateErr 66 | } 67 | } 68 | if !res.Requeue && res.RequeueAfter == 0 { 69 | res.RequeueAfter = defaultRequeueAfter 70 | } 71 | }() 72 | 73 | if nbGroup.DeletionTimestamp != nil { 74 | if len(nbGroup.Finalizers) == 0 { 75 | return ctrl.Result{}, nil 76 | } 77 | return ctrl.Result{}, r.handleDelete(ctx, nbGroup, logger) 78 | } 79 | 80 | return r.syncNetBirdGroup(ctx, &nbGroup, logger) 81 | } 82 | 83 | // syncNetBirdGroup reconciliation logic for non-deleted objects. 84 | func (r *NBGroupReconciler) syncNetBirdGroup(ctx context.Context, nbGroup *netbirdiov1.NBGroup, logger logr.Logger) (ctrl.Result, error) { 85 | // Get all NetBird groups to ensure no group duplication 86 | groups, err := r.netbird.Groups.List(ctx) 87 | if err != nil { 88 | logger.Error(errNetBirdAPI, "error listing groups", "err", err) 89 | return ctrl.Result{}, err 90 | } 91 | var group *api.Group 92 | for _, g := range groups { 93 | if g.Name == nbGroup.Spec.Name { 94 | group = &g 95 | } 96 | } 97 | 98 | // Create group if not exists, and update status.groupId 99 | if nbGroup.Status.GroupID == nil && group == nil { 100 | logger.Info("NBGroup: Creating group on NetBird", "name", nbGroup.Spec.Name) 101 | group, err := r.netbird.Groups.Create(ctx, api.GroupRequest{ 102 | Name: nbGroup.Spec.Name, 103 | }) 104 | if err != nil { 105 | nbGroup.Status.Conditions = netbirdiov1.NBConditionFalse("APIError", fmt.Sprintf("NetBird API Error: %v", err)) 106 | logger.Error(errNetBirdAPI, "error creating group", "err", err) 107 | return ctrl.Result{}, err 108 | } 109 | 110 | logger.Info("NBGroup: Created group on NetBird", "name", nbGroup.Spec.Name, "id", group.Id) 111 | nbGroup.Status.GroupID = &group.Id 112 | nbGroup.Status.Conditions = netbirdiov1.NBConditionTrue() 113 | } else if nbGroup.Status.GroupID == nil && group != nil { 114 | logger.Info("NBGroup: Found group with same name on NetBird", "name", nbGroup.Spec.Name, "id", group.Id) 115 | nbGroup.Status.GroupID = &group.Id 116 | nbGroup.Status.Conditions = netbirdiov1.NBConditionTrue() 117 | } else if group == nil { 118 | logger.Info("NBGroup: Group was deleted", "name", nbGroup.Spec.Name, "id", *nbGroup.Status.GroupID) 119 | nbGroup.Status.GroupID = nil 120 | nbGroup.Status.Conditions = netbirdiov1.NBConditionFalse("GroupGone", "Group was deleted from NetBird API") 121 | return ctrl.Result{Requeue: true}, nil 122 | } else { 123 | nbGroup.Status.Conditions = netbirdiov1.NBConditionTrue() 124 | } 125 | 126 | if nbGroup.Status.GroupID != nil && group != nil && *nbGroup.Status.GroupID != group.Id { 127 | // There are two possibilities here, either someone deleted and created the group in NetBird, thus the changed ID 128 | // Or there's a conflict with something else, either way, we just need to take the new ID here 129 | nbGroup.Status.GroupID = &group.Id 130 | nbGroup.Status.Conditions = netbirdiov1.NBConditionTrue() 131 | } 132 | return ctrl.Result{}, nil 133 | } 134 | 135 | func (r *NBGroupReconciler) handleDelete(ctx context.Context, nbGroup netbirdiov1.NBGroup, logger logr.Logger) error { 136 | // Group doesn't exist on NetBird, no need for cleanup 137 | if nbGroup.Status.GroupID == nil { 138 | nbGroup.Finalizers = util.Without(nbGroup.Finalizers, "netbird.io/group-cleanup") 139 | err := r.Client.Update(ctx, &nbGroup) 140 | if err != nil { 141 | logger.Error(errKubernetesAPI, "error updating NBGroup", "err", err) 142 | return err 143 | } 144 | 145 | return nil 146 | } 147 | 148 | err := r.netbird.Groups.Delete(ctx, *nbGroup.Status.GroupID) 149 | if err != nil && !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "linked") { 150 | logger.Error(errNetBirdAPI, "error deleting group", "err", err) 151 | return err 152 | } 153 | 154 | if err != nil && strings.Contains(err.Error(), "linked") && !nbGroup.DeletionTimestamp.Add(time.Minute).Before(time.Now()) { 155 | logger.Info("group still linked to resources on netbird", "err", err) 156 | // Check if group is defined elsewhere in the cluster 157 | var groups netbirdiov1.NBGroupList 158 | listErr := r.Client.List(ctx, &groups) 159 | if listErr != nil { 160 | logger.Error(errKubernetesAPI, "error listing NBGroups", "err", listErr) 161 | return listErr 162 | } 163 | for _, v := range groups.Items { 164 | if v.UID == nbGroup.UID { 165 | continue 166 | } 167 | if v.Status.GroupID != nil && nbGroup.Status.GroupID != nil && *v.Status.GroupID == *nbGroup.Status.GroupID { 168 | // Same group, multiple resources 169 | logger.Info("group exists in another namespace", "namespace", v.Namespace, "name", v.Name) 170 | nbGroup.Finalizers = util.Without(nbGroup.Finalizers, "netbird.io/group-cleanup") 171 | err = r.Client.Update(ctx, &nbGroup) 172 | if err != nil { 173 | logger.Error(errKubernetesAPI, "error updating NBGroup", "err", err) 174 | return err 175 | } 176 | return nil 177 | } 178 | } 179 | 180 | // No other NBGroup with same name on the cluster 181 | // This could be a group created by user elsewhere or some resources belonging to the group are still deleting. 182 | return err 183 | } 184 | 185 | nbGroup.Finalizers = util.Without(nbGroup.Finalizers, "netbird.io/group-cleanup") 186 | err = r.Client.Update(ctx, &nbGroup) 187 | if err != nil { 188 | logger.Error(errKubernetesAPI, "error updating NBGroup", "err", err) 189 | return err 190 | } 191 | 192 | return nil 193 | } 194 | 195 | // SetupWithManager sets up the controller with the Manager. 196 | func (r *NBGroupReconciler) SetupWithManager(mgr ctrl.Manager) error { 197 | r.netbird = netbird.New(r.ManagementURL, r.APIKey) 198 | 199 | return ctrl.NewControllerManagedBy(mgr). 200 | For(&netbirdiov1.NBGroup{}). 201 | Named("nbgroup"). 202 | Complete(r) 203 | } 204 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Image URL to use all building/pushing image targets 2 | IMG ?= docker.io/netbirdio/kubernetes-operator:latest 3 | 4 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 5 | ifeq (,$(shell go env GOBIN)) 6 | GOBIN=$(shell go env GOPATH)/bin 7 | else 8 | GOBIN=$(shell go env GOBIN) 9 | endif 10 | 11 | # CONTAINER_TOOL defines the container tool to be used for building images. 12 | # Be aware that the target commands are only tested with Docker which is 13 | # scaffolded by default. However, you might want to replace it to use other 14 | # tools. (i.e. podman) 15 | CONTAINER_TOOL ?= docker 16 | 17 | # Setting SHELL to bash allows bash commands to be executed by recipes. 18 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 19 | SHELL = /usr/bin/env bash -o pipefail 20 | .SHELLFLAGS = -ec 21 | 22 | .PHONY: all 23 | all: build 24 | 25 | ##@ General 26 | 27 | # The help target prints out all targets with their descriptions organized 28 | # beneath their categories. The categories are represented by '##@' and the 29 | # target descriptions by '##'. The awk command is responsible for reading the 30 | # entire set of makefiles included in this invocation, looking for lines of the 31 | # file as xyz: ## something, and then pretty-format the target and help. Then, 32 | # if there's a line with ##@ something, that gets pretty-printed as a category. 33 | # More info on the usage of ANSI control characters for terminal formatting: 34 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 35 | # More info on the awk command: 36 | # http://linuxcommand.org/lc3_adv_awk.php 37 | 38 | .PHONY: help 39 | help: ## Display this help. 40 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 41 | 42 | ##@ Development 43 | 44 | .PHONY: manifests 45 | manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. 46 | $(CONTROLLER_GEN) crd paths="./..." output:crd:artifacts:config=helm/kubernetes-operator/crds 47 | 48 | .PHONY: generate 49 | generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. 50 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 51 | 52 | .PHONY: fmt 53 | fmt: ## Run go fmt against code. 54 | go fmt ./... 55 | 56 | .PHONY: vet 57 | vet: ## Run go vet against code. 58 | go vet ./... 59 | 60 | .PHONY: test 61 | test: manifests fmt vet setup-envtest ## Run tests. 62 | KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test -v $$(go list ./... | grep -v /e2e) -coverprofile cover.out 63 | 64 | .PHONY: test-e2e 65 | test-e2e: manifests fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. 66 | @command -v kind >/dev/null 2>&1 || { \ 67 | echo "Kind is not installed. Please install Kind manually."; \ 68 | exit 1; \ 69 | } 70 | @kind get clusters | grep -q 'kind' || { \ 71 | echo "No Kind cluster is running. Please start a Kind cluster before running the e2e tests."; \ 72 | exit 1; \ 73 | } 74 | go test ./test/e2e/ -v -ginkgo.v 75 | 76 | .PHONY: lint 77 | lint: golangci-lint ## Run golangci-lint linter 78 | $(GOLANGCI_LINT) run 79 | 80 | .PHONY: lint-fix 81 | lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes 82 | $(GOLANGCI_LINT) run --fix 83 | 84 | .PHONY: lint-config 85 | lint-config: golangci-lint ## Verify golangci-lint linter configuration 86 | $(GOLANGCI_LINT) config verify 87 | 88 | ##@ Build 89 | 90 | .PHONY: build 91 | build: manifests fmt vet ## Build manager binary. 92 | go build -o bin/manager cmd/main.go 93 | 94 | .PHONY: run 95 | run: manifests fmt vet ## Run a controller from your host. 96 | go run ./cmd/main.go 97 | 98 | # If you wish to build the manager image targeting other platforms you can use the --platform flag. 99 | # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. 100 | # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 101 | .PHONY: docker-build 102 | docker-build: ## Build docker image with the manager. 103 | $(CONTAINER_TOOL) build -t ${IMG} . 104 | 105 | .PHONY: docker-push 106 | docker-push: ## Push docker image with the manager. 107 | $(CONTAINER_TOOL) push ${IMG} 108 | 109 | # PLATFORMS defines the target platforms for the manager image be built to provide support to multiple 110 | # architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: 111 | # - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ 112 | # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 113 | # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) 114 | # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. 115 | PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le 116 | .PHONY: docker-buildx 117 | docker-buildx: ## Build and push docker image for the manager for cross-platform support 118 | # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile 119 | sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross 120 | - $(CONTAINER_TOOL) buildx create --name operator-builder 121 | $(CONTAINER_TOOL) buildx use operator-builder 122 | - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . 123 | - $(CONTAINER_TOOL) buildx rm operator-builder 124 | rm Dockerfile.cross 125 | 126 | .PHONY: build-installer 127 | build-installer: manifests ## Generate a consolidated YAML with CRDs and deployment. 128 | mkdir -p manifests 129 | $(HELM) template --include-crds kubernetes-operator helm/kubernetes-operator > manifests/install.yaml 130 | 131 | ##@ Deployment 132 | 133 | ifndef ignore-not-found 134 | ignore-not-found = false 135 | endif 136 | 137 | .PHONY: install 138 | install: manifests ## Install CRDs into the K8s cluster specified in ~/.kube/config. 139 | $(KUBECTL) apply -f helm/kubernetes-operator/crds 140 | 141 | .PHONY: uninstall 142 | uninstall: manifests ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 143 | $(KUBECTL) delete -f helm/kubernetes-operator/crds 144 | 145 | .PHONY: deploy 146 | deploy: manifests ## Deploy controller to the K8s cluster specified in ~/.kube/config. 147 | $(HELM) install -n netbird --create-namespace kubernetes-operator --set operator.image.tag=$(word 2,$(subst :, ,${IMG})) helm/kubernetes-operator 148 | 149 | .PHONY: undeploy 150 | undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 151 | $(HELM) uninstall -n netbird kubernetes-operator 152 | 153 | ##@ Dependencies 154 | 155 | ## Location to install dependencies to 156 | LOCALBIN ?= $(shell pwd)/bin 157 | $(LOCALBIN): 158 | mkdir -p $(LOCALBIN) 159 | 160 | ## Tool Binaries 161 | KUBECTL ?= kubectl 162 | CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen 163 | ENVTEST ?= $(LOCALBIN)/setup-envtest 164 | GOLANGCI_LINT = $(LOCALBIN)/golangci-lint 165 | HELM ?= helm 166 | 167 | ## Tool Versions 168 | CONTROLLER_TOOLS_VERSION ?= v0.17.1 169 | #ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20) 170 | ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') 171 | #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) 172 | ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') 173 | GOLANGCI_LINT_VERSION ?= v1.63.4 174 | 175 | .PHONY: controller-gen 176 | controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. 177 | $(CONTROLLER_GEN): $(LOCALBIN) 178 | $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) 179 | 180 | .PHONY: setup-envtest 181 | setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory. 182 | @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..." 183 | @$(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path || { \ 184 | echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \ 185 | exit 1; \ 186 | } 187 | 188 | .PHONY: envtest 189 | envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. 190 | $(ENVTEST): $(LOCALBIN) 191 | $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) 192 | 193 | .PHONY: golangci-lint 194 | golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. 195 | $(GOLANGCI_LINT): $(LOCALBIN) 196 | $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) 197 | 198 | # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist 199 | # $1 - target path with name of binary 200 | # $2 - package url which can be installed 201 | # $3 - specific version of package 202 | define go-install-tool 203 | @[ -f "$(1)-$(3)" ] || { \ 204 | set -e; \ 205 | package=$(2)@$(3) ;\ 206 | echo "Downloading $${package}" ;\ 207 | rm -f $(1) || true ;\ 208 | GOBIN=$(LOCALBIN) go install $${package} ;\ 209 | mv $(1) $(1)-$(3) ;\ 210 | } ;\ 211 | ln -sf $(1)-$(3) $(1) 212 | endef 213 | --------------------------------------------------------------------------------