├── config ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── crd │ ├── kustomization.yaml │ └── kustomizeconfig.yaml ├── default │ ├── kustomization.yaml │ ├── manager_config_patch.yaml │ └── manager_auth_proxy_patch.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── rbac │ ├── kustomization.yaml │ ├── dashboard_viewer_role.yaml │ ├── dashboard_editor_role.yaml │ ├── service_account.yaml │ ├── auth_proxy_client_clusterrole.yaml │ ├── leader_election_role.yaml │ ├── role_binding.yaml │ ├── auth_proxy_role_binding.yaml │ ├── leader_election_role_binding.yaml │ ├── auth_proxy_role.yaml │ ├── auth_proxy_service.yaml │ ├── role-minimal.yaml │ └── role.yaml └── samples │ ├── homer_v1alpha1_dashboard_footer_disabled.yaml │ ├── homer_v1alpha1_dashboard_multicluster.yaml │ └── homer_v1alpha1_dashboard.yaml ├── homer ├── Homer-Operator.png ├── httpRouteExample.yaml └── config.yml ├── pkg ├── utils │ ├── common.go │ ├── filters.go │ ├── retry.go │ └── filters_test.go └── homer │ ├── validation_test.go │ ├── footer_test.go │ ├── conflict_test.go │ ├── empty_service_test.go │ ├── crd_service_matching_test.go │ ├── grouping_test.go │ └── hide_feature_test.go ├── charts └── homer-operator │ ├── .helmignore │ ├── templates │ ├── serviceaccount.yaml │ ├── service.yaml │ ├── poddisruptionbudget.yaml │ ├── servicemonitor.yaml │ ├── NOTES.txt │ ├── hpa.yaml │ ├── vpa.yaml │ ├── prometheusrule.yaml │ ├── _helpers.tpl │ ├── deployment.yaml │ └── rbac.yaml │ ├── Chart.yaml │ ├── ci │ └── values.yaml │ ├── values.yaml │ ├── values.schema.json │ └── README.md ├── .gitignore ├── hack └── boilerplate.go.txt ├── .dockerignore ├── test ├── fixtures │ ├── invalid-theme-test.yaml │ └── missing-secret-test.yaml └── e2e │ └── e2e_suite_test.go ├── renovate.json ├── PROJECT ├── internal └── controller │ ├── constants.go │ ├── test_utils.go │ ├── suite_test.go │ └── resource_controller.go ├── Dockerfile ├── api └── v1alpha1 │ ├── groupversion_info.go │ └── dashboard_types_test.go ├── scripts └── sync-crd-to-helm.sh ├── .golangci.yml ├── .github └── workflows │ ├── ci.yml │ ├── helm-release.yml │ └── release.yml ├── go.mod ├── cmd └── main.go └── Makefile /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - bases/homer.rajsingh.info_dashboards.yaml 3 | -------------------------------------------------------------------------------- /homer/Homer-Operator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rajsinghtech/homer-operator/HEAD/homer/Homer-Operator.png -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | namespace: homer-operator-system 2 | namePrefix: homer-operator- 3 | 4 | resources: 5 | - ../crd 6 | - ../rbac 7 | - ../manager 8 | 9 | patches: 10 | - path: manager_auth_proxy_patch.yaml -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | apiVersion: kustomize.config.k8s.io/v1beta1 4 | kind: Kustomization 5 | images: 6 | - name: controller 7 | newName: ghcr.io/rajsinghtech/homer-operator 8 | newTag: main 9 | -------------------------------------------------------------------------------- /config/default/manager_config_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - service_account.yaml 3 | - role.yaml 4 | - role_binding.yaml 5 | - leader_election_role.yaml 6 | - leader_election_role_binding.yaml 7 | - auth_proxy_service.yaml 8 | - auth_proxy_role.yaml 9 | - auth_proxy_role_binding.yaml 10 | - auth_proxy_client_clusterrole.yaml 11 | -------------------------------------------------------------------------------- /config/rbac/dashboard_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: dashboard-viewer-role 5 | rules: 6 | - apiGroups: 7 | - homer.rajsingh.info 8 | resources: 9 | - dashboards 10 | - dashboards/status 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | -------------------------------------------------------------------------------- /pkg/utils/common.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // IsSubset checks if all key-value pairs in map2 exist in map1 4 | // In other words, it checks if map2 is a subset of map1 5 | func IsSubset(map1, map2 map[string]string) bool { 6 | for key, value := range map2 { 7 | if map1[key] != value { 8 | return false 9 | } 10 | } 11 | return true 12 | } 13 | -------------------------------------------------------------------------------- /config/rbac/dashboard_editor_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: dashboard-editor-role 5 | rules: 6 | - apiGroups: 7 | - homer.rajsingh.info 8 | resources: 9 | - dashboards 10 | - dashboards/status 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: serviceaccount 6 | app.kubernetes.io/instance: controller-manager-sa 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: homer-operator 9 | app.kubernetes.io/part-of: homer-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /charts/homer-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/ -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | nameReference: 2 | - kind: Service 3 | version: v1 4 | fieldSpecs: 5 | - kind: CustomResourceDefinition 6 | version: v1 7 | group: apiextensions.k8s.io 8 | path: spec/conversion/webhook/clientConfig/service/name 9 | 10 | namespace: 11 | - kind: CustomResourceDefinition 12 | version: v1 13 | group: apiextensions.k8s.io 14 | path: spec/conversion/webhook/clientConfig/service/namespace 15 | create: false 16 | 17 | varReference: 18 | - path: metadata/annotations 19 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrole 6 | app.kubernetes.io/instance: metrics-reader 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: homer-operator 9 | app.kubernetes.io/part-of: homer-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: metrics-reader 12 | rules: 13 | - nonResourceURLs: 14 | - "/metrics" 15 | verbs: 16 | - get 17 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: leader-election-role 5 | rules: 6 | - apiGroups: 7 | - "" 8 | resources: 9 | - configmaps 10 | - events 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - coordination.k8s.io 21 | resources: 22 | - leases 23 | verbs: 24 | - create 25 | - delete 26 | - get 27 | - list 28 | - patch 29 | - update 30 | - watch 31 | -------------------------------------------------------------------------------- /charts/homer-operator/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | --- 3 | apiVersion: v1 4 | kind: ServiceAccount 5 | metadata: 6 | name: {{ include "homer-operator.serviceAccountName" . }} 7 | namespace: {{ include "homer-operator.namespace" . }} 8 | labels: 9 | {{- include "homer-operator.labels" . | nindent 4 }} 10 | {{- with .Values.serviceAccount.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 15 | {{- end }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin/* 9 | Dockerfile.cross 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # Kubernetes Generated files - skip generated files, except for vendored files 21 | !vendor/**/zz_generated.* 22 | 23 | # editor and IDE paraphernalia 24 | .idea 25 | .vscode 26 | *.swp 27 | *.swo 28 | *~ 29 | __debug_bin* 30 | test-**.yaml 31 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 RajSingh. 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 | */ -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrolebinding 6 | app.kubernetes.io/instance: manager-rolebinding 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: homer-operator 9 | app.kubernetes.io/part-of: homer-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: manager-rolebinding 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: ClusterRole 15 | name: manager-role 16 | subjects: 17 | - kind: ServiceAccount 18 | name: controller-manager 19 | namespace: system 20 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrolebinding 6 | app.kubernetes.io/instance: proxy-rolebinding 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: homer-operator 9 | app.kubernetes.io/part-of: homer-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: proxy-rolebinding 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: ClusterRole 15 | name: proxy-role 16 | subjects: 17 | - kind: ServiceAccount 18 | name: controller-manager 19 | namespace: system 20 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: rolebinding 6 | app.kubernetes.io/instance: leader-election-rolebinding 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: homer-operator 9 | app.kubernetes.io/part-of: homer-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: leader-election-rolebinding 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: Role 15 | name: leader-election-role 16 | subjects: 17 | - kind: ServiceAccount 18 | name: controller-manager 19 | namespace: system 20 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrole 6 | app.kubernetes.io/instance: proxy-role 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: homer-operator 9 | app.kubernetes.io/part-of: homer-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: proxy-role 12 | rules: 13 | - apiGroups: 14 | - authentication.k8s.io 15 | resources: 16 | - tokenreviews 17 | verbs: 18 | - create 19 | - apiGroups: 20 | - authorization.k8s.io 21 | resources: 22 | - subjectaccessreviews 23 | verbs: 24 | - create 25 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: service 7 | app.kubernetes.io/instance: controller-manager-metrics-service 8 | app.kubernetes.io/component: kube-rbac-proxy 9 | app.kubernetes.io/created-by: homer-operator 10 | app.kubernetes.io/part-of: homer-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: controller-manager-metrics-service 13 | namespace: system 14 | spec: 15 | ports: 16 | - name: https 17 | port: 8443 18 | protocol: TCP 19 | targetPort: https 20 | selector: 21 | control-plane: controller-manager 22 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | 5 | # Git and GitHub files 6 | .git 7 | .github 8 | .gitignore 9 | 10 | # Documentation 11 | *.md 12 | README* 13 | CHANGELOG* 14 | LICENSE* 15 | 16 | # Test files 17 | *_test.go 18 | testdata/ 19 | tests/ 20 | coverage.* 21 | cover.* 22 | 23 | # Development files 24 | Makefile 25 | .dockerignore 26 | 27 | # Config samples (not needed in runtime) 28 | config/samples/ 29 | 30 | # Temporary files 31 | *.tmp 32 | *.log 33 | .DS_Store 34 | 35 | # IDE files 36 | .vscode/ 37 | .idea/ 38 | *.swp 39 | *.swo 40 | 41 | # Chart development 42 | charts/*/tests/ 43 | -------------------------------------------------------------------------------- /test/fixtures/invalid-theme-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Test dashboard with invalid theme (should fail validation) 3 | apiVersion: homer.rajsingh.info/v1alpha1 4 | kind: Dashboard 5 | metadata: 6 | name: dashboard-invalid-theme 7 | namespace: homer-test 8 | spec: 9 | replicas: 1 10 | homerConfig: 11 | title: "Invalid Theme Dashboard" 12 | subtitle: "This should fail validation" 13 | header: true # Required field 14 | theme: "nonexistent-theme" # This should cause validation error 15 | services: 16 | - parameters: 17 | name: "Test Services" 18 | items: 19 | - parameters: 20 | name: "Test Service" 21 | url: "https://example.com" -------------------------------------------------------------------------------- /charts/homer-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: homer-operator 3 | description: Kubernetes operator for automated Homer dashboard deployment with service discovery 4 | type: application 5 | version: 0.1.0 6 | appVersion: "latest" 7 | home: https://github.com/rajsinghtech/homer-operator 8 | icon: https://raw.githubusercontent.com/rajsinghtech/homer-operator/main/homer/Homer-Operator.png 9 | sources: 10 | - https://github.com/rajsinghtech/homer-operator 11 | maintainers: 12 | - name: rajsingh 13 | email: raj@rajsingh.tech 14 | keywords: 15 | - homer 16 | - operator 17 | - dashboard 18 | - service-discovery 19 | - ingress 20 | - gateway-api 21 | - monitoring 22 | annotations: 23 | category: Monitoring 24 | licenses: Apache-2.0 -------------------------------------------------------------------------------- /test/fixtures/missing-secret-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Dashboard with reference to non-existent secret (should fail gracefully) 3 | apiVersion: homer.rajsingh.info/v1alpha1 4 | kind: Dashboard 5 | metadata: 6 | name: dashboard-missing-secret 7 | namespace: homer-test 8 | spec: 9 | replicas: 1 10 | secrets: 11 | apiKey: 12 | name: nonexistent-secret # This secret doesn't exist 13 | key: api-key 14 | homerConfig: 15 | title: "Missing Secret Test" 16 | subtitle: "This should fail gracefully" 17 | header: true 18 | services: 19 | - parameters: 20 | name: "Test Services" 21 | items: 22 | - parameters: 23 | name: "Test Smart Card" 24 | type: "Emby" 25 | url: "http://test.example.com" -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "docker:enableMajor", 6 | ":dependencyDashboard" 7 | ], 8 | "labels": ["dependencies"], 9 | "assignees": ["@rajsingh"], 10 | "reviewers": ["@rajsingh"], 11 | "schedule": ["before 6am on Monday"], 12 | "timezone": "America/Los_Angeles", 13 | "separateMinorPatch": true, 14 | "separateMajorMinor": true, 15 | "rangeStrategy": "bump", 16 | "golang": { 17 | "enabled": true 18 | }, 19 | "docker": { 20 | "enabled": true 21 | }, 22 | "helmv3": { 23 | "enabled": true 24 | }, 25 | "packageRules": [ 26 | { 27 | "matchDepTypes": ["major"], 28 | "dependencyDashboardApproval": true 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /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: rajsingh.info 6 | layout: 7 | - go.kubebuilder.io/v4 8 | projectName: homer-operator 9 | repo: github.com/rajsinghtech/homer-operator.git 10 | resources: 11 | - api: 12 | crdVersion: v1 13 | namespaced: true 14 | controller: true 15 | domain: rajsingh.info 16 | group: homer 17 | kind: Dashboard 18 | path: github.com/rajsinghtech/homer-operator.git/api/v1alpha1 19 | version: v1alpha1 20 | - controller: true 21 | domain: k8s.io 22 | group: networking 23 | kind: Ingress 24 | path: k8s.io/api/networking/v1 25 | version: v1 26 | version: "3" 27 | -------------------------------------------------------------------------------- /internal/controller/constants.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 RajSingh. 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 | const ( 20 | dashboardFinalizer = "homer.rajsingh.info/finalizer" 21 | gatewayKind = "Gateway" 22 | localClusterName = "local" 23 | ) 24 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: monitoring.coreos.com/v1 2 | kind: ServiceMonitor 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: servicemonitor 7 | app.kubernetes.io/instance: controller-manager-metrics-monitor 8 | app.kubernetes.io/component: metrics 9 | app.kubernetes.io/created-by: homer-operator 10 | app.kubernetes.io/part-of: homer-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: controller-manager-metrics-monitor 13 | namespace: system 14 | spec: 15 | endpoints: 16 | - path: /metrics 17 | port: https 18 | scheme: https 19 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 20 | tlsConfig: 21 | insecureSkipVerify: true 22 | selector: 23 | matchLabels: 24 | control-plane: controller-manager 25 | -------------------------------------------------------------------------------- /charts/homer-operator/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.services.metrics.enabled }} 2 | --- 3 | apiVersion: v1 4 | kind: Service 5 | metadata: 6 | name: {{ include "homer-operator.metricsServiceName" . }} 7 | namespace: {{ include "homer-operator.namespace" . }} 8 | labels: 9 | {{- include "homer-operator.labels" . | nindent 4 }} 10 | control-plane: controller-manager 11 | app.kubernetes.io/component: kube-rbac-proxy 12 | {{- with .Values.services.metrics.annotations }} 13 | annotations: 14 | {{- toYaml . | nindent 4 }} 15 | {{- end }} 16 | spec: 17 | type: {{ .Values.services.metrics.type }} 18 | ports: 19 | - name: https 20 | port: {{ .Values.services.metrics.port }} 21 | protocol: TCP 22 | targetPort: https 23 | selector: 24 | {{- include "homer-operator.selectorLabels" . | nindent 4 }} 25 | control-plane: controller-manager 26 | {{- end }} -------------------------------------------------------------------------------- /test/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 RajSingh. 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 | "testing" 21 | 22 | . "github.com/onsi/ginkgo/v2" 23 | . "github.com/onsi/gomega" 24 | ) 25 | 26 | func TestE2E(t *testing.T) { 27 | RegisterFailHandler(Fail) 28 | RunSpecs(t, "Homer Operator E2E Suite") 29 | } 30 | -------------------------------------------------------------------------------- /charts/homer-operator/templates/poddisruptionbudget.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.highAvailability.podDisruptionBudget.enabled }} 2 | --- 3 | apiVersion: policy/v1 4 | kind: PodDisruptionBudget 5 | metadata: 6 | name: {{ include "homer-operator.fullname" . }} 7 | namespace: {{ include "homer-operator.namespace" . }} 8 | labels: 9 | {{- include "homer-operator.labels" . | nindent 4 }} 10 | spec: 11 | {{- if .Values.highAvailability.podDisruptionBudget.minAvailable }} 12 | minAvailable: {{ .Values.highAvailability.podDisruptionBudget.minAvailable }} 13 | {{- end }} 14 | {{- if .Values.highAvailability.podDisruptionBudget.maxUnavailable }} 15 | maxUnavailable: {{ .Values.highAvailability.podDisruptionBudget.maxUnavailable }} 16 | {{- end }} 17 | selector: 18 | matchLabels: 19 | {{- include "homer-operator.selectorLabels" . | nindent 6 }} 20 | control-plane: controller-manager 21 | {{- end }} -------------------------------------------------------------------------------- /charts/homer-operator/ci/values.yaml: -------------------------------------------------------------------------------- 1 | # CI values for chart testing 2 | # Minimal configuration for testing 3 | 4 | replicaCount: 1 5 | 6 | image: 7 | repository: ghcr.io/rajsinghtech/homer-operator 8 | pullPolicy: IfNotPresent 9 | tag: "latest" 10 | 11 | operator: 12 | enableGatewayAPI: false 13 | metrics: 14 | enabled: true 15 | secureMetrics: false 16 | leaderElection: 17 | enabled: true 18 | 19 | resources: 20 | limits: 21 | cpu: 100m 22 | memory: 128Mi 23 | requests: 24 | cpu: 10m 25 | memory: 64Mi 26 | 27 | serviceAccount: 28 | create: true 29 | automount: true 30 | 31 | rbac: 32 | create: true 33 | 34 | crd: 35 | create: true 36 | 37 | namespace: 38 | create: true 39 | 40 | serviceMonitor: 41 | create: false 42 | 43 | podDisruptionBudget: 44 | enabled: false 45 | 46 | autoscaling: 47 | enabled: false 48 | 49 | networkPolicy: 50 | enabled: false -------------------------------------------------------------------------------- /config/samples/homer_v1alpha1_dashboard_footer_disabled.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: homer.rajsingh.info/v1alpha1 3 | kind: Dashboard 4 | metadata: 5 | name: dashboard-no-footer 6 | namespace: public 7 | labels: 8 | app: homer-dashboard 9 | tier: frontend 10 | spec: 11 | replicas: 1 12 | 13 | homerConfig: 14 | title: "Minimal Dashboard" 15 | subtitle: "No Footer Example" 16 | header: true 17 | # Footer can be set to false to hide it completely (issue #33 fix) 18 | footer: false 19 | 20 | # Default layout settings 21 | defaults: 22 | layout: "columns" 23 | colorTheme: "auto" 24 | 25 | # Basic service example 26 | services: 27 | - parameters: 28 | name: "Services" 29 | icon: "fas fa-server" 30 | items: 31 | - parameters: 32 | name: "Example Service" 33 | subtitle: "This dashboard has no footer" 34 | icon: "fas fa-globe" 35 | url: "https://example.com" 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder 3 | 4 | ARG TARGETOS 5 | ARG TARGETARCH 6 | 7 | WORKDIR /workspace 8 | 9 | # Install build dependencies 10 | RUN apk add --no-cache git ca-certificates 11 | 12 | # Copy go mod files first for better caching 13 | COPY go.mod go.sum ./ 14 | 15 | # Download dependencies (cached layer) 16 | ENV GOTOOLCHAIN=auto 17 | RUN --mount=type=cache,target=/go/pkg/mod \ 18 | go mod download 19 | 20 | # Copy source code 21 | COPY cmd/ cmd/ 22 | COPY api/ api/ 23 | COPY internal/ internal/ 24 | COPY pkg/ pkg/ 25 | 26 | # Build with cache mounts and target platform 27 | RUN --mount=type=cache,target=/go/pkg/mod \ 28 | --mount=type=cache,target=/root/.cache/go-build \ 29 | CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ 30 | go build -ldflags="-w -s" -trimpath -o manager cmd/main.go 31 | 32 | # Runtime stage 33 | FROM gcr.io/distroless/static:nonroot 34 | 35 | WORKDIR / 36 | 37 | COPY --from=builder /workspace/manager . 38 | 39 | USER 65532:65532 40 | 41 | ENTRYPOINT ["/manager"] 42 | -------------------------------------------------------------------------------- /config/default/manager_auth_proxy_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: kube-rbac-proxy 11 | securityContext: 12 | allowPrivilegeEscalation: false 13 | capabilities: 14 | drop: 15 | - "ALL" 16 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.15.0 17 | imagePullPolicy: Always 18 | args: 19 | - "--secure-listen-address=0.0.0.0:8443" 20 | - "--upstream=http://127.0.0.1:8080/" 21 | - "--v=0" 22 | ports: 23 | - containerPort: 8443 24 | protocol: TCP 25 | name: https 26 | resources: 27 | limits: 28 | cpu: 500m 29 | memory: 128Mi 30 | requests: 31 | cpu: 5m 32 | memory: 64Mi 33 | - name: manager 34 | args: 35 | - "--health-probe-bind-address=:8081" 36 | - "--metrics-bind-address=127.0.0.1:8080" 37 | - "--leader-elect" 38 | -------------------------------------------------------------------------------- /charts/homer-operator/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.serviceMonitor.enabled .Values.operator.metrics.enabled }} 2 | --- 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | name: {{ include "homer-operator.fullname" . }} 7 | namespace: {{ include "homer-operator.namespace" . }} 8 | labels: 9 | {{- include "homer-operator.labels" . | nindent 4 }} 10 | {{- with .Values.serviceMonitor.labels }} 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | {{- with .Values.serviceMonitor.annotations }} 14 | annotations: 15 | {{- toYaml . | nindent 4 }} 16 | {{- end }} 17 | spec: 18 | selector: 19 | matchLabels: 20 | {{- include "homer-operator.selectorLabels" . | nindent 6 }} 21 | control-plane: controller-manager 22 | endpoints: 23 | - port: https 24 | scheme: https 25 | interval: {{ .Values.serviceMonitor.interval }} 26 | scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} 27 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 28 | tlsConfig: 29 | insecureSkipVerify: true 30 | {{- end }} -------------------------------------------------------------------------------- /config/rbac/role-minimal.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role-minimal 6 | rules: 7 | - apiGroups: 8 | - homer.rajsingh.info 9 | resources: 10 | - dashboards 11 | - dashboards/finalizers 12 | - dashboards/status 13 | verbs: 14 | - create 15 | - delete 16 | - get 17 | - list 18 | - patch 19 | - update 20 | - watch 21 | - apiGroups: 22 | - "" 23 | resources: 24 | - configmaps 25 | - configmaps/status 26 | - services 27 | - services/status 28 | - secrets 29 | verbs: 30 | - create 31 | - delete 32 | - get 33 | - list 34 | - patch 35 | - update 36 | - watch 37 | - apiGroups: 38 | - apps 39 | resources: 40 | - deployments 41 | - deployments/status 42 | verbs: 43 | - create 44 | - delete 45 | - get 46 | - list 47 | - patch 48 | - update 49 | - watch 50 | - apiGroups: 51 | - networking.k8s.io 52 | resources: 53 | - ingresses 54 | - ingresses/status 55 | verbs: 56 | - get 57 | - list 58 | - watch 59 | - apiGroups: 60 | - gateway.networking.k8s.io 61 | resources: 62 | - httproutes 63 | - httproutes/status 64 | - gateways 65 | verbs: 66 | - get 67 | - list 68 | - watch -------------------------------------------------------------------------------- /charts/homer-operator/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | Thank you for installing {{ .Chart.Name }}. 2 | 3 | Your release is named {{ .Release.Name }}. 4 | 5 | To learn more about the release, try: 6 | 7 | $ helm status {{ .Release.Name }} 8 | $ helm get values {{ .Release.Name }} 9 | 10 | The Homer Operator is now running in namespace {{ include "homer-operator.namespace" . }}. 11 | 12 | {{- if .Values.operator.metrics.enabled }} 13 | Metrics are enabled and can be scraped from the metrics service. 14 | {{- if .Values.serviceMonitor.enabled }} 15 | ServiceMonitor is enabled for Prometheus integration. 16 | {{- end }} 17 | {{- end }} 18 | 19 | {{- if .Values.operator.enableGatewayAPI }} 20 | Gateway API support is enabled. The operator will discover services from HTTPRoute resources. 21 | {{- end }} 22 | 23 | To create a Dashboard resource, apply: 24 | 25 | kubectl apply -f - <" >&2 11 | echo " Converts a Kubernetes CRD file to a Helm template" >&2 12 | exit 1 13 | fi 14 | 15 | CRD_FILE="$1" 16 | 17 | # Validate input file 18 | if [ ! -f "$CRD_FILE" ]; then 19 | echo "Error: CRD file '$CRD_FILE' not found" >&2 20 | exit 1 21 | fi 22 | 23 | # Check if file is readable 24 | if [ ! -r "$CRD_FILE" ]; then 25 | echo "Error: CRD file '$CRD_FILE' is not readable" >&2 26 | exit 1 27 | fi 28 | 29 | # Validate that the file contains CRD content 30 | if ! grep -q "kind: CustomResourceDefinition" "$CRD_FILE"; then 31 | echo "Error: File '$CRD_FILE' does not appear to be a CRD (missing 'kind: CustomResourceDefinition')" >&2 32 | exit 1 33 | fi 34 | 35 | cat <&2 57 | exit 1 58 | fi 59 | 60 | # Extract and validate the spec section 61 | SPEC_CONTENT=$(tail -n +9 "$CRD_FILE" | sed '$d') 62 | if [ -z "$SPEC_CONTENT" ]; then 63 | echo "Error: No spec content found in CRD file '$CRD_FILE'" >&2 64 | exit 1 65 | fi 66 | 67 | echo "$SPEC_CONTENT" 68 | 69 | echo "{{- end }}" -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: namespace 7 | app.kubernetes.io/instance: system 8 | app.kubernetes.io/component: manager 9 | app.kubernetes.io/created-by: homer-operator 10 | app.kubernetes.io/part-of: homer-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: system 13 | --- 14 | apiVersion: apps/v1 15 | kind: Deployment 16 | metadata: 17 | name: controller-manager 18 | namespace: system 19 | labels: 20 | control-plane: controller-manager 21 | app.kubernetes.io/name: deployment 22 | app.kubernetes.io/instance: controller-manager 23 | app.kubernetes.io/component: manager 24 | app.kubernetes.io/created-by: homer-operator 25 | app.kubernetes.io/part-of: homer-operator 26 | app.kubernetes.io/managed-by: kustomize 27 | spec: 28 | selector: 29 | matchLabels: 30 | control-plane: controller-manager 31 | replicas: 1 32 | template: 33 | metadata: 34 | annotations: 35 | kubectl.kubernetes.io/default-container: manager 36 | labels: 37 | control-plane: controller-manager 38 | spec: 39 | securityContext: 40 | runAsNonRoot: true 41 | containers: 42 | - command: 43 | - /manager 44 | args: 45 | - --leader-elect 46 | image: controller:latest 47 | imagePullPolicy: Always 48 | name: manager 49 | securityContext: 50 | allowPrivilegeEscalation: false 51 | capabilities: 52 | drop: 53 | - "ALL" 54 | livenessProbe: 55 | httpGet: 56 | path: /healthz 57 | port: 8081 58 | initialDelaySeconds: 15 59 | periodSeconds: 20 60 | readinessProbe: 61 | httpGet: 62 | path: /readyz 63 | port: 8081 64 | initialDelaySeconds: 5 65 | periodSeconds: 10 66 | resources: 67 | limits: 68 | cpu: 500m 69 | memory: 128Mi 70 | requests: 71 | cpu: 10m 72 | memory: 64Mi 73 | serviceAccountName: controller-manager 74 | terminationGracePeriodSeconds: 10 75 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | - services 12 | verbs: 13 | - create 14 | - delete 15 | - get 16 | - list 17 | - patch 18 | - update 19 | - watch 20 | - apiGroups: 21 | - "" 22 | resources: 23 | - configmaps/status 24 | - services/status 25 | verbs: 26 | - get 27 | - patch 28 | - update 29 | - apiGroups: 30 | - "" 31 | resources: 32 | - namespaces 33 | - secrets 34 | verbs: 35 | - get 36 | - list 37 | - watch 38 | - apiGroups: 39 | - apps 40 | resources: 41 | - deployments 42 | verbs: 43 | - create 44 | - delete 45 | - get 46 | - list 47 | - patch 48 | - update 49 | - watch 50 | - apiGroups: 51 | - apps 52 | resources: 53 | - deployments/status 54 | verbs: 55 | - get 56 | - patch 57 | - update 58 | - apiGroups: 59 | - authentication.k8s.io 60 | resources: 61 | - tokenreviews 62 | verbs: 63 | - create 64 | - apiGroups: 65 | - authorization.k8s.io 66 | resources: 67 | - subjectaccessreviews 68 | verbs: 69 | - create 70 | - apiGroups: 71 | - gateway.networking.k8s.io 72 | resources: 73 | - gateways 74 | - httproutes 75 | verbs: 76 | - get 77 | - list 78 | - watch 79 | - apiGroups: 80 | - gateway.networking.k8s.io 81 | resources: 82 | - httproutes/status 83 | verbs: 84 | - get 85 | - apiGroups: 86 | - homer.rajsingh.info 87 | resources: 88 | - dashboards 89 | verbs: 90 | - create 91 | - delete 92 | - get 93 | - list 94 | - patch 95 | - update 96 | - watch 97 | - apiGroups: 98 | - homer.rajsingh.info 99 | resources: 100 | - dashboards/finalizers 101 | verbs: 102 | - update 103 | - apiGroups: 104 | - homer.rajsingh.info 105 | resources: 106 | - dashboards/status 107 | verbs: 108 | - get 109 | - patch 110 | - update 111 | - apiGroups: 112 | - networking.k8s.io 113 | resources: 114 | - ingresses 115 | verbs: 116 | - get 117 | - list 118 | - watch 119 | - apiGroups: 120 | - networking.k8s.io 121 | resources: 122 | - ingresses/status 123 | verbs: 124 | - get 125 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | go: "1.25.1" 5 | timeout: 10m 6 | allow-parallel-runners: true 7 | 8 | linters: 9 | default: none 10 | enable: 11 | # Error detection 12 | - errcheck # Check for unchecked errors 13 | - govet # Vet examines Go source code 14 | - ineffassign # Detect ineffectual assignments 15 | - unused # Check for unused code 16 | 17 | # Code quality 18 | - copyloopvar # Detect loop variable copying issues 19 | - errorlint # Error wrapping issues 20 | - ginkgolinter # Enforce ginkgo/gomega best practices 21 | - goconst # Repeated strings that could be constants 22 | - gocyclo # Cyclomatic complexity 23 | - misspell # Spell checker 24 | - nakedret # Naked returns in long functions 25 | - nilerr # Check error handling patterns 26 | - prealloc # Find slice preallocation opportunities 27 | 28 | # Security & reliability 29 | - bodyclose # Check HTTP response body closure 30 | - errchkjson # Check JSON encoding errors 31 | - makezero # Find slice declarations with non-zero length 32 | 33 | # Style (selective) 34 | - unconvert # Remove unnecessary type conversions 35 | - unparam # Unused function parameters 36 | - whitespace # Unnecessary whitespace 37 | 38 | # Staticcheck (enabled with exclusions) 39 | - staticcheck 40 | 41 | settings: 42 | govet: 43 | disable: 44 | - fieldalignment # Don't enforce struct field alignment 45 | - shadow # Variable shadowing is sometimes acceptable 46 | enable-all: true 47 | 48 | staticcheck: 49 | checks: 50 | - "all" 51 | - "-QF1001" # De Morgan's law (style preference) 52 | - "-QF1008" # Embedded field usage (style preference) 53 | - "-ST1000" # Package comments (not always needed) 54 | - "-ST1001" # Dot imports acceptable in tests 55 | - "-ST1003" # Naming conventions (allow Url instead of URL for JSON compat) 56 | 57 | issues: 58 | max-issues-per-linter: 0 59 | max-same-issues: 0 60 | exclude-use-default: false 61 | 62 | exclusions: 63 | generated: strict 64 | paths: 65 | - "zz_generated.*\\.go$" 66 | 67 | rules: 68 | # Allow dupl in test files 69 | - linters: 70 | - dupl 71 | path: "_test\\.go$" 72 | 73 | # Long lines acceptable in API definitions 74 | - linters: 75 | - lll 76 | path: "api/.*\\.go$" 77 | 78 | formatters: 79 | enable: 80 | - gofmt 81 | - goimports 82 | 83 | exclusions: 84 | generated: strict 85 | paths: 86 | - "zz_generated.*\\.go$" 87 | -------------------------------------------------------------------------------- /charts/homer-operator/templates/prometheusrule.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.prometheusRule.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: PrometheusRule 4 | metadata: 5 | name: {{ include "homer-operator.fullname" . }} 6 | namespace: {{ include "homer-operator.namespace" . }} 7 | labels: 8 | {{- include "homer-operator.labels" . | nindent 4 }} 9 | {{- include "homer-operator.componentLabels" "prometheusrule" | nindent 4 }} 10 | {{- with .Values.prometheusRule.labels }} 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | {{- with .Values.prometheusRule.annotations }} 14 | annotations: 15 | {{- toYaml . | nindent 4 }} 16 | {{- end }} 17 | spec: 18 | groups: 19 | - name: homer-operator.rules 20 | rules: 21 | - alert: HomerOperatorDown 22 | expr: up{job="{{ include "homer-operator.fullname" . }}"} == 0 23 | for: 5m 24 | labels: 25 | severity: critical 26 | annotations: 27 | summary: "Homer Operator is down" 28 | description: "Homer Operator has been down for more than 5 minutes." 29 | 30 | - alert: HomerOperatorHighMemoryUsage 31 | expr: (container_memory_working_set_bytes{container="manager",pod=~"{{ include "homer-operator.fullname" . }}.*"} / container_spec_memory_limit_bytes{container="manager",pod=~"{{ include "homer-operator.fullname" . }}.*"}) > 0.8 32 | for: 10m 33 | labels: 34 | severity: warning 35 | annotations: 36 | summary: "Homer Operator high memory usage" 37 | description: "Homer Operator memory usage is above 80% for more than 10 minutes." 38 | 39 | - alert: HomerOperatorHighCPUUsage 40 | expr: (rate(container_cpu_usage_seconds_total{container="manager",pod=~"{{ include "homer-operator.fullname" . }}.*"}[5m]) / container_spec_cpu_quota{container="manager",pod=~"{{ include "homer-operator.fullname" . }}.*"} * container_spec_cpu_period{container="manager",pod=~"{{ include "homer-operator.fullname" . }}.*"}) > 0.8 41 | for: 10m 42 | labels: 43 | severity: warning 44 | annotations: 45 | summary: "Homer Operator high CPU usage" 46 | description: "Homer Operator CPU usage is above 80% for more than 10 minutes." 47 | 48 | - alert: HomerOperatorReconcileErrors 49 | expr: rate(controller_runtime_reconcile_errors_total{controller="dashboard"}[5m]) > 0.1 50 | for: 5m 51 | labels: 52 | severity: warning 53 | annotations: 54 | summary: "Homer Operator reconciliation errors" 55 | description: "Homer Operator is experiencing reconciliation errors at a rate of {{ "{{ $value }}" }} errors per second." 56 | 57 | {{- with .Values.prometheusRule.additionalRules }} 58 | {{- toYaml . | nindent 4 }} 59 | {{- end }} 60 | {{- end }} -------------------------------------------------------------------------------- /pkg/homer/validation_test.go: -------------------------------------------------------------------------------- 1 | package homer 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestHomerConfigValidation tests overall configuration validation 8 | func TestHomerConfigValidation(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | config HomerConfig 12 | expectError bool 13 | errorMsg string 14 | }{ 15 | { 16 | name: "valid config", 17 | config: HomerConfig{ 18 | Title: "Test Dashboard", 19 | Subtitle: "Test Subtitle", 20 | }, 21 | expectError: false, 22 | }, 23 | { 24 | name: "empty title", 25 | config: HomerConfig{}, 26 | expectError: true, 27 | errorMsg: "title: required", 28 | }, 29 | { 30 | name: "valid config with services", 31 | config: HomerConfig{ 32 | Title: "Test Dashboard", 33 | Services: []Service{ 34 | { 35 | Parameters: map[string]string{ 36 | "name": "Test Service", 37 | }, 38 | Items: []Item{ 39 | { 40 | Parameters: map[string]string{ 41 | "name": "Test Item", 42 | "url": "https://example.com", 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | }, 49 | expectError: false, 50 | }, 51 | } 52 | 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | err := ValidateHomerConfig(&tt.config) 56 | if tt.expectError && err == nil { 57 | t.Errorf("Expected error for config, but got none") 58 | } 59 | if !tt.expectError && err != nil { 60 | t.Errorf("Expected no error for config, but got: %v", err) 61 | } 62 | if tt.expectError && err != nil && tt.errorMsg != "" { 63 | if err.Error() != tt.errorMsg { 64 | t.Errorf("Expected error message '%s', got '%s'", tt.errorMsg, err.Error()) 65 | } 66 | } 67 | }) 68 | } 69 | } 70 | 71 | // TestThemeValidation tests theme validation 72 | func TestThemeValidation(t *testing.T) { 73 | tests := []struct { 74 | name string 75 | theme string 76 | expectError bool 77 | }{ 78 | { 79 | name: "valid default theme", 80 | theme: "default", 81 | expectError: false, 82 | }, 83 | { 84 | name: "valid neon theme", 85 | theme: "neon", 86 | expectError: false, 87 | }, 88 | { 89 | name: "empty theme", 90 | theme: "", 91 | expectError: false, // Empty theme defaults to "default" 92 | }, 93 | { 94 | name: "invalid theme", 95 | theme: "invalid-theme", 96 | expectError: true, 97 | }, 98 | } 99 | 100 | for _, tt := range tests { 101 | t.Run(tt.name, func(t *testing.T) { 102 | err := ValidateTheme(tt.theme) 103 | if tt.expectError && err == nil { 104 | t.Errorf("Expected error for theme '%s', but got none", tt.theme) 105 | } 106 | if !tt.expectError && err != nil { 107 | t.Errorf("Expected no error for theme '%s', but got: %v", tt.theme, err) 108 | } 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - 'release/*' 8 | workflow_dispatch: 9 | 10 | env: 11 | GO_VERSION: '1.25.1' 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ env.GO_VERSION }} 25 | 26 | - name: Cache Go modules 27 | uses: actions/cache@v4 28 | with: 29 | path: | 30 | ~/go/pkg/mod 31 | ~/.cache/go-build 32 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 33 | restore-keys: | 34 | ${{ runner.os }}-go- 35 | 36 | - name: Run linters 37 | run: make lint 38 | 39 | - name: Verify manifests 40 | run: | 41 | make manifests 42 | git diff --exit-code 43 | 44 | - name: Verify code generation 45 | run: | 46 | make generate 47 | git diff --exit-code 48 | 49 | - name: Run unit tests with coverage 50 | run: | 51 | make test 52 | go tool cover -html=cover.out -o coverage.html 53 | 54 | - name: Upload coverage report 55 | uses: actions/upload-artifact@v4 56 | with: 57 | name: coverage-report 58 | path: coverage.html 59 | 60 | build: 61 | name: Build 62 | runs-on: ubuntu-latest 63 | needs: test 64 | steps: 65 | - name: Checkout code 66 | uses: actions/checkout@v4 67 | 68 | - name: Set up Go 69 | uses: actions/setup-go@v5 70 | with: 71 | go-version: ${{ env.GO_VERSION }} 72 | 73 | - name: Cache Go modules 74 | uses: actions/cache@v4 75 | with: 76 | path: | 77 | ~/go/pkg/mod 78 | ~/.cache/go-build 79 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 80 | restore-keys: | 81 | ${{ runner.os }}-go- 82 | 83 | - name: Set up QEMU 84 | uses: docker/setup-qemu-action@v3 85 | 86 | - name: Set up Docker Buildx 87 | uses: docker/setup-buildx-action@v3 88 | 89 | - name: Build Docker image (no push) 90 | uses: docker/build-push-action@v6 91 | with: 92 | context: . 93 | platforms: linux/amd64,linux/arm64 94 | push: false 95 | tags: homer-operator:test 96 | cache-from: type=gha 97 | cache-to: type=gha,mode=max 98 | 99 | helm-test: 100 | name: Helm Test 101 | runs-on: ubuntu-latest 102 | steps: 103 | - name: Checkout 104 | uses: actions/checkout@v4 105 | with: 106 | fetch-depth: 0 107 | 108 | - name: Set up Helm 109 | uses: azure/setup-helm@v4 110 | with: 111 | version: v3.17.0 112 | 113 | - name: Validate Helm chart 114 | run: | 115 | helm lint charts/homer-operator 116 | helm template test charts/homer-operator --dry-run > /dev/null 117 | echo "Helm chart validation passed" 118 | 119 | -------------------------------------------------------------------------------- /pkg/homer/footer_test.go: -------------------------------------------------------------------------------- 1 | package homer 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "testing" 7 | 8 | yaml "gopkg.in/yaml.v2" 9 | ) 10 | 11 | func TestFooterFalseHandling(t *testing.T) { 12 | t.Run("YAML unmarshal footer: false", func(t *testing.T) { 13 | yamlData := ` 14 | title: "Test Dashboard" 15 | footer: false 16 | ` 17 | var config HomerConfig 18 | err := yaml.Unmarshal([]byte(yamlData), &config) 19 | if err != nil { 20 | t.Fatalf("Failed to unmarshal YAML: %v", err) 21 | } 22 | 23 | if config.Footer != FooterHidden { 24 | t.Errorf("Expected Footer to be %q, got %q", FooterHidden, config.Footer) 25 | } 26 | }) 27 | 28 | t.Run("YAML unmarshal footer: string", func(t *testing.T) { 29 | yamlData := ` 30 | title: "Test Dashboard" 31 | footer: "

Custom Footer

" 32 | ` 33 | var config HomerConfig 34 | err := yaml.Unmarshal([]byte(yamlData), &config) 35 | if err != nil { 36 | t.Fatalf("Failed to unmarshal YAML: %v", err) 37 | } 38 | 39 | expected := "

Custom Footer

" 40 | if config.Footer != expected { 41 | t.Errorf("Expected Footer to be %q, got %q", expected, config.Footer) 42 | } 43 | }) 44 | 45 | t.Run("JSON unmarshal footer: false", func(t *testing.T) { 46 | jsonData := `{ 47 | "title": "Test Dashboard", 48 | "footer": false 49 | }` 50 | var config HomerConfig 51 | err := json.Unmarshal([]byte(jsonData), &config) 52 | if err != nil { 53 | t.Fatalf("Failed to unmarshal JSON: %v", err) 54 | } 55 | 56 | if config.Footer != FooterHidden { 57 | t.Errorf("Expected Footer to be %q, got %q", FooterHidden, config.Footer) 58 | } 59 | }) 60 | 61 | t.Run("JSON unmarshal footer: string", func(t *testing.T) { 62 | jsonData := `{ 63 | "title": "Test Dashboard", 64 | "footer": "

Custom Footer

" 65 | }` 66 | var config HomerConfig 67 | err := json.Unmarshal([]byte(jsonData), &config) 68 | if err != nil { 69 | t.Fatalf("Failed to unmarshal JSON: %v", err) 70 | } 71 | 72 | expected := "

Custom Footer

" 73 | if config.Footer != expected { 74 | t.Errorf("Expected Footer to be %q, got %q", expected, config.Footer) 75 | } 76 | }) 77 | 78 | t.Run("YAML marshal footer: false", func(t *testing.T) { 79 | config := &HomerConfig{ 80 | Title: "Test Dashboard", 81 | Footer: FooterHidden, 82 | } 83 | 84 | yamlBytes, err := marshalHomerConfigToYAML(config) 85 | if err != nil { 86 | t.Fatalf("Failed to generate Homer YAML: %v", err) 87 | } 88 | 89 | yamlStr := string(yamlBytes) 90 | if !strings.Contains(yamlStr, "footer: false") { 91 | t.Errorf("Expected YAML to contain 'footer: false', got:\n%s", yamlStr) 92 | } 93 | }) 94 | 95 | t.Run("YAML marshal footer: string", func(t *testing.T) { 96 | config := &HomerConfig{ 97 | Title: "Test Dashboard", 98 | Footer: "

Custom Footer

", 99 | } 100 | 101 | yamlBytes, err := marshalHomerConfigToYAML(config) 102 | if err != nil { 103 | t.Fatalf("Failed to generate Homer YAML: %v", err) 104 | } 105 | 106 | yamlStr := string(yamlBytes) 107 | if !strings.Contains(yamlStr, "footer:

Custom Footer

") { 108 | t.Errorf("Expected YAML to contain custom footer string, got:\n%s", yamlStr) 109 | } 110 | }) 111 | 112 | t.Run("YAML marshal footer: empty (omitted)", func(t *testing.T) { 113 | config := &HomerConfig{ 114 | Title: "Test Dashboard", 115 | Footer: "", 116 | } 117 | 118 | yamlBytes, err := marshalHomerConfigToYAML(config) 119 | if err != nil { 120 | t.Fatalf("Failed to generate Homer YAML: %v", err) 121 | } 122 | 123 | yamlStr := string(yamlBytes) 124 | if strings.Contains(yamlStr, "footer:") { 125 | t.Errorf("Expected YAML to not contain footer field when empty, got:\n%s", yamlStr) 126 | } 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /internal/controller/test_utils.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 RajSingh. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "time" 22 | 23 | . "github.com/onsi/gomega" 24 | appsv1 "k8s.io/api/apps/v1" 25 | corev1 "k8s.io/api/core/v1" 26 | apierrors "k8s.io/apimachinery/pkg/api/errors" 27 | "k8s.io/apimachinery/pkg/types" 28 | "sigs.k8s.io/controller-runtime/pkg/client" 29 | 30 | homerv1alpha1 "github.com/rajsinghtech/homer-operator/api/v1alpha1" 31 | ) 32 | 33 | type TestResourceManager struct { 34 | ctx context.Context 35 | client client.Client 36 | timeout time.Duration 37 | interval time.Duration 38 | } 39 | 40 | func NewTestResourceManager(ctx context.Context, client client.Client) *TestResourceManager { 41 | return &TestResourceManager{ 42 | ctx: ctx, 43 | client: client, 44 | timeout: time.Second * 10, 45 | interval: time.Millisecond * 100, 46 | } 47 | } 48 | 49 | func (m *TestResourceManager) Cleanup(obj client.Object) { 50 | if obj == nil { 51 | return 52 | } 53 | 54 | namespacedName := types.NamespacedName{ 55 | Name: obj.GetName(), 56 | Namespace: obj.GetNamespace(), 57 | } 58 | 59 | // Special handling for Dashboard finalizers 60 | if _, ok := obj.(*homerv1alpha1.Dashboard); ok { 61 | current := &homerv1alpha1.Dashboard{} 62 | if err := m.client.Get(m.ctx, namespacedName, current); err == nil { 63 | if len(current.Finalizers) > 0 { 64 | current.Finalizers = []string{} 65 | _ = m.client.Update(m.ctx, current) 66 | } 67 | } 68 | } 69 | 70 | err := m.client.Delete(m.ctx, obj) 71 | if err != nil && !apierrors.IsNotFound(err) { 72 | return 73 | } 74 | 75 | // Wait for deletion 76 | Eventually(func() bool { 77 | err := m.client.Get(m.ctx, namespacedName, obj) 78 | return apierrors.IsNotFound(err) 79 | }, m.timeout, m.interval).Should(BeTrue()) 80 | } 81 | 82 | func (m *TestResourceManager) CleanupAll(resources ...client.Object) { 83 | for _, resource := range resources { 84 | m.Cleanup(resource) 85 | } 86 | } 87 | 88 | func (m *TestResourceManager) WaitForResource(namespacedName types.NamespacedName, resource client.Object) { 89 | Eventually(func() bool { 90 | err := m.client.Get(m.ctx, namespacedName, resource) 91 | return err == nil 92 | }, m.timeout, m.interval).Should(BeTrue()) 93 | } 94 | 95 | func (m *TestResourceManager) WaitForConfigMapData(namespacedName types.NamespacedName, key string) { 96 | Eventually(func() bool { 97 | configMap := &corev1.ConfigMap{} 98 | err := m.client.Get(m.ctx, namespacedName, configMap) 99 | if err != nil { 100 | return false 101 | } 102 | data, exists := configMap.Data[key] 103 | return exists && data != "" && data != "null" 104 | }, m.timeout, m.interval).Should(BeTrue()) 105 | } 106 | 107 | func (m *TestResourceManager) GetHomerResources(dashboardName, namespace string) (*corev1.ConfigMap, *appsv1.Deployment, *corev1.Service) { 108 | homerName := dashboardName + "-homer" 109 | 110 | configMap := &corev1.ConfigMap{} 111 | _ = m.client.Get(m.ctx, types.NamespacedName{Name: homerName, Namespace: namespace}, configMap) 112 | 113 | deployment := &appsv1.Deployment{} 114 | _ = m.client.Get(m.ctx, types.NamespacedName{Name: homerName, Namespace: namespace}, deployment) 115 | 116 | service := &corev1.Service{} 117 | _ = m.client.Get(m.ctx, types.NamespacedName{Name: homerName, Namespace: namespace}, service) 118 | 119 | return configMap, deployment, service 120 | } 121 | -------------------------------------------------------------------------------- /pkg/homer/conflict_test.go: -------------------------------------------------------------------------------- 1 | package homer 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestBasicServiceUpdate(t *testing.T) { 10 | config := &HomerConfig{ 11 | Services: []Service{ 12 | { 13 | Parameters: map[string]string{ 14 | "name": "test-namespace", 15 | }, 16 | Items: []Item{ 17 | { 18 | Parameters: map[string]string{ 19 | "name": "existing-service", 20 | "url": "http://old.example.com", 21 | "tag": "old-tag", 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | } 28 | 29 | // Test adding a new item to existing service 30 | service := Service{ 31 | Parameters: map[string]string{ 32 | "name": "test-namespace", 33 | }, 34 | } 35 | newItem := Item{ 36 | Parameters: map[string]string{ 37 | "name": "new-service", 38 | "url": "https://new.example.com", 39 | "tag": "new-tag", 40 | }, 41 | } 42 | updateOrAddServiceItems(config, service, []Item{newItem}) 43 | 44 | if len(config.Services) != 1 { 45 | t.Fatalf("Expected 1 service, got %d", len(config.Services)) 46 | } 47 | 48 | if len(config.Services[0].Items) != 2 { 49 | t.Fatalf("Expected 2 items, got %d", len(config.Services[0].Items)) 50 | } 51 | 52 | // Test replacing existing item 53 | replacementItem := Item{ 54 | Parameters: map[string]string{ 55 | "name": "existing-service", 56 | "url": "https://updated.example.com", 57 | "tag": "updated-tag", 58 | }, 59 | } 60 | updateOrAddServiceItems(config, service, []Item{replacementItem}) 61 | 62 | if len(config.Services[0].Items) != 2 { 63 | t.Fatalf("Expected 2 items after replacement, got %d", len(config.Services[0].Items)) 64 | } 65 | 66 | // Find the updated item 67 | var updatedItem *Item 68 | for i, item := range config.Services[0].Items { 69 | if item.Parameters != nil && item.Parameters["name"] == "existing-service" { 70 | updatedItem = &config.Services[0].Items[i] 71 | break 72 | } 73 | } 74 | 75 | if updatedItem == nil { 76 | t.Fatal("Could not find updated item") 77 | } 78 | 79 | expectedURL := "https://updated.example.com" 80 | actualURL := "" 81 | if updatedItem.Parameters != nil { 82 | actualURL = updatedItem.Parameters["url"] 83 | } 84 | if actualURL != expectedURL { 85 | t.Errorf("Expected URL '%s', got '%s'", expectedURL, actualURL) 86 | } 87 | } 88 | 89 | // Redundant annotation processing tests moved to annotation_processing_test.go 90 | 91 | func TestServiceGroupingPerformance(t *testing.T) { 92 | // Create a large config with many services and items 93 | config := &HomerConfig{} 94 | 95 | // Add 100 services with 10 items each 96 | for i := 0; i < 100; i++ { 97 | service := Service{ 98 | Parameters: map[string]string{ 99 | "name": fmt.Sprintf("service-%d", i), 100 | }, 101 | } 102 | for j := 0; j < 10; j++ { 103 | item := Item{ 104 | Parameters: map[string]string{ 105 | "name": fmt.Sprintf("item-%d-%d", i, j), 106 | "url": fmt.Sprintf("https://example-%d-%d.com", i, j), 107 | }, 108 | } 109 | service.Items = append(service.Items, item) 110 | } 111 | config.Services = append(config.Services, service) 112 | } 113 | 114 | // Test adding new items to existing services 115 | start := time.Now() 116 | for i := 0; i < 100; i++ { 117 | service := Service{ 118 | Parameters: map[string]string{ 119 | "name": fmt.Sprintf("service-%d", i), 120 | }, 121 | } 122 | newItem := Item{ 123 | Parameters: map[string]string{ 124 | "name": fmt.Sprintf("new-item-%d", i), 125 | "url": fmt.Sprintf("https://new-example-%d.com", i), 126 | }, 127 | } 128 | updateOrAddServiceItems(config, service, []Item{newItem}) 129 | } 130 | duration := time.Since(start) 131 | 132 | // Performance should be reasonable for 100 services with 10 items each 133 | if duration > time.Millisecond*100 { 134 | t.Errorf("Service grouping took too long: %v", duration) 135 | } 136 | 137 | // Verify that new items were added 138 | for i := 0; i < 100; i++ { 139 | service := config.Services[i] 140 | if len(service.Items) != 11 { // 10 original + 1 new 141 | t.Errorf("Expected 11 items in service %d, got %d", i, len(service.Items)) 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rajsinghtech/homer-operator 2 | 3 | go 1.25.1 4 | 5 | require ( 6 | github.com/onsi/ginkgo/v2 v2.26.0 7 | github.com/onsi/gomega v1.38.2 8 | gopkg.in/yaml.v2 v2.4.0 9 | k8s.io/api v0.34.1 10 | k8s.io/apimachinery v0.34.1 11 | k8s.io/client-go v0.34.1 12 | sigs.k8s.io/controller-runtime v0.21.0 13 | sigs.k8s.io/gateway-api v1.3.0 14 | ) 15 | 16 | require ( 17 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 18 | github.com/go-openapi/swag/cmdutils v0.25.1 // indirect 19 | github.com/go-openapi/swag/conv v0.25.1 // indirect 20 | github.com/go-openapi/swag/fileutils v0.25.1 // indirect 21 | github.com/go-openapi/swag/jsonname v0.25.1 // indirect 22 | github.com/go-openapi/swag/jsonutils v0.25.1 // indirect 23 | github.com/go-openapi/swag/loading v0.25.1 // indirect 24 | github.com/go-openapi/swag/mangling v0.25.1 // indirect 25 | github.com/go-openapi/swag/netutils v0.25.1 // indirect 26 | github.com/go-openapi/swag/stringutils v0.25.1 // indirect 27 | github.com/go-openapi/swag/typeutils v0.25.1 // indirect 28 | github.com/go-openapi/swag/yamlutils v0.25.1 // indirect 29 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 30 | golang.org/x/mod v0.27.0 // indirect 31 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 32 | ) 33 | 34 | require ( 35 | github.com/beorn7/perks v1.0.1 // indirect 36 | github.com/blang/semver/v4 v4.0.0 // indirect 37 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 38 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 39 | github.com/emicklei/go-restful/v3 v3.13.0 // indirect 40 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 41 | github.com/fsnotify/fsnotify v1.9.0 // indirect 42 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 43 | github.com/go-logr/logr v1.4.3 44 | github.com/go-logr/zapr v1.3.0 // indirect 45 | github.com/go-openapi/jsonpointer v0.22.1 // indirect 46 | github.com/go-openapi/jsonreference v0.21.2 // indirect 47 | github.com/go-openapi/swag v0.25.1 // indirect 48 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 49 | github.com/gogo/protobuf v1.3.2 // indirect 50 | github.com/google/btree v1.1.3 // indirect 51 | github.com/google/gnostic-models v0.7.0 // indirect 52 | github.com/google/go-cmp v0.7.0 // indirect 53 | github.com/google/pprof v0.0.0-20250629210550-e611ec304b22 // indirect 54 | github.com/google/uuid v1.6.0 // indirect 55 | github.com/josharian/intern v1.0.0 // indirect 56 | github.com/json-iterator/go v1.1.12 // indirect 57 | github.com/mailru/easyjson v0.9.1 // indirect 58 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 59 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 60 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 61 | github.com/pkg/errors v0.9.1 // indirect 62 | github.com/prometheus/client_golang v1.22.0 // indirect 63 | github.com/prometheus/client_model v0.6.2 // indirect 64 | github.com/prometheus/common v0.65.0 // indirect 65 | github.com/prometheus/procfs v0.16.1 // indirect 66 | github.com/spf13/pflag v1.0.10 // indirect 67 | github.com/x448/float16 v0.8.4 // indirect 68 | go.uber.org/automaxprocs v1.6.0 // indirect 69 | go.uber.org/multierr v1.11.0 // indirect 70 | go.uber.org/zap v1.27.0 // indirect 71 | go.yaml.in/yaml/v2 v2.4.3 // indirect 72 | go.yaml.in/yaml/v3 v3.0.4 // indirect 73 | golang.org/x/net v0.43.0 // indirect 74 | golang.org/x/oauth2 v0.30.0 // indirect 75 | golang.org/x/sync v0.16.0 // indirect 76 | golang.org/x/sys v0.35.0 // indirect 77 | golang.org/x/term v0.34.0 // indirect 78 | golang.org/x/text v0.28.0 // indirect 79 | golang.org/x/time v0.12.0 // indirect 80 | golang.org/x/tools v0.36.0 // indirect 81 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 82 | google.golang.org/protobuf v1.36.10 // indirect 83 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 84 | gopkg.in/inf.v0 v0.9.1 // indirect 85 | gopkg.in/yaml.v3 v3.0.1 // indirect 86 | k8s.io/apiextensions-apiserver v0.34.1 // indirect 87 | k8s.io/klog/v2 v2.130.1 // indirect 88 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect 89 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect 90 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 91 | sigs.k8s.io/randfill v1.0.0 // indirect 92 | sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect 93 | sigs.k8s.io/yaml v1.6.0 // indirect 94 | ) 95 | -------------------------------------------------------------------------------- /internal/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 RajSingh. 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 | "path/filepath" 23 | "runtime" 24 | "testing" 25 | 26 | . "github.com/onsi/ginkgo/v2" 27 | . "github.com/onsi/gomega" 28 | 29 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 30 | "k8s.io/client-go/kubernetes/scheme" 31 | "k8s.io/client-go/rest" 32 | "sigs.k8s.io/controller-runtime/pkg/client" 33 | "sigs.k8s.io/controller-runtime/pkg/envtest" 34 | logf "sigs.k8s.io/controller-runtime/pkg/log" 35 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 36 | 37 | networkingv1 "k8s.io/api/networking/v1" 38 | gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" 39 | 40 | homerv1alpha1 "github.com/rajsinghtech/homer-operator/api/v1alpha1" 41 | //+kubebuilder:scaffold:imports 42 | ) 43 | 44 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 45 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 46 | 47 | var cfg *rest.Config 48 | var k8sClient client.Client 49 | var testEnv *envtest.Environment 50 | 51 | // isGatewayAPIAvailable checks if Gateway API CRDs are available in the test environment 52 | func isGatewayAPIAvailable() bool { 53 | // Try to create a minimal HTTPRoute to test if the CRD is available 54 | httproute := &gatewayv1.HTTPRoute{ 55 | ObjectMeta: metav1.ObjectMeta{ 56 | Name: "test-availability", 57 | Namespace: "default", 58 | }, 59 | Spec: gatewayv1.HTTPRouteSpec{ 60 | Hostnames: []gatewayv1.Hostname{"test.example.com"}, 61 | }, 62 | } 63 | 64 | err := k8sClient.Create(context.Background(), httproute) 65 | if err != nil { 66 | return false 67 | } 68 | 69 | // Clean up the test resource 70 | _ = k8sClient.Delete(context.Background(), httproute) 71 | return true 72 | } 73 | 74 | func TestControllers(t *testing.T) { 75 | RegisterFailHandler(Fail) 76 | 77 | RunSpecs(t, "Controller Suite") 78 | } 79 | 80 | var _ = BeforeSuite(func() { 81 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 82 | 83 | By("bootstrapping test environment") 84 | testEnv = &envtest.Environment{ 85 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, 86 | ErrorIfCRDPathMissing: false, // Allow tests to run without Gateway API CRDs 87 | 88 | // The BinaryAssetsDirectory is only required if you want to run the tests directly 89 | // without call the makefile target test. If not informed it will look for the 90 | // default path defined in controller-runtime which is /usr/local/kubebuilder/. 91 | // Note that you must have the required binaries setup under the bin directory to perform 92 | // the tests directly. When we run make test it will be setup and used automatically. 93 | BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", 94 | fmt.Sprintf("1.29.0-%s-%s", runtime.GOOS, runtime.GOARCH)), 95 | } 96 | 97 | var err error 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 | err = homerv1alpha1.AddToScheme(scheme.Scheme) 104 | Expect(err).NotTo(HaveOccurred()) 105 | 106 | err = networkingv1.AddToScheme(scheme.Scheme) 107 | Expect(err).NotTo(HaveOccurred()) 108 | 109 | // Add Gateway API scheme if available - HTTPRoute tests will be skipped if not available 110 | err = gatewayv1.Install(scheme.Scheme) 111 | if err != nil { 112 | logf.Log.Info("Gateway API scheme not available, HTTPRoute tests will be skipped", "error", err) 113 | } 114 | 115 | //+kubebuilder:scaffold:scheme 116 | 117 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 118 | Expect(err).NotTo(HaveOccurred()) 119 | Expect(k8sClient).NotTo(BeNil()) 120 | 121 | }) 122 | 123 | var _ = AfterSuite(func() { 124 | By("tearing down the test environment") 125 | err := testEnv.Stop() 126 | Expect(err).NotTo(HaveOccurred()) 127 | }) 128 | -------------------------------------------------------------------------------- /charts/homer-operator/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for homer-operator 2 | 3 | replicaCount: 1 4 | 5 | image: 6 | repository: ghcr.io/rajsinghtech/homer-operator 7 | pullPolicy: Always 8 | tag: "" 9 | 10 | imagePullSecrets: [] 11 | nameOverride: "" 12 | fullnameOverride: "" 13 | 14 | # Operator configuration 15 | operator: 16 | enableGatewayAPI: false 17 | # Operator behavior configuration 18 | reconcileInterval: "30s" 19 | maxConcurrentReconciles: 1 20 | # Logging configuration 21 | logLevel: "info" 22 | logFormat: "json" 23 | leaderElection: 24 | enabled: true 25 | leaseDuration: "15s" 26 | renewDeadline: "10s" 27 | retryPeriod: "2s" 28 | # Metrics configuration 29 | metrics: 30 | enabled: true 31 | # Bind address for metrics server 32 | bindAddress: ":8080" 33 | # Secure metrics serving 34 | secureMetrics: true 35 | # Kube RBAC Proxy configuration for secure metrics 36 | rbacProxy: 37 | image: 38 | repository: gcr.io/kubebuilder/kube-rbac-proxy 39 | tag: v0.16.0 40 | pullPolicy: Always 41 | resources: 42 | limits: 43 | cpu: 500m 44 | memory: 128Mi 45 | requests: 46 | cpu: 5m 47 | memory: 64Mi 48 | healthProbe: 49 | bindAddress: ":8081" 50 | 51 | serviceAccount: 52 | create: true 53 | automount: true 54 | annotations: {} 55 | name: "" 56 | 57 | # RBAC configuration 58 | rbac: 59 | create: true 60 | annotations: {} 61 | 62 | # Custom Resource Definition configuration 63 | crd: 64 | create: true 65 | annotations: {} 66 | 67 | podAnnotations: {} 68 | podLabels: {} 69 | 70 | podSecurityContext: 71 | runAsNonRoot: true 72 | runAsUser: 1000 73 | runAsGroup: 1000 74 | fsGroup: 1000 75 | 76 | securityContext: 77 | allowPrivilegeEscalation: false 78 | readOnlyRootFilesystem: true 79 | seccompProfile: 80 | type: RuntimeDefault 81 | capabilities: 82 | drop: 83 | - ALL 84 | 85 | # Service configurations 86 | services: 87 | # Webhook service (main service) 88 | webhook: 89 | type: ClusterIP 90 | port: 8443 91 | annotations: {} 92 | # Metrics service 93 | metrics: 94 | enabled: true 95 | type: ClusterIP 96 | port: 8443 97 | annotations: {} 98 | 99 | # ServiceMonitor configuration for Prometheus Operator 100 | serviceMonitor: 101 | enabled: false 102 | interval: 30s 103 | scrapeTimeout: 10s 104 | labels: {} 105 | annotations: {} 106 | 107 | resources: 108 | limits: 109 | cpu: 200m 110 | memory: 128Mi 111 | requests: 112 | cpu: 50m 113 | memory: 64Mi 114 | 115 | livenessProbe: 116 | httpGet: 117 | path: /healthz 118 | port: 8081 119 | initialDelaySeconds: 15 120 | periodSeconds: 20 121 | 122 | readinessProbe: 123 | httpGet: 124 | path: /readyz 125 | port: 8081 126 | initialDelaySeconds: 5 127 | periodSeconds: 10 128 | 129 | # Startup probe configuration 130 | startupProbe: 131 | enabled: false 132 | httpGet: 133 | path: /readyz 134 | port: 8081 135 | initialDelaySeconds: 10 136 | periodSeconds: 10 137 | failureThreshold: 30 138 | 139 | # Scheduling and placement 140 | scheduling: 141 | nodeSelector: {} 142 | tolerations: [] 143 | affinity: {} 144 | priorityClassName: "" 145 | 146 | # Additional volumes and mounts 147 | volumes: [] 148 | volumeMounts: [] 149 | 150 | # High availability and scaling 151 | highAvailability: 152 | podDisruptionBudget: 153 | enabled: true 154 | minAvailable: 1 155 | autoscaling: 156 | enabled: false 157 | minReplicas: 1 158 | maxReplicas: 3 159 | targetCPUUtilizationPercentage: 80 160 | targetMemoryUtilizationPercentage: 80 161 | 162 | # VerticalPodAutoscaler configuration 163 | vpa: 164 | enabled: false 165 | updateMode: "Auto" 166 | controlledResources: ["cpu", "memory"] 167 | maxAllowed: 168 | cpu: 1 169 | memory: 512Mi 170 | minAllowed: 171 | cpu: 10m 172 | memory: 32Mi 173 | labels: {} 174 | annotations: {} 175 | 176 | # Topology spread constraints 177 | topologySpreadConstraints: [] 178 | # Example: 179 | # - maxSkew: 1 180 | # topologyKey: topology.kubernetes.io/zone 181 | # whenUnsatisfiable: DoNotSchedule 182 | # labelSelector: 183 | # matchLabels: 184 | # app.kubernetes.io/name: homer-operator 185 | 186 | # PrometheusRule configuration 187 | prometheusRule: 188 | enabled: false 189 | additionalRules: [] 190 | labels: {} 191 | annotations: {} 192 | 193 | # Grafana Dashboard configuration 194 | grafanaDashboard: 195 | enabled: false 196 | labels: {} 197 | annotations: {} 198 | 199 | # Deployment strategy configuration 200 | deploymentStrategy: 201 | type: RollingUpdate 202 | rollingUpdate: 203 | maxUnavailable: 1 204 | maxSurge: 1 205 | 206 | # Termination grace period 207 | terminationGracePeriodSeconds: 10 208 | 209 | # Environment variables 210 | env: [] 211 | # Example: 212 | # - name: LOG_LEVEL 213 | # value: "debug" 214 | # - name: SECRET_VALUE 215 | # valueFrom: 216 | # secretKeyRef: 217 | # name: my-secret 218 | # key: password 219 | 220 | envFrom: [] 221 | # Example: 222 | # - configMapRef: 223 | # name: my-config 224 | # - secretRef: 225 | # name: my-secret -------------------------------------------------------------------------------- /charts/homer-operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "homer-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 "homer-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 "homer-operator.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "homer-operator.labels" -}} 37 | helm.sh/chart: {{ include "homer-operator.chart" . }} 38 | {{ include "homer-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 | app.kubernetes.io/part-of: homer-operator 44 | {{- end }} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "homer-operator.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "homer-operator.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end }} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "homer-operator.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create }} 59 | {{- default (include "homer-operator.fullname" .) .Values.serviceAccount.name }} 60 | {{- else }} 61 | {{- default "default" .Values.serviceAccount.name }} 62 | {{- end }} 63 | {{- end }} 64 | 65 | {{/* 66 | Create the name of the namespace to use 67 | */}} 68 | {{- define "homer-operator.namespace" -}} 69 | {{- .Release.Namespace }} 70 | {{- end }} 71 | 72 | {{/* 73 | Create the name of the manager deployment 74 | */}} 75 | {{- define "homer-operator.managerName" -}} 76 | {{- include "homer-operator.fullname" . }} 77 | {{- end }} 78 | 79 | {{/* 80 | Create the name of the metrics service 81 | */}} 82 | {{- define "homer-operator.metricsServiceName" -}} 83 | {{- printf "%s-metrics" (include "homer-operator.fullname" .) }} 84 | {{- end }} 85 | 86 | {{/* 87 | Create the name of the webhook service 88 | */}} 89 | {{- define "homer-operator.webhookServiceName" -}} 90 | {{- printf "%s-webhook" (include "homer-operator.fullname" .) }} 91 | {{- end }} 92 | 93 | {{/* 94 | Create leader election role name 95 | */}} 96 | {{- define "homer-operator.leaderElectionRoleName" -}} 97 | {{- printf "%s-leader-election" (include "homer-operator.fullname" .) }} 98 | {{- end }} 99 | 100 | {{/* 101 | Create manager role name 102 | */}} 103 | {{- define "homer-operator.managerRoleName" -}} 104 | {{- printf "%s-manager" (include "homer-operator.fullname" .) }} 105 | {{- end }} 106 | 107 | {{/* 108 | Create metrics reader role name 109 | */}} 110 | {{- define "homer-operator.metricsReaderRoleName" -}} 111 | {{- printf "%s-metrics-reader" (include "homer-operator.fullname" .) }} 112 | {{- end }} 113 | 114 | {{/* 115 | Create proxy role name 116 | */}} 117 | {{- define "homer-operator.proxyRoleName" -}} 118 | {{- printf "%s-proxy" (include "homer-operator.fullname" .) }} 119 | {{- end }} 120 | 121 | {{/* 122 | Create proxy role binding name 123 | */}} 124 | {{- define "homer-operator.proxyRoleBindingName" -}} 125 | {{- printf "%s-proxy" (include "homer-operator.fullname" .) }} 126 | {{- end }} 127 | 128 | {{/* 129 | Create the image name 130 | */}} 131 | {{- define "homer-operator.image" -}} 132 | {{- if .Values.image.tag }} 133 | {{- printf "%s:%s" .Values.image.repository .Values.image.tag }} 134 | {{- else }} 135 | {{- printf "%s:%s" .Values.image.repository .Chart.AppVersion }} 136 | {{- end }} 137 | {{- end }} 138 | 139 | {{/* 140 | Create environment variables for the operator 141 | */}} 142 | {{- define "homer-operator.env" -}} 143 | {{- if .Values.operator.enableGatewayAPI }} 144 | - name: ENABLE_GATEWAY_API 145 | value: "true" 146 | {{- end }} 147 | {{- with .Values.env }} 148 | {{- toYaml . }} 149 | {{- end }} 150 | {{- end }} 151 | 152 | {{/* 153 | Create environment variables from secrets/configmaps 154 | */}} 155 | {{- define "homer-operator.envFrom" -}} 156 | {{- with .Values.envFrom }} 157 | {{- toYaml . }} 158 | {{- end }} 159 | {{- end }} 160 | 161 | {{/* 162 | Standardized annotations helper 163 | */}} 164 | {{- define "homer-operator.annotations" -}} 165 | {{- $annotations := . -}} 166 | {{- if $annotations }} 167 | annotations: 168 | {{- toYaml $annotations | nindent 2 }} 169 | {{- end }} 170 | {{- end }} 171 | 172 | {{/* 173 | Component labels helper 174 | */}} 175 | {{- define "homer-operator.componentLabels" -}} 176 | {{- $component := . -}} 177 | {{- if $component }} 178 | app.kubernetes.io/component: {{ $component }} 179 | {{- end }} 180 | {{- end }} -------------------------------------------------------------------------------- /pkg/utils/filters_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | networkingv1 "k8s.io/api/networking/v1" 7 | gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" 8 | ) 9 | 10 | func TestMatchesHostDomainFilters(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | hostname string 14 | domainFilters []string 15 | expected bool 16 | }{ 17 | { 18 | name: "no filters - should match", 19 | hostname: "example.com", 20 | domainFilters: []string{}, 21 | expected: true, 22 | }, 23 | { 24 | name: "exact match", 25 | hostname: "example.com", 26 | domainFilters: []string{"example.com"}, 27 | expected: true, 28 | }, 29 | { 30 | name: "subdomain match", 31 | hostname: "api.example.com", 32 | domainFilters: []string{"example.com"}, 33 | expected: true, 34 | }, 35 | { 36 | name: "no match", 37 | hostname: "other.com", 38 | domainFilters: []string{"example.com"}, 39 | expected: false, 40 | }, 41 | { 42 | name: "multiple filters - match first", 43 | hostname: "api.example.com", 44 | domainFilters: []string{"example.com", "other.com"}, 45 | expected: true, 46 | }, 47 | { 48 | name: "multiple filters - match second", 49 | hostname: "api.other.com", 50 | domainFilters: []string{"example.com", "other.com"}, 51 | expected: true, 52 | }, 53 | { 54 | name: "multiple filters - no match", 55 | hostname: "unmatched.com", 56 | domainFilters: []string{"example.com", "other.com"}, 57 | expected: false, 58 | }, 59 | } 60 | 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | result := MatchesHostDomainFilters(tt.hostname, tt.domainFilters) 64 | if result != tt.expected { 65 | t.Errorf("MatchesHostDomainFilters(%q, %v) = %v; want %v", 66 | tt.hostname, tt.domainFilters, result, tt.expected) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func TestMatchesIngressDomainFilters(t *testing.T) { 73 | // Create helper function for Ingress creation 74 | createIngress := func(hosts ...string) *networkingv1.Ingress { 75 | var rules []networkingv1.IngressRule 76 | for _, host := range hosts { 77 | rules = append(rules, networkingv1.IngressRule{Host: host}) 78 | } 79 | return &networkingv1.Ingress{ 80 | Spec: networkingv1.IngressSpec{Rules: rules}, 81 | } 82 | } 83 | 84 | tests := []struct { 85 | name string 86 | ingress *networkingv1.Ingress 87 | domainFilters []string 88 | expected bool 89 | }{ 90 | { 91 | name: "ingress with matching host", 92 | ingress: createIngress("api.example.com"), 93 | domainFilters: []string{"example.com"}, 94 | expected: true, 95 | }, 96 | { 97 | name: "ingress with non-matching host", 98 | ingress: createIngress("api.other.com"), 99 | domainFilters: []string{"example.com"}, 100 | expected: false, 101 | }, 102 | { 103 | name: "ingress with empty host", 104 | ingress: createIngress(""), 105 | domainFilters: []string{"example.com"}, 106 | expected: false, 107 | }, 108 | { 109 | name: "ingress with multiple hosts - one matches", 110 | ingress: createIngress("api.other.com", "api.example.com"), 111 | domainFilters: []string{"example.com"}, 112 | expected: true, 113 | }, 114 | } 115 | 116 | for _, tt := range tests { 117 | t.Run(tt.name, func(t *testing.T) { 118 | result := MatchesIngressDomainFilters(tt.ingress, tt.domainFilters) 119 | if result != tt.expected { 120 | t.Errorf("MatchesIngressDomainFilters() = %v; want %v", result, tt.expected) 121 | } 122 | }) 123 | } 124 | } 125 | 126 | func TestMatchesHTTPRouteDomainFilters(t *testing.T) { 127 | tests := []struct { 128 | name string 129 | hostnames []gatewayv1.Hostname 130 | domainFilters []string 131 | expected bool 132 | }{ 133 | { 134 | name: "matching hostname", 135 | hostnames: []gatewayv1.Hostname{"api.example.com"}, 136 | domainFilters: []string{"example.com"}, 137 | expected: true, 138 | }, 139 | { 140 | name: "non-matching hostname", 141 | hostnames: []gatewayv1.Hostname{"api.other.com"}, 142 | domainFilters: []string{"example.com"}, 143 | expected: false, 144 | }, 145 | { 146 | name: "multiple hostnames - one matches", 147 | hostnames: []gatewayv1.Hostname{"api.other.com", "api.example.com"}, 148 | domainFilters: []string{"example.com"}, 149 | expected: true, 150 | }, 151 | { 152 | name: "no hostnames", 153 | hostnames: []gatewayv1.Hostname{}, 154 | domainFilters: []string{"example.com"}, 155 | expected: false, 156 | }, 157 | { 158 | name: "no filters", 159 | hostnames: []gatewayv1.Hostname{"api.example.com"}, 160 | domainFilters: []string{}, 161 | expected: true, 162 | }, 163 | } 164 | 165 | for _, tt := range tests { 166 | t.Run(tt.name, func(t *testing.T) { 167 | result := MatchesHTTPRouteDomainFilters(tt.hostnames, tt.domainFilters) 168 | if result != tt.expected { 169 | t.Errorf("MatchesHTTPRouteDomainFilters() = %v; want %v", result, tt.expected) 170 | } 171 | }) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 RajSingh. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "crypto/tls" 21 | "flag" 22 | "os" 23 | "strconv" 24 | 25 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 26 | // to ensure that exec-entrypoint and run can make use of them. 27 | _ "k8s.io/client-go/plugin/pkg/client/auth" 28 | 29 | "k8s.io/apimachinery/pkg/runtime" 30 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 31 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 32 | ctrl "sigs.k8s.io/controller-runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/healthz" 34 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 35 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 36 | 37 | homerv1alpha1 "github.com/rajsinghtech/homer-operator/api/v1alpha1" 38 | "github.com/rajsinghtech/homer-operator/internal/controller" 39 | gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" 40 | //+kubebuilder:scaffold:imports 41 | ) 42 | 43 | var ( 44 | scheme = runtime.NewScheme() 45 | setupLog = ctrl.Log.WithName("setup") 46 | ) 47 | 48 | func init() { 49 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 50 | 51 | utilruntime.Must(homerv1alpha1.AddToScheme(scheme)) 52 | utilruntime.Must(gatewayv1.Install(scheme)) 53 | //+kubebuilder:scaffold:scheme 54 | } 55 | 56 | func main() { 57 | var metricsAddr string 58 | var enableLeaderElection bool 59 | var probeAddr string 60 | var secureMetrics bool 61 | var enableHTTP2 bool 62 | var enableGatewayAPI bool 63 | var leaderElectionID string 64 | flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") 65 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 66 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election") 67 | flag.StringVar(&leaderElectionID, "leader-election-id", "3f6c65b5.rajsingh.info", "Leader election ID") 68 | flag.BoolVar(&secureMetrics, "metrics-secure", false, "Serve metrics securely") 69 | flag.BoolVar(&enableHTTP2, "enable-http2", false, "Enable HTTP/2") 70 | flag.BoolVar(&enableGatewayAPI, "enable-gateway-api", false, "Enable Gateway API support") 71 | opts := zap.Options{ 72 | Development: true, 73 | } 74 | opts.BindFlags(flag.CommandLine) 75 | flag.Parse() 76 | 77 | if envGateway := os.Getenv("ENABLE_GATEWAY_API"); envGateway != "" { 78 | if parsed, err := strconv.ParseBool(envGateway); err == nil { 79 | enableGatewayAPI = parsed 80 | } 81 | } 82 | 83 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 84 | 85 | var tlsOpts []func(*tls.Config) 86 | if !enableHTTP2 { 87 | tlsOpts = append(tlsOpts, func(c *tls.Config) { 88 | c.NextProtos = []string{"http/1.1"} 89 | }) 90 | } 91 | 92 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 93 | Scheme: scheme, 94 | Metrics: metricsserver.Options{ 95 | BindAddress: metricsAddr, 96 | SecureServing: secureMetrics, 97 | TLSOpts: tlsOpts, 98 | }, 99 | HealthProbeBindAddress: probeAddr, 100 | LeaderElection: enableLeaderElection, 101 | LeaderElectionID: leaderElectionID, 102 | }) 103 | if err != nil { 104 | setupLog.Error(err, "unable to start manager") 105 | os.Exit(1) 106 | } 107 | 108 | if err = (&controller.DashboardReconciler{ 109 | Client: mgr.GetClient(), 110 | Scheme: mgr.GetScheme(), 111 | EnableGatewayAPI: enableGatewayAPI, 112 | ClusterManager: controller.NewClusterManager(mgr.GetClient(), mgr.GetScheme()), 113 | }).SetupWithManager(mgr); err != nil { 114 | setupLog.Error(err, "unable to create controller", "controller", "Dashboard") 115 | os.Exit(1) 116 | } 117 | ingressController := &controller.GenericResourceReconciler{ 118 | Client: mgr.GetClient(), 119 | Scheme: mgr.GetScheme(), 120 | } 121 | if err = ingressController.SetupIngressController(mgr); err != nil { 122 | setupLog.Error(err, "unable to create controller", "controller", "Ingress") 123 | os.Exit(1) 124 | } 125 | 126 | if enableGatewayAPI { 127 | httpRouteController := &controller.GenericResourceReconciler{ 128 | Client: mgr.GetClient(), 129 | Scheme: mgr.GetScheme(), 130 | } 131 | if err = httpRouteController.SetupHTTPRouteController(mgr); err != nil { 132 | setupLog.Error(err, "unable to create controller", "controller", "HTTPRoute") 133 | os.Exit(1) 134 | } 135 | } 136 | //+kubebuilder:scaffold:builder 137 | 138 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 139 | setupLog.Error(err, "unable to set up health check") 140 | os.Exit(1) 141 | } 142 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 143 | setupLog.Error(err, "unable to set up ready check") 144 | os.Exit(1) 145 | } 146 | 147 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 148 | setupLog.Error(err, "problem running manager") 149 | os.Exit(1) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /charts/homer-operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "homer-operator.managerName" . }} 5 | namespace: {{ include "homer-operator.namespace" . }} 6 | labels: 7 | {{- include "homer-operator.labels" . | nindent 4 }} 8 | control-plane: controller-manager 9 | app.kubernetes.io/component: manager 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | strategy: 13 | type: {{ .Values.deploymentStrategy.type }} 14 | {{- if eq .Values.deploymentStrategy.type "RollingUpdate" }} 15 | rollingUpdate: 16 | maxUnavailable: {{ .Values.deploymentStrategy.rollingUpdate.maxUnavailable }} 17 | maxSurge: {{ .Values.deploymentStrategy.rollingUpdate.maxSurge }} 18 | {{- end }} 19 | selector: 20 | matchLabels: 21 | {{- include "homer-operator.selectorLabels" . | nindent 6 }} 22 | control-plane: controller-manager 23 | template: 24 | metadata: 25 | {{- with .Values.podAnnotations }} 26 | annotations: 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | labels: 30 | {{- include "homer-operator.labels" . | nindent 8 }} 31 | control-plane: controller-manager 32 | {{- with .Values.podLabels }} 33 | {{- toYaml . | nindent 8 }} 34 | {{- end }} 35 | spec: 36 | {{- with .Values.imagePullSecrets }} 37 | imagePullSecrets: 38 | {{- toYaml . | nindent 8 }} 39 | {{- end }} 40 | serviceAccountName: {{ include "homer-operator.serviceAccountName" . }} 41 | securityContext: 42 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 43 | terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} 44 | containers: 45 | - name: manager 46 | image: {{ include "homer-operator.image" . }} 47 | imagePullPolicy: {{ .Values.image.pullPolicy }} 48 | command: 49 | - /manager 50 | args: 51 | {{- if .Values.operator.leaderElection.enabled }} 52 | - --leader-elect 53 | {{- end }} 54 | {{- if .Values.operator.metrics.enabled }} 55 | - --metrics-bind-address={{ .Values.operator.metrics.bindAddress }} 56 | {{- if .Values.operator.metrics.secureMetrics }} 57 | - --metrics-secure 58 | {{- end }} 59 | {{- end }} 60 | - --health-probe-bind-address={{ .Values.operator.healthProbe.bindAddress }} 61 | {{- if .Values.operator.enableGatewayAPI }} 62 | - --enable-gateway-api 63 | {{- end }} 64 | {{- $envVars := include "homer-operator.env" . }} 65 | {{- if $envVars }} 66 | env: 67 | {{- $envVars | nindent 8 }} 68 | {{- end }} 69 | {{- $envFromVars := include "homer-operator.envFrom" . }} 70 | {{- if $envFromVars }} 71 | envFrom: 72 | {{- $envFromVars | nindent 8 }} 73 | {{- end }} 74 | ports: 75 | - containerPort: {{ .Values.operator.healthProbe.bindAddress | replace ":" "" | int }} 76 | name: health 77 | protocol: TCP 78 | {{- if .Values.operator.metrics.enabled }} 79 | - containerPort: {{ .Values.operator.metrics.bindAddress | replace ":" "" | int }} 80 | name: metrics 81 | protocol: TCP 82 | {{- end }} 83 | livenessProbe: 84 | {{- toYaml .Values.livenessProbe | nindent 10 }} 85 | readinessProbe: 86 | {{- toYaml .Values.readinessProbe | nindent 10 }} 87 | {{- if .Values.startupProbe.enabled }} 88 | startupProbe: 89 | {{- toYaml .Values.startupProbe | nindent 10 }} 90 | {{- end }} 91 | resources: 92 | {{- toYaml .Values.resources | nindent 10 }} 93 | securityContext: 94 | {{- toYaml .Values.securityContext | nindent 10 }} 95 | {{- with .Values.volumeMounts }} 96 | volumeMounts: 97 | {{- toYaml . | nindent 10 }} 98 | {{- end }} 99 | {{- if .Values.operator.metrics.enabled }} 100 | - name: kube-rbac-proxy 101 | image: {{ .Values.operator.metrics.rbacProxy.image.repository }}:{{ .Values.operator.metrics.rbacProxy.image.tag }} 102 | imagePullPolicy: {{ .Values.operator.metrics.rbacProxy.image.pullPolicy }} 103 | args: 104 | - --secure-listen-address=0.0.0.0:{{ .Values.services.metrics.port }} 105 | - --upstream=http://127.0.0.1:{{ .Values.operator.metrics.bindAddress | replace ":" "" }}/ 106 | - --v=0 107 | ports: 108 | - containerPort: {{ .Values.services.metrics.port }} 109 | name: https 110 | protocol: TCP 111 | resources: 112 | {{- toYaml .Values.operator.metrics.rbacProxy.resources | nindent 10 }} 113 | securityContext: 114 | {{- toYaml .Values.securityContext | nindent 10 }} 115 | {{- end }} 116 | {{- with .Values.volumes }} 117 | volumes: 118 | {{- toYaml . | nindent 8 }} 119 | {{- end }} 120 | {{- with .Values.scheduling }} 121 | {{- with .nodeSelector }} 122 | nodeSelector: 123 | {{- toYaml . | nindent 8 }} 124 | {{- end }} 125 | {{- with .affinity }} 126 | affinity: 127 | {{- toYaml . | nindent 8 }} 128 | {{- end }} 129 | {{- with .tolerations }} 130 | tolerations: 131 | {{- toYaml . | nindent 8 }} 132 | {{- end }} 133 | {{- with .priorityClassName }} 134 | priorityClassName: {{ . }} 135 | {{- end }} 136 | {{- with $.Values.topologySpreadConstraints }} 137 | topologySpreadConstraints: 138 | {{- toYaml . | nindent 8 }} 139 | {{- end }} 140 | {{- end }} -------------------------------------------------------------------------------- /charts/homer-operator/values.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "replicaCount": { 6 | "type": "integer", 7 | "minimum": 1, 8 | "maximum": 10 9 | }, 10 | "image": { 11 | "type": "object", 12 | "properties": { 13 | "repository": { 14 | "type": "string", 15 | "pattern": "^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$" 16 | }, 17 | "pullPolicy": { 18 | "type": "string", 19 | "enum": ["Always", "IfNotPresent", "Never"] 20 | }, 21 | "tag": { 22 | "type": "string" 23 | } 24 | }, 25 | "required": ["repository", "pullPolicy"] 26 | }, 27 | "operator": { 28 | "type": "object", 29 | "properties": { 30 | "enableGatewayAPI": { 31 | "type": "boolean" 32 | }, 33 | "logLevel": { 34 | "type": "string", 35 | "enum": ["debug", "info", "warn", "error"] 36 | }, 37 | "logFormat": { 38 | "type": "string", 39 | "enum": ["json", "console"] 40 | }, 41 | "reconcileInterval": { 42 | "type": "string", 43 | "pattern": "^[0-9]+[smh]$" 44 | }, 45 | "maxConcurrentReconciles": { 46 | "type": "integer", 47 | "minimum": 1, 48 | "maximum": 10 49 | }, 50 | "leaderElection": { 51 | "type": "object", 52 | "properties": { 53 | "enabled": { 54 | "type": "boolean" 55 | }, 56 | "leaseDuration": { 57 | "type": "string", 58 | "pattern": "^[0-9]+[smh]$" 59 | }, 60 | "renewDeadline": { 61 | "type": "string", 62 | "pattern": "^[0-9]+[smh]$" 63 | }, 64 | "retryPeriod": { 65 | "type": "string", 66 | "pattern": "^[0-9]+[smh]$" 67 | } 68 | } 69 | }, 70 | "metrics": { 71 | "type": "object", 72 | "properties": { 73 | "enabled": { 74 | "type": "boolean" 75 | }, 76 | "bindAddress": { 77 | "type": "string", 78 | "pattern": "^:[0-9]+$" 79 | }, 80 | "secureMetrics": { 81 | "type": "boolean" 82 | } 83 | } 84 | } 85 | } 86 | }, 87 | "resources": { 88 | "type": "object", 89 | "properties": { 90 | "limits": { 91 | "type": "object", 92 | "properties": { 93 | "cpu": { 94 | "type": "string", 95 | "pattern": "^[0-9]+m?$" 96 | }, 97 | "memory": { 98 | "type": "string", 99 | "pattern": "^[0-9]+[KMGT]?i?$" 100 | } 101 | } 102 | }, 103 | "requests": { 104 | "type": "object", 105 | "properties": { 106 | "cpu": { 107 | "type": "string", 108 | "pattern": "^[0-9]+m?$" 109 | }, 110 | "memory": { 111 | "type": "string", 112 | "pattern": "^[0-9]+[KMGT]?i?$" 113 | } 114 | } 115 | } 116 | } 117 | }, 118 | "highAvailability": { 119 | "type": "object", 120 | "properties": { 121 | "podDisruptionBudget": { 122 | "type": "object", 123 | "properties": { 124 | "enabled": { 125 | "type": "boolean" 126 | }, 127 | "minAvailable": { 128 | "type": "integer", 129 | "minimum": 1 130 | } 131 | } 132 | }, 133 | "autoscaling": { 134 | "type": "object", 135 | "properties": { 136 | "enabled": { 137 | "type": "boolean" 138 | }, 139 | "minReplicas": { 140 | "type": "integer", 141 | "minimum": 1 142 | }, 143 | "maxReplicas": { 144 | "type": "integer", 145 | "minimum": 1 146 | }, 147 | "targetCPUUtilizationPercentage": { 148 | "type": "integer", 149 | "minimum": 1, 150 | "maximum": 100 151 | }, 152 | "targetMemoryUtilizationPercentage": { 153 | "type": "integer", 154 | "minimum": 1, 155 | "maximum": 100 156 | } 157 | } 158 | } 159 | } 160 | }, 161 | "vpa": { 162 | "type": "object", 163 | "properties": { 164 | "enabled": { 165 | "type": "boolean" 166 | }, 167 | "updateMode": { 168 | "type": "string", 169 | "enum": ["Off", "Initial", "Recreation", "Auto"] 170 | } 171 | } 172 | }, 173 | "prometheusRule": { 174 | "type": "object", 175 | "properties": { 176 | "enabled": { 177 | "type": "boolean" 178 | } 179 | } 180 | }, 181 | "grafanaDashboard": { 182 | "type": "object", 183 | "properties": { 184 | "enabled": { 185 | "type": "boolean" 186 | } 187 | } 188 | }, 189 | "deploymentStrategy": { 190 | "type": "object", 191 | "properties": { 192 | "type": { 193 | "type": "string", 194 | "enum": ["RollingUpdate", "Recreate"] 195 | } 196 | } 197 | }, 198 | "terminationGracePeriodSeconds": { 199 | "type": "integer", 200 | "minimum": 0, 201 | "maximum": 300 202 | } 203 | }, 204 | "required": ["replicaCount", "image", "operator"] 205 | } -------------------------------------------------------------------------------- /charts/homer-operator/README.md: -------------------------------------------------------------------------------- 1 | # Homer Operator Helm Chart 2 | 3 | A Helm chart for deploying the Homer Operator on Kubernetes. The Homer Operator manages Homer dashboard instances that automatically discover and display your Kubernetes services. 4 | 5 | ## Prerequisites 6 | 7 | - Kubernetes 1.19+ 8 | - Helm 3.8+ 9 | 10 | ## Installation 11 | 12 | ### Install from OCI Registry (Recommended) 13 | 14 | ```bash 15 | helm install homer-operator oci://ghcr.io/rajsinghtech/homer-operator/charts/homer-operator --version 0.0.0-latest -n homer-operator --create-namespace 16 | ``` 17 | 18 | ### Install from Source 19 | 20 | ```bash 21 | git clone https://github.com/rajsinghtech/homer-operator.git 22 | cd homer-operator 23 | helm install homer-operator charts/homer-operator -n homer-operator --create-namespace 24 | ``` 25 | 26 | ## Configuration 27 | 28 | The following table lists the configurable parameters of the Homer Operator chart and their default values. 29 | 30 | | Parameter | Description | Default | 31 | | --- | --- | --- | 32 | | `replicaCount` | Number of operator replicas | `1` | 33 | | `image.repository` | Operator image repository | `ghcr.io/rajsinghtech/homer-operator` | 34 | | `image.tag` | Operator image tag | `Chart.appVersion` | 35 | | `image.pullPolicy` | Image pull policy | `IfNotPresent` | 36 | | `operator.enableGatewayAPI` | Enable Gateway API support | `false` | 37 | | `operator.metrics.enabled` | Enable metrics collection | `true` | 38 | | `operator.metrics.secureMetrics` | Use secure metrics serving | `true` | 39 | | `serviceAccount.create` | Create service account | `true` | 40 | | `rbac.create` | Create RBAC resources | `true` | 41 | | `crd.create` | Create CustomResourceDefinitions | `true` | 42 | | `namespace.create` | Create namespace | `true` | 43 | | `resources.limits.memory` | Memory limit | `128Mi` | 44 | | `resources.limits.cpu` | CPU limit | `500m` | 45 | | `resources.requests.memory` | Memory request | `64Mi` | 46 | | `resources.requests.cpu` | CPU request | `10m` | 47 | 48 | ## Examples 49 | 50 | ### Basic Installation 51 | 52 | ```bash 53 | helm install homer-operator oci://ghcr.io/rajsinghtech/homer-operator/charts/homer-operator -n homer-operator --create-namespace 54 | ``` 55 | 56 | ### With Custom Values 57 | 58 | ```bash 59 | helm install homer-operator oci://ghcr.io/rajsinghtech/homer-operator/charts/homer-operator \ 60 | -n homer-operator --create-namespace \ 61 | --set operator.enableGatewayAPI=true \ 62 | --set operator.metrics.enabled=false \ 63 | --set resources.limits.memory=256Mi 64 | ``` 65 | 66 | ### With Values File 67 | 68 | ```yaml 69 | # values.yaml 70 | operator: 71 | enableGatewayAPI: true 72 | metrics: 73 | enabled: true 74 | secureMetrics: false 75 | 76 | resources: 77 | limits: 78 | memory: 256Mi 79 | cpu: 1000m 80 | requests: 81 | memory: 128Mi 82 | cpu: 100m 83 | 84 | serviceMonitor: 85 | create: true 86 | interval: 60s 87 | ``` 88 | 89 | ```bash 90 | helm install homer-operator oci://ghcr.io/rajsinghtech/homer-operator/charts/homer-operator -n homer-operator --create-namespace -f values.yaml 91 | ``` 92 | 93 | ## Features 94 | 95 | - **Automatic Service Discovery**: Discovers Kubernetes Ingress resources and creates Homer dashboard entries 96 | - **Gateway API Support**: Optional support for Gateway API HTTPRoute resources 97 | - **Metrics Collection**: Prometheus metrics for monitoring operator performance 98 | - **Security**: Runs with non-root user and restrictive security contexts 99 | - **High Availability**: Configurable replica count and pod disruption budgets 100 | - **Monitoring**: ServiceMonitor support for Prometheus Operator 101 | 102 | ## Usage 103 | 104 | After installing the operator, create a Dashboard resource: 105 | 106 | ```yaml 107 | apiVersion: homer.rajsingh.info/v1alpha1 108 | kind: Dashboard 109 | metadata: 110 | name: my-dashboard 111 | namespace: default 112 | spec: 113 | replicas: 2 114 | homerConfig: 115 | title: "My Services" 116 | subtitle: "Application Dashboard" 117 | logo: "https://example.com/logo.png" 118 | services: 119 | - name: "Web Services" 120 | icon: "fas fa-globe" 121 | items: 122 | - name: "My App" 123 | logo: "https://example.com/app-logo.png" 124 | url: "https://myapp.example.com" 125 | subtitle: "Main Application" 126 | ``` 127 | 128 | ## Troubleshooting 129 | 130 | ### Namespace Creation Issues 131 | 132 | If you encounter `namespaces 'homer-operator' not found`, add the `--create-namespace` flag: 133 | 134 | ```bash 135 | helm upgrade --install homer-operator charts/homer-operator -n homer-operator --create-namespace 136 | ``` 137 | 138 | ## Gateway API Support 139 | 140 | To enable Gateway API support, set `operator.enableGatewayAPI=true`. This requires Gateway API CRDs to be installed in your cluster. 141 | 142 | ## Monitoring 143 | 144 | The operator exposes Prometheus metrics on port 8080. To enable monitoring: 145 | 146 | ```yaml 147 | operator: 148 | metrics: 149 | enabled: true 150 | 151 | serviceMonitor: 152 | create: true 153 | interval: 30s 154 | ``` 155 | 156 | ## Security 157 | 158 | The operator follows security best practices: 159 | 160 | - Runs as non-root user (UID 65532) 161 | - Uses read-only root filesystem 162 | - Drops all capabilities 163 | - Implements least-privilege RBAC 164 | 165 | ## Uninstalling 166 | 167 | ```bash 168 | helm uninstall homer-operator -n homer-operator 169 | ``` 170 | 171 | Note: This will not remove the CustomResourceDefinitions or the namespace. To remove them: 172 | 173 | ```bash 174 | kubectl delete crd dashboards.homer.rajsingh.info 175 | kubectl delete namespace homer-operator 176 | ``` 177 | 178 | ## Support 179 | 180 | For issues and questions, please visit the [GitHub repository](https://github.com/rajsinghtech/homer-operator). -------------------------------------------------------------------------------- /pkg/homer/empty_service_test.go: -------------------------------------------------------------------------------- 1 | package homer 2 | 3 | import ( 4 | "testing" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" 8 | ) 9 | 10 | func TestHTTPRouteWithEmptyServiceName(t *testing.T) { 11 | // Test case 1: HTTPRoute with empty namespace 12 | t.Run("empty namespace", func(t *testing.T) { 13 | httproute := &gatewayv1.HTTPRoute{ 14 | ObjectMeta: metav1.ObjectMeta{ 15 | Name: "test-route", 16 | Namespace: "", // Empty namespace 17 | }, 18 | Spec: gatewayv1.HTTPRouteSpec{ 19 | Hostnames: []gatewayv1.Hostname{"test.rajsingh.info"}, 20 | }, 21 | } 22 | 23 | config := &HomerConfig{Title: "Test Dashboard"} 24 | UpdateHomerConfigHTTPRoute(config, httproute, []string{"rajsingh.info"}) 25 | 26 | if err := ValidateHomerConfig(config); err != nil { 27 | t.Errorf("Validation should pass with empty namespace, but got error: %v", err) 28 | } 29 | 30 | // Check that a service was created with a proper name 31 | if len(config.Services) == 0 { 32 | t.Error("Expected at least one service to be created") 33 | } else { 34 | serviceName := getServiceName(&config.Services[0]) 35 | if serviceName == "" { 36 | t.Error("Service name should not be empty") 37 | } 38 | if serviceName != DefaultNamespace { 39 | t.Errorf("Expected service name to be '%s', got '%s'", DefaultNamespace, serviceName) 40 | } 41 | } 42 | }) 43 | 44 | // Test case 2: HTTPRoute with empty service name annotation 45 | t.Run("empty service name annotation", func(t *testing.T) { 46 | httproute := &gatewayv1.HTTPRoute{ 47 | ObjectMeta: metav1.ObjectMeta{ 48 | Name: "test-route", 49 | Namespace: "test-namespace", 50 | Annotations: map[string]string{ 51 | "service.homer.rajsingh.info/name": "", // Empty service name annotation 52 | }, 53 | }, 54 | Spec: gatewayv1.HTTPRouteSpec{ 55 | Hostnames: []gatewayv1.Hostname{"test.rajsingh.info"}, 56 | }, 57 | } 58 | 59 | config := &HomerConfig{Title: "Test Dashboard"} 60 | UpdateHomerConfigHTTPRoute(config, httproute, []string{"rajsingh.info"}) 61 | 62 | if err := ValidateHomerConfig(config); err != nil { 63 | t.Errorf("Validation should pass with empty service name annotation, but got error: %v", err) 64 | } 65 | 66 | // Check that a service was created with a proper name (should fall back to namespace) 67 | if len(config.Services) == 0 { 68 | t.Error("Expected at least one service to be created") 69 | } else { 70 | serviceName := getServiceName(&config.Services[0]) 71 | if serviceName == "" { 72 | t.Error("Service name should not be empty") 73 | } 74 | if serviceName != "test-namespace" { 75 | t.Errorf("Expected service name to be 'test-namespace', got '%s'", serviceName) 76 | } 77 | } 78 | }) 79 | 80 | // Test case 3: HTTPRoute with no matching hostnames (should not create empty service) 81 | t.Run("no matching hostnames", func(t *testing.T) { 82 | httproute := &gatewayv1.HTTPRoute{ 83 | ObjectMeta: metav1.ObjectMeta{ 84 | Name: "test-route", 85 | Namespace: "test-namespace", 86 | }, 87 | Spec: gatewayv1.HTTPRouteSpec{ 88 | Hostnames: []gatewayv1.Hostname{"test.other-domain.com"}, // Doesn't match filter 89 | }, 90 | } 91 | 92 | config := &HomerConfig{Title: "Test Dashboard"} 93 | UpdateHomerConfigHTTPRoute(config, httproute, []string{"rajsingh.info"}) 94 | 95 | if err := ValidateHomerConfig(config); err != nil { 96 | t.Errorf("Validation should pass even with no matching hostnames, but got error: %v", err) 97 | } 98 | 99 | // Should not create any services since no hostnames match 100 | if len(config.Services) != 0 { 101 | t.Errorf("Expected no services to be created when no hostnames match, but got %d services", len(config.Services)) 102 | } 103 | }) 104 | 105 | // Test case 4: HomerConfig with pre-existing invalid services (simulates Dashboard CRD with bad data) 106 | t.Run("invalid pre-existing services", func(t *testing.T) { 107 | config := &HomerConfig{ 108 | Title: "Test Dashboard", 109 | Services: []Service{ 110 | { 111 | // Service with empty Parameters (like what we saw in the error) 112 | Items: []Item{ 113 | { 114 | // Item with empty Parameters too 115 | }, 116 | }, 117 | }, 118 | }, 119 | } 120 | 121 | // This should clean up the invalid service 122 | cleanupHomerConfig(config) 123 | 124 | if err := ValidateHomerConfig(config); err != nil { 125 | t.Errorf("Validation should pass after cleanup, but got error: %v", err) 126 | } 127 | 128 | // Should have no services since the invalid one was removed 129 | if len(config.Services) != 0 { 130 | t.Errorf("Expected no services after cleanup, but got %d services", len(config.Services)) 131 | } 132 | }) 133 | 134 | // Test case 5: HomerConfig with pre-existing services that can be fixed 135 | t.Run("fixable pre-existing services", func(t *testing.T) { 136 | config := &HomerConfig{ 137 | Title: "Test Dashboard", 138 | Services: []Service{ 139 | { 140 | // Service with no Parameters but valid namespace context 141 | Parameters: map[string]string{"name": "valid-service"}, 142 | Items: []Item{ 143 | { 144 | Parameters: map[string]string{"name": "valid-item"}, 145 | }, 146 | { 147 | // Item with no name - should be removed 148 | }, 149 | }, 150 | }, 151 | }, 152 | } 153 | 154 | // This should clean up the invalid items but keep the valid service 155 | cleanupHomerConfig(config) 156 | 157 | if err := ValidateHomerConfig(config); err != nil { 158 | t.Errorf("Validation should pass after cleanup, but got error: %v", err) 159 | } 160 | 161 | // Should have 1 service with 1 item 162 | if len(config.Services) != 1 { 163 | t.Errorf("Expected 1 service after cleanup, but got %d services", len(config.Services)) 164 | } else if len(config.Services[0].Items) != 1 { 165 | t.Errorf("Expected 1 item after cleanup, but got %d items", len(config.Services[0].Items)) 166 | } 167 | }) 168 | } 169 | -------------------------------------------------------------------------------- /homer/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Homepage configuration 3 | # See https://fontawesome.com/v5/search for icons options 4 | 5 | # Optional: Use external configuration file. 6 | # Using this will ignore remaining config in this file 7 | # externalConfig: https://example.com/server-luci/config.yaml 8 | 9 | title: "App dashboard" 10 | subtitle: "Homer" 11 | # documentTitle: "Welcome" # Customize the browser tab text 12 | logo: "assets/logo.png" 13 | # Alternatively a fa icon can be provided: 14 | # icon: "fas fa-skull-crossbones" 15 | 16 | header: true # Set to false to hide the header 17 | # Optional: Different hotkey for search, defaults to "/" 18 | # hotkey: 19 | # search: "Shift" 20 | footer: '

Homer-Operator

' # Set to false to hide the footer: footer: false 21 | 22 | columns: "3" # "auto" or number (must be a factor of 12: 1, 2, 3, 4, 6, 12) 23 | connectivityCheck: 24 | true # whether you want to display a message when the apps are not accessible anymore (VPN disconnected for example). 25 | # You should set it to true when using an authentication proxy, it also reloads the page when a redirection is detected when checking connectivity. 26 | 27 | # Optional: Proxy / hosting option 28 | proxy: 29 | useCredentials: false # send cookies & authorization headers when fetching service specific data. Set to `true` if you use an authentication proxy. Can be overrided on service level. 30 | 31 | # Set the default layout and color scheme 32 | defaults: 33 | layout: columns # Either 'columns', or 'list' 34 | colorTheme: auto # One of 'auto', 'light', or 'dark' 35 | 36 | # Optional theming 37 | theme: default # 'default' or one of the themes available in 'src/assets/themes'. 38 | 39 | # Optional custom stylesheet 40 | # Will load custom CSS files. Especially useful for custom icon sets. 41 | # stylesheet: 42 | # - "assets/custom.css" 43 | 44 | # Here is the exhaustive list of customization parameters 45 | # However all value are optional and will fallback to default if not set. 46 | # if you want to change only some of the colors, feel free to remove all unused key. 47 | colors: 48 | light: 49 | highlight-primary: "#3367d6" 50 | highlight-secondary: "#4285f4" 51 | highlight-hover: "#5a95f5" 52 | background: "#f5f5f5" 53 | card-background: "#ffffff" 54 | text: "#363636" 55 | text-header: "#424242" 56 | text-title: "#303030" 57 | text-subtitle: "#424242" 58 | card-shadow: rgba(0, 0, 0, 0.1) 59 | link: "#3273dc" 60 | link-hover: "#363636" 61 | background-image: "assets/your/light/bg.png" 62 | dark: 63 | highlight-primary: "#3367d6" 64 | highlight-secondary: "#4285f4" 65 | highlight-hover: "#5a95f5" 66 | background: "#131313" 67 | card-background: "#2b2b2b" 68 | text: "#eaeaea" 69 | text-header: "#ffffff" 70 | text-title: "#fafafa" 71 | text-subtitle: "#f5f5f5" 72 | card-shadow: rgba(0, 0, 0, 0.4) 73 | link: "#3273dc" 74 | link-hover: "#ffdd57" 75 | background-image: "assets/your/dark/bg.png" 76 | 77 | # Optional message 78 | message: 79 | # url: "https://" # Can fetch information from an endpoint to override value below. 80 | # mapping: # allows to map fields from the remote format to the one expected by Homer 81 | # title: 'id' # use value from field 'id' as title 82 | # content: 'value' # value from field 'value' as content 83 | # refreshInterval: 10000 # Optional: time interval to refresh message 84 | # 85 | # Real example using chucknorris.io for showing Chuck Norris facts as messages: 86 | # url: https://api.chucknorris.io/jokes/random 87 | # mapping: 88 | # title: 'id' 89 | # content: 'value' 90 | # refreshInterval: 10000 91 | style: "is-warning" 92 | title: "Optional message!" 93 | icon: "fa fa-exclamation-triangle" 94 | content: "Welcome to your Homer dashboard! This message can be customized." 95 | 96 | # Optional navbar 97 | # links: [] # Allows for navbar (dark mode, layout, and search) without any links 98 | links: 99 | - name: "Link 1" 100 | icon: "fab fa-github" 101 | url: "https://github.com/bastienwirtz/homer" 102 | target: "_blank" # optional html tag target attribute 103 | - name: "link 2" 104 | icon: "fas fa-book" 105 | url: "https://github.com/bastienwirtz/homer" 106 | # this will link to a second homer page that will load config from page2.yml and keep default config values as in config.yml file 107 | # see url field and assets/page.yml used in this example: 108 | - name: "Second Page" 109 | icon: "fas fa-file-alt" 110 | url: "https://github.com/rajsinghtech/homer-operator/wiki" 111 | 112 | # Services 113 | # First level array represents a group. 114 | # Leave only a "items" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed). 115 | services: 116 | - name: "Development Tools" 117 | icon: "fas fa-code-branch" 118 | # A path to an image can also be provided. Note that icon take precedence if both icon and logo are set. 119 | # logo: "path/to/logo" 120 | items: 121 | - name: "Self-Hosted Reddit" 122 | logo: "assets/tools/sample.png" 123 | # Alternatively a fa icon can be provided: 124 | # icon: "fab fa-jenkins" 125 | subtitle: "Bookmark example" 126 | tag: "app" 127 | keywords: "self hosted reddit" # optional keyword used for searching purpose 128 | url: "https://www.reddit.com/r/selfhosted/" 129 | target: "_blank" # optional html tag target attribute 130 | - name: "Another one" 131 | logo: "assets/tools/sample2.png" 132 | subtitle: "Another application" 133 | tag: "app" 134 | # Optional tagstyle 135 | tagstyle: "is-success" 136 | url: "https://github.com/rajsinghtech/homer-operator" 137 | - name: "Other group" 138 | icon: "fas fa-heartbeat" 139 | items: 140 | - name: "Pi-hole" 141 | logo: "assets/tools/sample.png" 142 | # subtitle: "Network-wide Ad Blocking" # optional, if no subtitle is defined, PiHole statistics will be shown 143 | tag: "other" 144 | url: "https://pihole.rajsingh.info/admin" 145 | type: "PiHole" # optional, loads a specific component that provides extra features. MUST MATCH a file name (without file extension) available in `src/components/services` 146 | target: "_blank" # optional html a tag target attribute 147 | # class: "green" # optional custom CSS class for card, useful with custom stylesheet 148 | # background: red # optional color for card to set color directly without custom stylesheet 149 | -------------------------------------------------------------------------------- /.github/workflows/helm-release.yml: -------------------------------------------------------------------------------- 1 | name: Helm Chart Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'charts/homer-operator/Chart.yaml' 9 | - 'charts/homer-operator/values.yaml' 10 | - 'charts/homer-operator/templates/**' 11 | - '.github/workflows/helm-release.yml' 12 | tags: 13 | - 'v*.*.*' 14 | 15 | env: 16 | REGISTRY: ghcr.io 17 | IMAGE_NAME: ${{ github.repository }} 18 | 19 | jobs: 20 | publish: 21 | name: Publish Helm Chart 22 | runs-on: ubuntu-latest 23 | # Only run on direct pushes, not PRs 24 | if: github.event_name == 'push' 25 | permissions: 26 | contents: read 27 | packages: write 28 | id-token: write 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Set up Helm 36 | uses: azure/setup-helm@v4 37 | with: 38 | version: v3.17.0 39 | 40 | - name: Validate chart 41 | run: | 42 | # Use helm lint instead of ct lint to avoid complex ct configuration 43 | helm lint charts/homer-operator 44 | # Verify template rendering 45 | helm template test charts/homer-operator --dry-run > /dev/null 46 | echo "Chart validation passed" 47 | 48 | - name: Log in to GHCR 49 | uses: docker/login-action@v3 50 | with: 51 | registry: ${{ env.REGISTRY }} 52 | username: ${{ github.actor }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Extract version 56 | id: meta 57 | run: | 58 | if [[ $GITHUB_REF == refs/tags/* ]]; then 59 | # Extract version from tag (remove 'v' prefix) 60 | TAG_VERSION=${GITHUB_REF#refs/tags/v} 61 | echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT 62 | echo "chart_version=$TAG_VERSION" >> $GITHUB_OUTPUT 63 | echo "is_release=true" >> $GITHUB_OUTPUT 64 | echo "Using tag version: $TAG_VERSION" 65 | else 66 | # For main branch, use a dev version 67 | echo "version=0.0.0-latest" >> $GITHUB_OUTPUT 68 | echo "chart_version=0.0.0-latest" >> $GITHUB_OUTPUT 69 | echo "is_release=false" >> $GITHUB_OUTPUT 70 | echo "Using development version: 0.0.0-latest" 71 | fi 72 | 73 | - name: Update Chart versions 74 | run: | 75 | VERSION="${{ steps.meta.outputs.version }}" 76 | 77 | if [[ $GITHUB_REF == refs/tags/* ]]; then 78 | # For tags, update both version and appVersion 79 | sed -i "s/version: .*/version: $VERSION/" charts/homer-operator/Chart.yaml 80 | sed -i "s/appVersion: .*/appVersion: \"$VERSION\"/" charts/homer-operator/Chart.yaml 81 | else 82 | # For main branch, only update version (keep appVersion as 'latest') 83 | sed -i "s/version: .*/version: $VERSION/" charts/homer-operator/Chart.yaml 84 | sed -i "s/appVersion: .*/appVersion: \"latest\"/" charts/homer-operator/Chart.yaml 85 | fi 86 | 87 | echo "Updated chart version to: $VERSION" 88 | 89 | - name: Package and Push Helm Chart 90 | run: | 91 | CHART_VERSION="${{ steps.meta.outputs.version }}" 92 | REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') 93 | 94 | # Package the chart 95 | if ! helm package charts/homer-operator --destination ./packaged-charts; then 96 | echo "Failed to package Helm chart" 97 | exit 1 98 | fi 99 | 100 | # Verify package was created 101 | if [[ ! -f "./packaged-charts/homer-operator-${CHART_VERSION}.tgz" ]]; then 102 | echo "Package file not found: homer-operator-${CHART_VERSION}.tgz" 103 | exit 1 104 | fi 105 | 106 | # Push to GHCR 107 | if ! helm push "./packaged-charts/homer-operator-${CHART_VERSION}.tgz" "oci://${{ env.REGISTRY }}/${REPO_NAME}/charts"; then 108 | echo "Failed to push Helm chart to registry" 109 | exit 1 110 | fi 111 | 112 | echo "Chart successfully pushed to: oci://${{ env.REGISTRY }}/${REPO_NAME}/charts/homer-operator:${CHART_VERSION}" 113 | 114 | - name: Generate summary 115 | run: | 116 | CHART_VERSION="${{ steps.meta.outputs.version }}" 117 | REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') 118 | IS_RELEASE="${{ steps.meta.outputs.is_release }}" 119 | 120 | if [[ $IS_RELEASE == "true" ]]; then 121 | echo "## Helm Chart Released" >> $GITHUB_STEP_SUMMARY 122 | echo "" >> $GITHUB_STEP_SUMMARY 123 | echo "**Chart Version:** \`$CHART_VERSION\`" >> $GITHUB_STEP_SUMMARY 124 | echo "**Registry:** \`${{ env.REGISTRY }}/${REPO_NAME}/charts\`" >> $GITHUB_STEP_SUMMARY 125 | echo "**Tag:** \`${GITHUB_REF#refs/tags/}\`" >> $GITHUB_STEP_SUMMARY 126 | echo "" >> $GITHUB_STEP_SUMMARY 127 | echo "### Installation" >> $GITHUB_STEP_SUMMARY 128 | echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY 129 | echo "helm install homer-operator oci://${{ env.REGISTRY }}/${REPO_NAME}/charts/homer-operator --version $CHART_VERSION" >> $GITHUB_STEP_SUMMARY 130 | echo "\`\`\`" >> $GITHUB_STEP_SUMMARY 131 | echo "" >> $GITHUB_STEP_SUMMARY 132 | echo "**Stable release suitable for production use.**" >> $GITHUB_STEP_SUMMARY 133 | else 134 | echo "## Helm Chart Development Build" >> $GITHUB_STEP_SUMMARY 135 | echo "" >> $GITHUB_STEP_SUMMARY 136 | echo "**Chart Version:** \`$CHART_VERSION\`" >> $GITHUB_STEP_SUMMARY 137 | echo "**Registry:** \`${{ env.REGISTRY }}/${REPO_NAME}/charts\`" >> $GITHUB_STEP_SUMMARY 138 | echo "**Branch:** \`${GITHUB_REF#refs/heads/}\`" >> $GITHUB_STEP_SUMMARY 139 | echo "" >> $GITHUB_STEP_SUMMARY 140 | echo "### Installation (Development)" >> $GITHUB_STEP_SUMMARY 141 | echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY 142 | echo "helm install homer-operator oci://${{ env.REGISTRY }}/${REPO_NAME}/charts/homer-operator --version $CHART_VERSION" >> $GITHUB_STEP_SUMMARY 143 | echo "\`\`\`" >> $GITHUB_STEP_SUMMARY 144 | echo "" >> $GITHUB_STEP_SUMMARY 145 | echo "**Development build from main branch - not for production use.**" >> $GITHUB_STEP_SUMMARY 146 | fi -------------------------------------------------------------------------------- /charts/homer-operator/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.create }} 2 | --- 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRole 5 | metadata: 6 | name: {{ include "homer-operator.managerRoleName" . }} 7 | labels: 8 | {{- include "homer-operator.labels" . | nindent 4 }} 9 | {{- with .Values.rbac.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | rules: 14 | - apiGroups: 15 | - "" 16 | resources: 17 | - configmaps 18 | verbs: 19 | - create 20 | - delete 21 | - get 22 | - list 23 | - patch 24 | - update 25 | - watch 26 | - apiGroups: 27 | - "" 28 | resources: 29 | - configmaps/status 30 | verbs: 31 | - get 32 | - patch 33 | - update 34 | - apiGroups: 35 | - "" 36 | resources: 37 | - services 38 | verbs: 39 | - create 40 | - delete 41 | - get 42 | - list 43 | - patch 44 | - update 45 | - watch 46 | - apiGroups: 47 | - "" 48 | resources: 49 | - services/status 50 | verbs: 51 | - get 52 | - patch 53 | - update 54 | - apiGroups: 55 | - apps 56 | resources: 57 | - deployments 58 | verbs: 59 | - create 60 | - delete 61 | - get 62 | - list 63 | - patch 64 | - update 65 | - watch 66 | - apiGroups: 67 | - apps 68 | resources: 69 | - deployments/status 70 | verbs: 71 | - get 72 | - patch 73 | - update 74 | - apiGroups: 75 | - "" 76 | resources: 77 | - secrets 78 | verbs: 79 | - get 80 | - list 81 | - watch 82 | - apiGroups: 83 | - "" 84 | resources: 85 | - namespaces 86 | verbs: 87 | - get 88 | - list 89 | - watch 90 | {{- if .Values.operator.enableGatewayAPI }} 91 | - apiGroups: 92 | - gateway.networking.k8s.io 93 | resources: 94 | - gateways 95 | verbs: 96 | - get 97 | - list 98 | - watch 99 | - apiGroups: 100 | - gateway.networking.k8s.io 101 | resources: 102 | - httproutes 103 | verbs: 104 | - get 105 | - list 106 | - watch 107 | - apiGroups: 108 | - gateway.networking.k8s.io 109 | resources: 110 | - httproutes/status 111 | verbs: 112 | - get 113 | {{- end }} 114 | - apiGroups: 115 | - homer.rajsingh.info 116 | resources: 117 | - dashboards 118 | verbs: 119 | - create 120 | - delete 121 | - get 122 | - list 123 | - patch 124 | - update 125 | - watch 126 | - apiGroups: 127 | - homer.rajsingh.info 128 | resources: 129 | - dashboards/finalizers 130 | verbs: 131 | - update 132 | - apiGroups: 133 | - homer.rajsingh.info 134 | resources: 135 | - dashboards/status 136 | verbs: 137 | - get 138 | - patch 139 | - update 140 | - apiGroups: 141 | - networking.k8s.io 142 | resources: 143 | - ingresses 144 | verbs: 145 | - create 146 | - delete 147 | - get 148 | - list 149 | - patch 150 | - update 151 | - watch 152 | - apiGroups: 153 | - networking.k8s.io 154 | resources: 155 | - ingresses/finalizers 156 | verbs: 157 | - update 158 | - apiGroups: 159 | - networking.k8s.io 160 | resources: 161 | - ingresses/status 162 | verbs: 163 | - get 164 | - patch 165 | - update 166 | --- 167 | apiVersion: rbac.authorization.k8s.io/v1 168 | kind: ClusterRoleBinding 169 | metadata: 170 | name: {{ include "homer-operator.managerRoleName" . }} 171 | labels: 172 | {{- include "homer-operator.labels" . | nindent 4 }} 173 | {{- with .Values.rbac.annotations }} 174 | annotations: 175 | {{- toYaml . | nindent 4 }} 176 | {{- end }} 177 | roleRef: 178 | apiGroup: rbac.authorization.k8s.io 179 | kind: ClusterRole 180 | name: {{ include "homer-operator.managerRoleName" . }} 181 | subjects: 182 | - kind: ServiceAccount 183 | name: {{ include "homer-operator.serviceAccountName" . }} 184 | namespace: {{ include "homer-operator.namespace" . }} 185 | --- 186 | apiVersion: rbac.authorization.k8s.io/v1 187 | kind: Role 188 | metadata: 189 | name: {{ include "homer-operator.leaderElectionRoleName" . }} 190 | namespace: {{ include "homer-operator.namespace" . }} 191 | labels: 192 | {{- include "homer-operator.labels" . | nindent 4 }} 193 | {{- with .Values.rbac.annotations }} 194 | annotations: 195 | {{- toYaml . | nindent 4 }} 196 | {{- end }} 197 | rules: 198 | - apiGroups: 199 | - "" 200 | resources: 201 | - configmaps 202 | verbs: 203 | - get 204 | - list 205 | - watch 206 | - create 207 | - update 208 | - patch 209 | - delete 210 | - apiGroups: 211 | - coordination.k8s.io 212 | resources: 213 | - leases 214 | verbs: 215 | - get 216 | - list 217 | - watch 218 | - create 219 | - update 220 | - patch 221 | - delete 222 | - apiGroups: 223 | - "" 224 | resources: 225 | - events 226 | verbs: 227 | - create 228 | - patch 229 | --- 230 | apiVersion: rbac.authorization.k8s.io/v1 231 | kind: RoleBinding 232 | metadata: 233 | name: {{ include "homer-operator.leaderElectionRoleName" . }} 234 | namespace: {{ include "homer-operator.namespace" . }} 235 | labels: 236 | {{- include "homer-operator.labels" . | nindent 4 }} 237 | {{- with .Values.rbac.annotations }} 238 | annotations: 239 | {{- toYaml . | nindent 4 }} 240 | {{- end }} 241 | roleRef: 242 | apiGroup: rbac.authorization.k8s.io 243 | kind: Role 244 | name: {{ include "homer-operator.leaderElectionRoleName" . }} 245 | subjects: 246 | - kind: ServiceAccount 247 | name: {{ include "homer-operator.serviceAccountName" . }} 248 | namespace: {{ include "homer-operator.namespace" . }} 249 | {{- if .Values.operator.metrics.enabled }} 250 | --- 251 | apiVersion: rbac.authorization.k8s.io/v1 252 | kind: ClusterRole 253 | metadata: 254 | name: {{ include "homer-operator.metricsReaderRoleName" . }} 255 | labels: 256 | {{- include "homer-operator.labels" . | nindent 4 }} 257 | {{- with .Values.rbac.annotations }} 258 | annotations: 259 | {{- toYaml . | nindent 4 }} 260 | {{- end }} 261 | rules: 262 | - nonResourceURLs: 263 | - "/metrics" 264 | verbs: 265 | - get 266 | --- 267 | apiVersion: rbac.authorization.k8s.io/v1 268 | kind: ClusterRole 269 | metadata: 270 | name: {{ include "homer-operator.proxyRoleName" . }} 271 | labels: 272 | {{- include "homer-operator.labels" . | nindent 4 }} 273 | {{- with .Values.rbac.annotations }} 274 | annotations: 275 | {{- toYaml . | nindent 4 }} 276 | {{- end }} 277 | rules: 278 | - apiGroups: 279 | - authentication.k8s.io 280 | resources: 281 | - tokenreviews 282 | verbs: 283 | - create 284 | - apiGroups: 285 | - authorization.k8s.io 286 | resources: 287 | - subjectaccessreviews 288 | verbs: 289 | - create 290 | --- 291 | apiVersion: rbac.authorization.k8s.io/v1 292 | kind: ClusterRoleBinding 293 | metadata: 294 | name: {{ include "homer-operator.proxyRoleBindingName" . }} 295 | labels: 296 | {{- include "homer-operator.labels" . | nindent 4 }} 297 | {{- with .Values.rbac.annotations }} 298 | annotations: 299 | {{- toYaml . | nindent 4 }} 300 | {{- end }} 301 | roleRef: 302 | apiGroup: rbac.authorization.k8s.io 303 | kind: ClusterRole 304 | name: {{ include "homer-operator.proxyRoleName" . }} 305 | subjects: 306 | - kind: ServiceAccount 307 | name: {{ include "homer-operator.serviceAccountName" . }} 308 | namespace: {{ include "homer-operator.namespace" . }} 309 | {{- end }} 310 | {{- end }} -------------------------------------------------------------------------------- /pkg/homer/crd_service_matching_test.go: -------------------------------------------------------------------------------- 1 | package homer 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestScoreCRDServiceGroupMatch(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | crdServiceName string 11 | discoveredNamespace string 12 | discoveredAnnotations map[string]string 13 | expectedScore int 14 | description string 15 | }{ 16 | { 17 | name: "exact namespace match", 18 | crdServiceName: "home", 19 | discoveredNamespace: "home", 20 | discoveredAnnotations: map[string]string{}, 21 | expectedScore: 100, 22 | description: "Direct namespace match should score 100", 23 | }, 24 | { 25 | name: "partial namespace match", 26 | crdServiceName: "kube-system", 27 | discoveredNamespace: "system", 28 | discoveredAnnotations: map[string]string{}, 29 | expectedScore: 50, 30 | description: "Partial namespace match should score 50", 31 | }, 32 | { 33 | name: "no namespace match", 34 | crdServiceName: "infrastructure", 35 | discoveredNamespace: "speedtest", 36 | discoveredAnnotations: map[string]string{}, 37 | expectedScore: 0, 38 | description: "No namespace match should score 0 (below threshold)", 39 | }, 40 | { 41 | name: "explicit service annotation match", 42 | crdServiceName: "Production Services", 43 | discoveredNamespace: "random-namespace", 44 | discoveredAnnotations: map[string]string{ 45 | "service.homer.rajsingh.info/name": "Production Services", 46 | }, 47 | expectedScore: 200, 48 | description: "Explicit service annotation should score 200", 49 | }, 50 | { 51 | name: "explicit service annotation no match", 52 | crdServiceName: "Development Services", 53 | discoveredNamespace: "dev", 54 | discoveredAnnotations: map[string]string{ 55 | "service.homer.rajsingh.info/name": "Production Services", 56 | }, 57 | expectedScore: 0, 58 | description: "Wrong service annotation should score 0, ignore namespace", 59 | }, 60 | { 61 | name: "case insensitive service annotation match", 62 | crdServiceName: "Media Services", 63 | discoveredNamespace: "media", 64 | discoveredAnnotations: map[string]string{ 65 | "service.homer.rajsingh.info/name": "MEDIA SERVICES", 66 | }, 67 | expectedScore: 200, 68 | description: "Case insensitive service annotation should work", 69 | }, 70 | { 71 | name: "case insensitive namespace match", 72 | crdServiceName: "HOME", 73 | discoveredNamespace: "home", 74 | discoveredAnnotations: map[string]string{}, 75 | expectedScore: 100, 76 | description: "Case insensitive namespace match should work", 77 | }, 78 | } 79 | 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | score := scoreCRDServiceGroupMatch( 83 | tt.crdServiceName, 84 | tt.discoveredNamespace, 85 | tt.discoveredAnnotations, 86 | ) 87 | 88 | if score != tt.expectedScore { 89 | t.Errorf("%s: expected score %d, got %d", tt.description, tt.expectedScore, score) 90 | } 91 | }) 92 | } 93 | } 94 | 95 | func TestFindBestMatchingCRDServiceGroupWithThreshold(t *testing.T) { 96 | // Create a test config with existing CRD services 97 | config := &HomerConfig{ 98 | Services: []Service{ 99 | { 100 | Parameters: map[string]string{ 101 | "name": "Infrastructure", 102 | }, 103 | Items: []Item{ 104 | { 105 | Source: CRDSource, 106 | Parameters: map[string]string{ 107 | "name": "Existing CRD Item", 108 | }, 109 | }, 110 | }, 111 | }, 112 | { 113 | Parameters: map[string]string{ 114 | "name": "home", 115 | }, 116 | Items: []Item{ 117 | { 118 | Source: CRDSource, 119 | Parameters: map[string]string{ 120 | "name": "Home Assistant", 121 | }, 122 | }, 123 | }, 124 | }, 125 | }, 126 | } 127 | 128 | tests := []struct { 129 | name string 130 | namespace string 131 | annotations map[string]string 132 | expectedMatch string 133 | description string 134 | }{ 135 | { 136 | name: "exact namespace match above threshold", 137 | namespace: "home", 138 | annotations: map[string]string{}, 139 | expectedMatch: "home", 140 | description: "Should match home service with score 100", 141 | }, 142 | { 143 | name: "weak match below threshold", 144 | namespace: "speedtest", 145 | annotations: map[string]string{}, 146 | expectedMatch: "", 147 | description: "Should not match any service (all scores below 30 threshold)", 148 | }, 149 | { 150 | name: "explicit annotation above threshold", 151 | namespace: "random", 152 | annotations: map[string]string{ 153 | "service.homer.rajsingh.info/name": "Infrastructure", 154 | }, 155 | expectedMatch: "Infrastructure", 156 | description: "Should match Infrastructure service via annotation with score 200", 157 | }, 158 | { 159 | name: "partial match above threshold", 160 | namespace: "infra", 161 | annotations: map[string]string{}, 162 | expectedMatch: "Infrastructure", 163 | description: "Should match Infrastructure service with partial match score 50", 164 | }, 165 | } 166 | 167 | for _, tt := range tests { 168 | t.Run(tt.name, func(t *testing.T) { 169 | match := findBestMatchingCRDServiceGroup( 170 | config, 171 | tt.namespace, 172 | tt.annotations, 173 | ) 174 | 175 | if match != tt.expectedMatch { 176 | t.Errorf("%s: expected match '%s', got '%s'", tt.description, tt.expectedMatch, match) 177 | } 178 | }) 179 | } 180 | } 181 | 182 | func TestMinimumScoreThresholdPreventsWeakMatches(t *testing.T) { 183 | // This test specifically addresses the bug where speedtest was incorrectly 184 | // matched to Infrastructure service group 185 | 186 | config := &HomerConfig{ 187 | Services: []Service{ 188 | { 189 | Parameters: map[string]string{ 190 | "name": "Infrastructure", 191 | }, 192 | Items: []Item{ 193 | { 194 | Source: CRDSource, 195 | Parameters: map[string]string{ 196 | "name": "Prometheus", 197 | }, 198 | }, 199 | }, 200 | }, 201 | { 202 | Parameters: map[string]string{ 203 | "name": "Network Tools", 204 | }, 205 | Items: []Item{ 206 | { 207 | Source: CRDSource, 208 | Parameters: map[string]string{ 209 | "name": "Network Scanner", 210 | }, 211 | }, 212 | }, 213 | }, 214 | }, 215 | } 216 | 217 | // Test the original bug scenario: speedtest with no annotations 218 | match := findBestMatchingCRDServiceGroup( 219 | config, 220 | "speedtest", // namespace that doesn't match any existing service names 221 | map[string]string{}, // no annotations 222 | ) 223 | 224 | // Should return empty string because no match scores above threshold (30) 225 | if match != "" { 226 | t.Errorf("Expected no match for speedtest namespace, got match: '%s'", match) 227 | } 228 | 229 | // Verify that with proper annotation, it would work 230 | matchWithAnnotation := findBestMatchingCRDServiceGroup( 231 | config, 232 | "speedtest", 233 | map[string]string{ 234 | "service.homer.rajsingh.info/name": "Network Tools", 235 | }, 236 | ) 237 | 238 | if matchWithAnnotation != "Network Tools" { 239 | t.Errorf("Expected 'Network Tools' match with annotation, got: '%s'", matchWithAnnotation) 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /internal/controller/resource_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 RajSingh. 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 | homerv1alpha1 "github.com/rajsinghtech/homer-operator/api/v1alpha1" 23 | homer "github.com/rajsinghtech/homer-operator/pkg/homer" 24 | "github.com/rajsinghtech/homer-operator/pkg/utils" 25 | corev1 "k8s.io/api/core/v1" 26 | networkingv1 "k8s.io/api/networking/v1" 27 | apierrors "k8s.io/apimachinery/pkg/api/errors" 28 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 | "k8s.io/apimachinery/pkg/labels" 30 | "k8s.io/apimachinery/pkg/runtime" 31 | ctrl "sigs.k8s.io/controller-runtime" 32 | "sigs.k8s.io/controller-runtime/pkg/client" 33 | gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" 34 | ) 35 | 36 | type ResourceInfo struct { 37 | Name string 38 | Namespace string 39 | Annotations map[string]string 40 | Labels map[string]string 41 | Object client.Object 42 | } 43 | 44 | //+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch 45 | //+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses/status,verbs=get 46 | //+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch 47 | //+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/status,verbs=get 48 | //+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch 49 | 50 | type GenericResourceReconciler struct { 51 | client.Client 52 | Scheme *runtime.Scheme 53 | IsHTTPRoute bool 54 | } 55 | 56 | func (r *GenericResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 57 | resourceInfo, err := r.getResourceInfo(ctx, req) 58 | if err != nil { 59 | if apierrors.IsNotFound(err) { 60 | return ctrl.Result{}, nil 61 | } 62 | return ctrl.Result{}, err 63 | } 64 | 65 | // List all Dashboard CRs 66 | dashboardList := &homerv1alpha1.DashboardList{} 67 | if err := r.List(ctx, dashboardList); err != nil { 68 | return ctrl.Result{}, err 69 | } 70 | 71 | for _, dashboard := range dashboardList.Items { 72 | delete(dashboard.Annotations, "kubectl.kubernetes.io/last-applied-configuration") 73 | if utils.IsSubset(resourceInfo.Annotations, dashboard.Annotations) { 74 | shouldInclude, err := r.shouldIncludeResource(ctx, resourceInfo, &dashboard) 75 | if err != nil { 76 | return ctrl.Result{}, err 77 | } 78 | if !shouldInclude { 79 | continue 80 | } 81 | 82 | configMap := corev1.ConfigMap{} 83 | configMapName := dashboard.Name + homer.ResourceSuffix 84 | if err := r.Get(ctx, client.ObjectKey{Namespace: dashboard.Namespace, Name: configMapName}, &configMap); err != nil { 85 | if apierrors.IsNotFound(err) { 86 | continue 87 | } 88 | return ctrl.Result{}, err 89 | } 90 | 91 | r.updateConfigMap(resourceInfo, &configMap, dashboard.Spec.DomainFilters) 92 | 93 | if err := utils.UpdateConfigMapWithRetry(ctx, r.Client, &configMap, dashboard.Name); err != nil { 94 | return ctrl.Result{}, err 95 | } 96 | } 97 | } 98 | 99 | return ctrl.Result{}, nil 100 | } 101 | 102 | func (r *GenericResourceReconciler) getResourceInfo(ctx context.Context, req ctrl.Request) (*ResourceInfo, error) { 103 | if r.IsHTTPRoute { 104 | var httproute gatewayv1.HTTPRoute 105 | if err := r.Get(ctx, req.NamespacedName, &httproute); err != nil { 106 | return nil, err 107 | } 108 | return &ResourceInfo{ 109 | Name: httproute.Name, 110 | Namespace: httproute.Namespace, 111 | Annotations: httproute.Annotations, 112 | Labels: httproute.Labels, 113 | Object: &httproute, 114 | }, nil 115 | } 116 | 117 | var ingress networkingv1.Ingress 118 | if err := r.Get(ctx, req.NamespacedName, &ingress); err != nil { 119 | return nil, err 120 | } 121 | return &ResourceInfo{ 122 | Name: ingress.Name, 123 | Namespace: ingress.Namespace, 124 | Annotations: ingress.Annotations, 125 | Labels: ingress.Labels, 126 | Object: &ingress, 127 | }, nil 128 | } 129 | 130 | func (r *GenericResourceReconciler) shouldIncludeResource(ctx context.Context, resourceInfo *ResourceInfo, dashboard *homerv1alpha1.Dashboard) (bool, error) { 131 | if r.IsHTTPRoute { 132 | return r.shouldIncludeHTTPRoute(ctx, resourceInfo, dashboard) 133 | } 134 | return r.shouldIncludeIngress(resourceInfo, dashboard) 135 | } 136 | 137 | func (r *GenericResourceReconciler) shouldIncludeIngress(resourceInfo *ResourceInfo, dashboard *homerv1alpha1.Dashboard) (bool, error) { 138 | if dashboard.Spec.IngressSelector != nil { 139 | selector, err := metav1.LabelSelectorAsSelector(dashboard.Spec.IngressSelector) 140 | if err != nil { 141 | return false, err 142 | } 143 | if !selector.Matches(labels.Set(resourceInfo.Labels)) { 144 | return false, nil 145 | } 146 | } 147 | 148 | if len(dashboard.Spec.DomainFilters) > 0 { 149 | ingress := resourceInfo.Object.(*networkingv1.Ingress) 150 | if !utils.MatchesIngressDomainFilters(ingress, dashboard.Spec.DomainFilters) { 151 | return false, nil 152 | } 153 | } 154 | 155 | return true, nil 156 | } 157 | 158 | func (r *GenericResourceReconciler) shouldIncludeHTTPRoute(ctx context.Context, resourceInfo *ResourceInfo, dashboard *homerv1alpha1.Dashboard) (bool, error) { 159 | httproute := resourceInfo.Object.(*gatewayv1.HTTPRoute) 160 | 161 | if dashboard.Spec.HTTPRouteSelector != nil { 162 | selector, err := metav1.LabelSelectorAsSelector(dashboard.Spec.HTTPRouteSelector) 163 | if err != nil { 164 | return false, err 165 | } 166 | if !selector.Matches(labels.Set(resourceInfo.Labels)) { 167 | return false, nil 168 | } 169 | } 170 | 171 | if len(dashboard.Spec.DomainFilters) > 0 { 172 | if !utils.MatchesHTTPRouteDomainFilters(httproute.Spec.Hostnames, dashboard.Spec.DomainFilters) { 173 | return false, nil 174 | } 175 | } 176 | 177 | if dashboard.Spec.GatewaySelector != nil { 178 | selector, err := metav1.LabelSelectorAsSelector(dashboard.Spec.GatewaySelector) 179 | if err != nil { 180 | return false, err 181 | } 182 | 183 | for _, parentRef := range httproute.Spec.ParentRefs { 184 | if parentRef.Kind != nil && string(*parentRef.Kind) != "Gateway" { 185 | continue 186 | } 187 | 188 | namespace := httproute.Namespace 189 | if parentRef.Namespace != nil { 190 | namespace = string(*parentRef.Namespace) 191 | } 192 | 193 | gateway := &gatewayv1.Gateway{} 194 | if err := r.Get(ctx, client.ObjectKey{Name: string(parentRef.Name), Namespace: namespace}, gateway); err != nil { 195 | if apierrors.IsNotFound(err) { 196 | continue 197 | } 198 | return false, err 199 | } 200 | 201 | if selector.Matches(labels.Set(gateway.Labels)) { 202 | return true, nil 203 | } 204 | } 205 | return false, nil 206 | } 207 | 208 | return true, nil 209 | } 210 | 211 | func (r *GenericResourceReconciler) updateConfigMap(resourceInfo *ResourceInfo, configMap *corev1.ConfigMap, domainFilters []string) { 212 | if r.IsHTTPRoute { 213 | httproute := resourceInfo.Object.(*gatewayv1.HTTPRoute) 214 | homer.UpdateConfigMapHTTPRoute(configMap, httproute, domainFilters) 215 | } else { 216 | ingress := resourceInfo.Object.(*networkingv1.Ingress) 217 | homer.UpdateConfigMapIngress(configMap, *ingress, domainFilters) 218 | } 219 | } 220 | 221 | func (r *GenericResourceReconciler) SetupIngressController(mgr ctrl.Manager) error { 222 | r.IsHTTPRoute = false 223 | return ctrl.NewControllerManagedBy(mgr). 224 | For(&networkingv1.Ingress{}). 225 | Complete(r) 226 | } 227 | 228 | func (r *GenericResourceReconciler) SetupHTTPRouteController(mgr ctrl.Manager) error { 229 | r.IsHTTPRoute = true 230 | return ctrl.NewControllerManagedBy(mgr). 231 | For(&gatewayv1.HTTPRoute{}). 232 | Complete(r) 233 | } 234 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*.*.*' 9 | workflow_dispatch: 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | GO_VERSION: '1.25.1' 15 | 16 | jobs: 17 | build-image: 18 | name: Build and Push Image 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | packages: write 23 | id-token: write 24 | outputs: 25 | digest: ${{ steps.build.outputs.digest }} 26 | tags: ${{ steps.meta.outputs.tags }} 27 | version: ${{ steps.version.outputs.version }} 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Extract version 33 | id: version 34 | run: | 35 | if [[ $GITHUB_REF == refs/tags/* ]]; then 36 | VERSION=${GITHUB_REF#refs/tags/v} 37 | echo "version=$VERSION" >> $GITHUB_OUTPUT 38 | echo "is_release=true" >> $GITHUB_OUTPUT 39 | echo "Version: $VERSION" 40 | else 41 | echo "version=0.0.0-latest" >> $GITHUB_OUTPUT 42 | echo "is_release=false" >> $GITHUB_OUTPUT 43 | echo "Version: 0.0.0-latest (development)" 44 | fi 45 | 46 | - name: Set up QEMU 47 | uses: docker/setup-qemu-action@v3 48 | 49 | - name: Set up Docker Buildx 50 | uses: docker/setup-buildx-action@v3 51 | 52 | - name: Log in to GHCR 53 | uses: docker/login-action@v3 54 | with: 55 | registry: ${{ env.REGISTRY }} 56 | username: ${{ github.actor }} 57 | password: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | - name: Extract metadata 60 | id: meta 61 | uses: docker/metadata-action@v5 62 | with: 63 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 64 | tags: | 65 | type=semver,pattern={{version}} 66 | type=semver,pattern={{major}}.{{minor}} 67 | type=semver,pattern={{major}} 68 | type=raw,value=latest 69 | type=sha,prefix=sha- 70 | 71 | - name: Build and push Docker image 72 | id: build 73 | uses: docker/build-push-action@v6 74 | with: 75 | context: . 76 | platforms: linux/amd64,linux/arm64 77 | push: true 78 | tags: ${{ steps.meta.outputs.tags }} 79 | labels: ${{ steps.meta.outputs.labels }} 80 | cache-from: type=gha 81 | cache-to: type=gha,mode=max 82 | provenance: false 83 | sbom: false 84 | 85 | - name: Verify build success 86 | run: | 87 | if [[ -z "${{ steps.build.outputs.digest }}" ]]; then 88 | echo "❌ Docker build failed - no digest generated" 89 | exit 1 90 | fi 91 | echo "✅ Docker build successful" 92 | echo "Digest: ${{ steps.build.outputs.digest }}" 93 | 94 | - name: Install cosign 95 | uses: sigstore/cosign-installer@v3.9.2 96 | 97 | - name: Sign container image 98 | env: 99 | DIGEST: ${{ steps.build.outputs.digest }} 100 | TAGS: ${{ steps.meta.outputs.tags }} 101 | run: | 102 | echo "$TAGS" | while IFS= read -r tag; do 103 | if [[ -n "$tag" ]]; then 104 | echo "Signing: $tag@${DIGEST}" 105 | cosign sign --yes "$tag@${DIGEST}" || echo "::warning::Failed to sign $tag" 106 | fi 107 | done 108 | 109 | - name: Generate SBOM 110 | uses: anchore/sbom-action@v0.20.6 111 | with: 112 | image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} 113 | format: spdx-json 114 | output-file: sbom.spdx.json 115 | 116 | - name: Upload SBOM 117 | uses: actions/upload-artifact@v4 118 | with: 119 | name: sbom 120 | path: sbom.spdx.json 121 | 122 | release-helm: 123 | name: Release Helm Chart 124 | runs-on: ubuntu-latest 125 | needs: build-image 126 | permissions: 127 | contents: read 128 | packages: write 129 | steps: 130 | - name: Checkout code 131 | uses: actions/checkout@v4 132 | 133 | - name: Set up Helm 134 | uses: azure/setup-helm@v4 135 | with: 136 | version: v3.17.0 137 | 138 | - name: Log in to GHCR 139 | uses: docker/login-action@v3 140 | with: 141 | registry: ${{ env.REGISTRY }} 142 | username: ${{ github.actor }} 143 | password: ${{ secrets.GITHUB_TOKEN }} 144 | 145 | - name: Update Chart with image digest 146 | env: 147 | DIGEST: ${{ needs.build-image.outputs.digest }} 148 | VERSION: ${{ needs.build-image.outputs.version }} 149 | run: | 150 | # Update Chart.yaml versions 151 | sed -i "s/version: .*/version: $VERSION/" charts/homer-operator/Chart.yaml 152 | sed -i "s/appVersion: .*/appVersion: \"$VERSION\"/" charts/homer-operator/Chart.yaml 153 | 154 | # Update values.yaml to use the specific digest 155 | REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') 156 | IMAGE_WITH_DIGEST="${{ env.REGISTRY }}/${REPO_NAME}@${DIGEST}" 157 | 158 | # Update the image repository and tag in values.yaml (only main image, not rbac-proxy) 159 | sed -i "s|^ repository: .*| repository: ${{ env.REGISTRY }}/${REPO_NAME}|" charts/homer-operator/values.yaml 160 | sed -i "s|^ tag: .*| tag: \"$VERSION\"|" charts/homer-operator/values.yaml 161 | 162 | echo "Updated Chart.yaml and values.yaml" 163 | echo "Version: $VERSION" 164 | echo "Image: $IMAGE_WITH_DIGEST" 165 | 166 | - name: Validate updated chart 167 | run: | 168 | helm lint charts/homer-operator 169 | helm template test charts/homer-operator --dry-run > /dev/null 170 | echo "✅ Updated chart validation passed" 171 | 172 | - name: Package and Push Helm Chart 173 | run: | 174 | VERSION="${{ needs.build-image.outputs.version }}" 175 | REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') 176 | 177 | # Package the chart 178 | helm package charts/homer-operator --destination ./packaged-charts 179 | 180 | # Push to GHCR 181 | helm push "./packaged-charts/homer-operator-${VERSION}.tgz" "oci://${{ env.REGISTRY }}/${REPO_NAME}/charts" 182 | 183 | echo "✅ Chart pushed to: oci://${{ env.REGISTRY }}/${REPO_NAME}/charts/homer-operator:${VERSION}" 184 | 185 | create-github-release: 186 | name: Create GitHub Release 187 | runs-on: ubuntu-latest 188 | needs: [build-image, release-helm] 189 | if: startsWith(github.ref, 'refs/tags/') 190 | permissions: 191 | contents: write 192 | steps: 193 | - name: Checkout code 194 | uses: actions/checkout@v4 195 | 196 | - name: Download SBOM 197 | uses: actions/download-artifact@v4 198 | with: 199 | name: sbom 200 | 201 | - name: Create Release 202 | uses: softprops/action-gh-release@v2 203 | with: 204 | draft: false 205 | prerelease: false 206 | generate_release_notes: true 207 | files: | 208 | sbom.spdx.json 209 | body: | 210 | ## Release ${{ needs.build-image.outputs.version }} 211 | 212 | ### Docker Image 213 | ```bash 214 | docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.version }} 215 | ``` 216 | 217 | **Digest:** `${{ needs.build-image.outputs.digest }}` 218 | 219 | ### Helm Chart 220 | ```bash 221 | helm install homer-operator oci://${{ env.REGISTRY }}/${{ github.repository }}/charts/homer-operator \ 222 | --version ${{ needs.build-image.outputs.version }} \ 223 | --namespace homer-operator-system \ 224 | --create-namespace 225 | ``` 226 | 227 | ### What's Changed 228 | See the full changelog below. 229 | 230 | --- 231 | 232 | **Full Changelog**: https://github.com/${{ github.repository }}/commits/${{ github.ref_name }} 233 | -------------------------------------------------------------------------------- /config/samples/homer_v1alpha1_dashboard_multicluster.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Example: Multi-cluster Dashboard Configuration 3 | # This sample demonstrates how to configure homer-operator to discover 4 | # services from multiple Kubernetes clusters 5 | apiVersion: v1 6 | kind: Secret 7 | metadata: 8 | name: cluster-staging-kubeconfig 9 | namespace: public 10 | type: Opaque 11 | data: 12 | # Base64 encoded kubeconfig for staging cluster 13 | # kubectl create secret generic cluster-staging-kubeconfig \ 14 | # --from-file=kubeconfig=/path/to/staging/kubeconfig \ 15 | # -n public 16 | kubeconfig: 17 | --- 18 | apiVersion: v1 19 | kind: Secret 20 | metadata: 21 | name: cluster-production-kubeconfig 22 | namespace: public 23 | type: Opaque 24 | data: 25 | # Base64 encoded kubeconfig for production cluster 26 | kubeconfig: 27 | --- 28 | apiVersion: homer.rajsingh.info/v1alpha1 29 | kind: Dashboard 30 | metadata: 31 | name: multicluster-dashboard 32 | namespace: public 33 | spec: 34 | replicas: 2 35 | 36 | # Homer configuration 37 | homerConfig: 38 | title: "Multi-Cluster Dashboard" 39 | subtitle: "Aggregated view across all clusters" 40 | logo: "https://raw.githubusercontent.com/rajsinghtech/homer-operator/main/homer/Homer-Operator.png" 41 | header: true 42 | footer: '

Multi-Cluster Dashboard powered by Homer-Operator

' 43 | 44 | colors: 45 | light: 46 | highlight-primary: "#3367d6" 47 | highlight-secondary: "#4285f4" 48 | highlight-hover: "#5a95f5" 49 | background: "#f5f5f5" 50 | card-background: "#ffffff" 51 | text: "#363636" 52 | text-header: "#ffffff" 53 | text-title: "#303030" 54 | text-subtitle: "#424242" 55 | card-shadow: "rgba(0, 0, 0, 0.1)" 56 | link: "#3273dc" 57 | link-hover: "#363636" 58 | dark: 59 | highlight-primary: "#3367d6" 60 | highlight-secondary: "#4285f4" 61 | highlight-hover: "#5a95f5" 62 | background: "#131313" 63 | card-background: "#2b2b2b" 64 | text: "#eaeaea" 65 | text-header: "#ffffff" 66 | text-title: "#fafafa" 67 | text-subtitle: "#f5f5f5" 68 | card-shadow: none 69 | link: "#3273dc" 70 | link-hover: "#ffdd57" 71 | 72 | # Default services (manually configured) 73 | services: 74 | - parameters: 75 | name: "Cluster Management" 76 | icon: "fas fa-network-wired" 77 | items: 78 | - parameters: 79 | name: "Local Cluster" 80 | subtitle: "Primary cluster dashboard" 81 | icon: "fas fa-home" 82 | tag: "local" 83 | tagstyle: "is-primary" 84 | url: "https://kubernetes-dashboard.local.example.com" 85 | - parameters: 86 | name: "Staging Cluster" 87 | subtitle: "Staging environment" 88 | icon: "fas fa-flask" 89 | tag: "staging" 90 | tagstyle: "is-warning" 91 | url: "https://kubernetes-dashboard.staging.example.com" 92 | - parameters: 93 | name: "Production Cluster" 94 | subtitle: "Production environment" 95 | icon: "fas fa-industry" 96 | tag: "production" 97 | tagstyle: "is-danger" 98 | url: "https://kubernetes-dashboard.prod.example.com" 99 | 100 | # Remote cluster configurations 101 | remoteClusters: 102 | - name: staging 103 | enabled: true 104 | secretRef: 105 | name: cluster-staging-kubeconfig 106 | key: kubeconfig # Optional: defaults to "kubeconfig" 107 | # namespace: public # Optional: defaults to Dashboard namespace 108 | 109 | # Optional: Filter to specific namespaces in the remote cluster 110 | namespaceFilter: 111 | - "staging" 112 | - "qa" 113 | 114 | # Add labels to all discovered resources from this cluster 115 | clusterLabels: 116 | cluster: "staging" 117 | environment: "staging" 118 | 119 | # Optional: Cluster-specific selectors (override main selectors) 120 | ingressSelector: 121 | matchLabels: 122 | visibility: "public" 123 | 124 | httpRouteSelector: 125 | matchLabels: 126 | visibility: "public" 127 | 128 | - name: production 129 | enabled: true 130 | secretRef: 131 | name: cluster-production-kubeconfig 132 | key: kubeconfig 133 | 134 | # Discover from all namespaces (subject to RBAC) 135 | # namespaceFilter: [] # Empty means all namespaces 136 | 137 | clusterLabels: 138 | cluster: "production" 139 | environment: "prod" 140 | 141 | # Production-specific filters 142 | ingressSelector: 143 | matchLabels: 144 | visibility: "public" 145 | matchExpressions: 146 | - key: "app.kubernetes.io/instance" 147 | operator: NotIn 148 | values: ["canary", "test"] 149 | 150 | gatewaySelector: 151 | matchLabels: 152 | gateway-class: "production-gateway" 153 | 154 | # Global filters (apply to all clusters including local) 155 | ingressSelector: 156 | matchLabels: 157 | dashboard: "enabled" 158 | 159 | httpRouteSelector: 160 | matchLabels: 161 | dashboard: "enabled" 162 | 163 | gatewaySelector: 164 | matchLabels: 165 | gateway: "public" 166 | 167 | # Domain filters apply to all clusters 168 | domainFilters: 169 | - "example.com" 170 | - "example.org" 171 | 172 | # Service grouping configuration 173 | serviceGrouping: 174 | strategy: "custom" # Group by cluster and namespace 175 | customRules: 176 | - name: "Local Services" 177 | condition: 178 | "homer.rajsingh.info/cluster": "local" 179 | priority: 10 180 | - name: "Staging Services" 181 | condition: 182 | "cluster": "staging" 183 | priority: 5 184 | - name: "Production Services" 185 | condition: 186 | "cluster": "production" 187 | priority: 1 188 | --- 189 | # Example Service Account and RBAC for remote cluster access 190 | # This should be created in each remote cluster 191 | apiVersion: v1 192 | kind: ServiceAccount 193 | metadata: 194 | name: homer-operator-reader 195 | namespace: kube-system 196 | --- 197 | apiVersion: rbac.authorization.k8s.io/v1 198 | kind: ClusterRole 199 | metadata: 200 | name: homer-operator-reader 201 | rules: 202 | - apiGroups: ["networking.k8s.io"] 203 | resources: ["ingresses"] 204 | verbs: ["get", "list", "watch"] 205 | - apiGroups: ["gateway.networking.k8s.io"] 206 | resources: ["httproutes", "gateways"] 207 | verbs: ["get", "list", "watch"] 208 | - apiGroups: [""] 209 | resources: ["namespaces"] 210 | verbs: ["get", "list"] 211 | --- 212 | apiVersion: rbac.authorization.k8s.io/v1 213 | kind: ClusterRoleBinding 214 | metadata: 215 | name: homer-operator-reader 216 | roleRef: 217 | apiGroup: rbac.authorization.k8s.io 218 | kind: ClusterRole 219 | name: homer-operator-reader 220 | subjects: 221 | - kind: ServiceAccount 222 | name: homer-operator-reader 223 | namespace: kube-system 224 | --- 225 | # Instructions for creating kubeconfig for remote cluster: 226 | # 227 | # 1. Get the service account token: 228 | # kubectl -n kube-system get secret $(kubectl -n kube-system get sa homer-operator-reader -o jsonpath='{.secrets[0].name}') -o jsonpath='{.data.token}' | base64 -d 229 | # 230 | # 2. Get the cluster CA certificate: 231 | # kubectl config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' 232 | # 233 | # 3. Get the cluster server URL: 234 | # kubectl config view -o jsonpath='{.clusters[0].cluster.server}' 235 | # 236 | # 4. Create a kubeconfig file with the above information: 237 | # 238 | # apiVersion: v1 239 | # kind: Config 240 | # clusters: 241 | # - name: remote-cluster 242 | # cluster: 243 | # certificate-authority-data: 244 | # server: 245 | # contexts: 246 | # - name: homer-operator 247 | # context: 248 | # cluster: remote-cluster 249 | # user: homer-operator 250 | # current-context: homer-operator 251 | # users: 252 | # - name: homer-operator 253 | # user: 254 | # token: 255 | # 256 | # 5. Create the secret: 257 | # kubectl create secret generic cluster-staging-kubeconfig \ 258 | # --from-file=kubeconfig=./kubeconfig-staging.yaml \ 259 | # -n public -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # Image URL to use all building/pushing image targets 3 | IMG ?= ghcr.io/rajsinghtech/homer-operator:main 4 | # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. 5 | ENVTEST_K8S_VERSION = 1.29.0 6 | 7 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 8 | ifeq (,$(shell go env GOBIN)) 9 | GOBIN=$(shell go env GOPATH)/bin 10 | else 11 | GOBIN=$(shell go env GOBIN) 12 | endif 13 | 14 | # CONTAINER_TOOL defines the container tool to be used for building images. 15 | # Be aware that the target commands are only tested with Docker which is 16 | # scaffolded by default. However, you might want to replace it to use other 17 | # tools. (i.e. podman) 18 | CONTAINER_TOOL ?= docker 19 | 20 | # Setting SHELL to bash allows bash commands to be executed by recipes. 21 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 22 | SHELL = /usr/bin/env bash -o pipefail 23 | .SHELLFLAGS = -ec 24 | 25 | .PHONY: all 26 | all: build 27 | 28 | ##@ General 29 | 30 | # Display help for targets with ## comments 31 | 32 | .PHONY: help 33 | help: ## Display this help. 34 | @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) 35 | 36 | ##@ Development 37 | 38 | .PHONY: manifests 39 | manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. 40 | $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases 41 | @echo "Syncing CRDs to Helm chart..." 42 | @mkdir -p charts/homer-operator/templates 43 | @./scripts/sync-crd-to-helm.sh config/crd/bases/homer.rajsingh.info_dashboards.yaml > charts/homer-operator/templates/crd.yaml 44 | @if [ -d charts/homer-operator/crds ]; then \ 45 | echo "Removing crds directory to use templates approach"; \ 46 | rm -rf charts/homer-operator/crds; \ 47 | fi 48 | 49 | .PHONY: generate 50 | generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. 51 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 52 | 53 | .PHONY: fmt 54 | fmt: ## Run go fmt against code. 55 | go fmt ./... 56 | 57 | .PHONY: vet 58 | vet: ## Run go vet against code. 59 | go vet ./... 60 | 61 | .PHONY: test 62 | test: manifests generate fmt vet envtest ## Run tests. 63 | KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out 64 | 65 | # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. 66 | .PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. 67 | test-e2e: 68 | go test ./test/e2e/ -v -ginkgo.v 69 | 70 | .PHONY: lint 71 | lint: golangci-lint ## Run golangci-lint linter & yamllint 72 | $(GOLANGCI_LINT) run 73 | 74 | .PHONY: lint-fix 75 | lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes 76 | $(GOLANGCI_LINT) run --fix 77 | 78 | ##@ Build 79 | 80 | .PHONY: build 81 | build: manifests generate fmt vet ## Build manager binary. 82 | go build -o bin/manager cmd/main.go 83 | 84 | .PHONY: run 85 | run: manifests generate fmt vet ## Run a controller from your host. 86 | ENABLE_GATEWAY_API=true go run ./cmd/main.go 87 | 88 | # If you wish to build the manager image targeting other platforms you can use the --platform flag. 89 | # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. 90 | # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 91 | .PHONY: docker-build 92 | docker-build: ## Build docker image with the manager. 93 | $(CONTAINER_TOOL) build -t ${IMG} . 94 | 95 | .PHONY: docker-push 96 | docker-push: ## Push docker image with the manager. 97 | $(CONTAINER_TOOL) push ${IMG} 98 | 99 | # PLATFORMS defines the target platforms for the manager image be built to provide support to multiple 100 | # architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: 101 | # - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ 102 | # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 103 | # - 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) 104 | # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. 105 | PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le 106 | .PHONY: docker-buildx 107 | docker-buildx: ## Build and push docker image for the manager for cross-platform support 108 | # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile 109 | sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross 110 | - $(CONTAINER_TOOL) buildx create --name project-v3-builder 111 | $(CONTAINER_TOOL) buildx use project-v3-builder 112 | - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . 113 | - $(CONTAINER_TOOL) buildx rm project-v3-builder 114 | rm Dockerfile.cross 115 | 116 | .PHONY: build-installer 117 | build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. 118 | mkdir -p dist 119 | @if [ -d "config/crd" ]; then \ 120 | $(KUSTOMIZE) build config/crd > dist/install.yaml; \ 121 | fi 122 | echo "---" >> dist/install.yaml # Add a document separator before appending 123 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 124 | $(KUSTOMIZE) build config/default >> dist/install.yaml 125 | @echo "Updating deploy/operator.yaml..." 126 | @cp dist/install.yaml deploy/operator.yaml 127 | 128 | ##@ Deployment 129 | 130 | ifndef ignore-not-found 131 | ignore-not-found = false 132 | endif 133 | 134 | .PHONY: install 135 | install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. 136 | $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - 137 | 138 | .PHONY: uninstall 139 | uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 140 | $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - 141 | 142 | .PHONY: deploy 143 | deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. 144 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 145 | $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - 146 | 147 | .PHONY: undeploy 148 | undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 149 | $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - 150 | 151 | ##@ Dependencies 152 | 153 | ## Location to install dependencies to 154 | LOCALBIN ?= $(shell pwd)/bin 155 | $(LOCALBIN): 156 | mkdir -p $(LOCALBIN) 157 | 158 | ## Tool Binaries 159 | KUBECTL ?= kubectl 160 | KUSTOMIZE ?= $(LOCALBIN)/kustomize 161 | CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen 162 | ENVTEST ?= $(LOCALBIN)/setup-envtest 163 | GOLANGCI_LINT = $(LOCALBIN)/golangci-lint 164 | 165 | ## Tool Versions 166 | KUSTOMIZE_VERSION ?= v5.3.0 167 | CONTROLLER_TOOLS_VERSION ?= v0.19.0 168 | ENVTEST_VERSION ?= latest 169 | GOLANGCI_LINT_VERSION ?= v2.5.0 170 | 171 | .PHONY: kustomize 172 | kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. 173 | $(KUSTOMIZE): $(LOCALBIN) 174 | $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) 175 | 176 | .PHONY: controller-gen 177 | controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. 178 | $(CONTROLLER_GEN): $(LOCALBIN) 179 | $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) 180 | 181 | .PHONY: envtest 182 | envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. 183 | $(ENVTEST): $(LOCALBIN) 184 | $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) 185 | 186 | .PHONY: golangci-lint 187 | golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. 188 | $(GOLANGCI_LINT): $(LOCALBIN) 189 | $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,${GOLANGCI_LINT_VERSION}) 190 | 191 | # Download and install Go tools with version pinning 192 | define go-install-tool 193 | @[ -f $(1) ] || { \ 194 | package=$(2)@$(3) ;\ 195 | echo "Downloading $${package}" ;\ 196 | GOBIN=$(LOCALBIN) go install $${package} ;\ 197 | } 198 | endef 199 | -------------------------------------------------------------------------------- /api/v1alpha1/dashboard_types_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "testing" 5 | 6 | homer "github.com/rajsinghtech/homer-operator/pkg/homer" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | ) 9 | 10 | func TestDashboardSpecValidation(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | spec DashboardSpec 14 | hasError bool 15 | }{ 16 | { 17 | name: "Valid minimal spec", 18 | spec: DashboardSpec{ 19 | HomerConfig: homer.HomerConfig{ 20 | Title: "Test Dashboard", 21 | }, 22 | }, 23 | hasError: false, 24 | }, 25 | { 26 | name: "Valid spec with service grouping", 27 | spec: DashboardSpec{ 28 | HomerConfig: homer.HomerConfig{ 29 | Title: "Test Dashboard", 30 | }, 31 | ServiceGrouping: &ServiceGroupingConfig{ 32 | Strategy: "label", 33 | LabelKey: "team", 34 | }, 35 | }, 36 | hasError: false, 37 | }, 38 | { 39 | name: "Valid spec with custom grouping rules", 40 | spec: DashboardSpec{ 41 | HomerConfig: homer.HomerConfig{ 42 | Title: "Test Dashboard", 43 | }, 44 | ServiceGrouping: &ServiceGroupingConfig{ 45 | Strategy: "custom", 46 | CustomRules: []GroupingRule{ 47 | { 48 | Name: "Production Services", 49 | Condition: map[string]string{"environment": "prod"}, 50 | Priority: 1, 51 | }, 52 | { 53 | Name: "Development Services", 54 | Condition: map[string]string{"environment": "dev"}, 55 | Priority: 2, 56 | }, 57 | }, 58 | }, 59 | }, 60 | hasError: false, 61 | }, 62 | { 63 | name: "Valid spec with health check config", 64 | spec: DashboardSpec{ 65 | HomerConfig: homer.HomerConfig{ 66 | Title: "Test Dashboard", 67 | }, 68 | HealthCheck: &ServiceHealthConfig{ 69 | Enabled: true, 70 | Interval: "30s", 71 | Timeout: "10s", 72 | HealthPath: "/health", 73 | ExpectedCode: 200, 74 | Headers: map[string]string{ 75 | "User-Agent": "Homer-Health-Check", 76 | }, 77 | }, 78 | }, 79 | hasError: false, 80 | }, 81 | { 82 | name: "Valid spec with remote clusters", 83 | spec: DashboardSpec{ 84 | HomerConfig: homer.HomerConfig{ 85 | Title: "Test Dashboard", 86 | }, 87 | }, 88 | hasError: false, 89 | }, 90 | { 91 | name: "Valid spec with all features", 92 | spec: DashboardSpec{ 93 | HomerConfig: homer.HomerConfig{ 94 | Title: "Complete Dashboard", 95 | Subtitle: "With all features enabled", 96 | }, 97 | Replicas: int32Ptr(2), 98 | ServiceGrouping: &ServiceGroupingConfig{ 99 | Strategy: "custom", 100 | CustomRules: []GroupingRule{ 101 | { 102 | Name: "Frontend Services", 103 | Condition: map[string]string{"app": "web-*", "tier": "frontend"}, 104 | Priority: 1, 105 | }, 106 | }, 107 | }, 108 | ValidationLevel: "strict", 109 | HealthCheck: &ServiceHealthConfig{ 110 | Enabled: true, 111 | Interval: "60s", 112 | Timeout: "15s", 113 | HealthPath: "/api/health", 114 | ExpectedCode: 200, 115 | }, 116 | DomainFilters: []string{"example.com", "internal.local"}, 117 | }, 118 | hasError: false, 119 | }, 120 | } 121 | 122 | for _, tt := range tests { 123 | t.Run(tt.name, func(t *testing.T) { 124 | dashboard := &Dashboard{ 125 | ObjectMeta: metav1.ObjectMeta{ 126 | Name: "test-dashboard", 127 | Namespace: "default", 128 | }, 129 | Spec: tt.spec, 130 | } 131 | 132 | // Basic validation - check that required fields are present 133 | if dashboard.Spec.HomerConfig.Title == "" { 134 | if !tt.hasError { 135 | t.Error("Expected title to be set for valid dashboard") 136 | } 137 | } 138 | 139 | // Validate service grouping config 140 | if dashboard.Spec.ServiceGrouping != nil { 141 | sg := dashboard.Spec.ServiceGrouping 142 | if sg.Strategy == "label" && sg.LabelKey == "" { 143 | if !tt.hasError { 144 | t.Error("Expected labelKey to be set when strategy is 'label'") 145 | } 146 | } 147 | if sg.Strategy == "custom" && len(sg.CustomRules) == 0 { 148 | if !tt.hasError { 149 | t.Error("Expected custom rules to be set when strategy is 'custom'") 150 | } 151 | } 152 | } 153 | 154 | // Validate health check config 155 | if dashboard.Spec.HealthCheck != nil { 156 | hc := dashboard.Spec.HealthCheck 157 | if hc.Enabled && hc.HealthPath == "" { 158 | if !tt.hasError { 159 | t.Error("Expected health path to be set when health check is enabled") 160 | } 161 | } 162 | } 163 | }) 164 | } 165 | } 166 | 167 | func TestServiceGroupingConfigDefaults(t *testing.T) { 168 | // Test that default values are handled properly 169 | config := &ServiceGroupingConfig{} 170 | 171 | // Strategy should default to "namespace" 172 | if config.Strategy == "" { 173 | config.Strategy = "namespace" 174 | } 175 | 176 | if config.Strategy != "namespace" { 177 | t.Errorf("Expected default strategy to be 'namespace', got '%s'", config.Strategy) 178 | } 179 | } 180 | 181 | func TestGroupingRuleValidation(t *testing.T) { 182 | tests := []struct { 183 | name string 184 | rule GroupingRule 185 | valid bool 186 | }{ 187 | { 188 | name: "Valid rule", 189 | rule: GroupingRule{ 190 | Name: "Production Services", 191 | Condition: map[string]string{"environment": "prod"}, 192 | Priority: 1, 193 | }, 194 | valid: true, 195 | }, 196 | { 197 | name: "Rule without name", 198 | rule: GroupingRule{ 199 | Condition: map[string]string{"environment": "prod"}, 200 | Priority: 1, 201 | }, 202 | valid: false, 203 | }, 204 | { 205 | name: "Rule without condition", 206 | rule: GroupingRule{ 207 | Name: "Production Services", 208 | Priority: 1, 209 | }, 210 | valid: false, 211 | }, 212 | { 213 | name: "Rule with zero priority", 214 | rule: GroupingRule{ 215 | Name: "Production Services", 216 | Condition: map[string]string{"environment": "prod"}, 217 | Priority: 0, 218 | }, 219 | valid: false, 220 | }, 221 | } 222 | 223 | for _, tt := range tests { 224 | t.Run(tt.name, func(t *testing.T) { 225 | // Basic validation logic 226 | valid := tt.rule.Name != "" && 227 | len(tt.rule.Condition) > 0 && 228 | tt.rule.Priority > 0 229 | 230 | if valid != tt.valid { 231 | t.Errorf("Expected validity %v, got %v", tt.valid, valid) 232 | } 233 | }) 234 | } 235 | } 236 | 237 | func TestServiceHealthConfigValidation(t *testing.T) { 238 | tests := []struct { 239 | name string 240 | config ServiceHealthConfig 241 | valid bool 242 | }{ 243 | { 244 | name: "Valid config", 245 | config: ServiceHealthConfig{ 246 | Enabled: true, 247 | Interval: "30s", 248 | Timeout: "10s", 249 | HealthPath: "/health", 250 | ExpectedCode: 200, 251 | }, 252 | valid: true, 253 | }, 254 | { 255 | name: "Disabled config", 256 | config: ServiceHealthConfig{ 257 | Enabled: false, 258 | }, 259 | valid: true, 260 | }, 261 | { 262 | name: "Invalid expected code", 263 | config: ServiceHealthConfig{ 264 | Enabled: true, 265 | ExpectedCode: 999, 266 | }, 267 | valid: false, 268 | }, 269 | { 270 | name: "Valid expected code range", 271 | config: ServiceHealthConfig{ 272 | Enabled: true, 273 | ExpectedCode: 404, // Valid even for error codes 274 | }, 275 | valid: true, 276 | }, 277 | } 278 | 279 | for _, tt := range tests { 280 | t.Run(tt.name, func(t *testing.T) { 281 | // Basic validation logic 282 | valid := !tt.config.Enabled || 283 | (tt.config.ExpectedCode >= 100 && tt.config.ExpectedCode <= 599) 284 | 285 | if valid != tt.valid { 286 | t.Errorf("Expected validity %v, got %v", tt.valid, valid) 287 | } 288 | }) 289 | } 290 | } 291 | 292 | func TestDashboardCreation(t *testing.T) { 293 | dashboard := &Dashboard{ 294 | ObjectMeta: metav1.ObjectMeta{ 295 | Name: "test-dashboard", 296 | Namespace: "test-namespace", 297 | }, 298 | Spec: DashboardSpec{ 299 | HomerConfig: homer.HomerConfig{ 300 | Title: "Test Dashboard", 301 | Subtitle: "A test dashboard for validation", 302 | }, 303 | Replicas: int32Ptr(3), 304 | ServiceGrouping: &ServiceGroupingConfig{ 305 | Strategy: "label", 306 | LabelKey: "team", 307 | }, 308 | ValidationLevel: "warn", 309 | HealthCheck: &ServiceHealthConfig{ 310 | Enabled: true, 311 | Interval: "45s", 312 | Timeout: "12s", 313 | HealthPath: "/api/health", 314 | ExpectedCode: 200, 315 | Headers: map[string]string{ 316 | "User-Agent": "Homer-Health-Check/1.0", 317 | "Authorization": "Bearer health-token", 318 | }, 319 | }, 320 | }, 321 | } 322 | 323 | // Verify that the dashboard was created with correct values 324 | if dashboard.Spec.HomerConfig.Title != "Test Dashboard" { 325 | t.Errorf("Expected title 'Test Dashboard', got '%s'", dashboard.Spec.HomerConfig.Title) 326 | } 327 | 328 | if *dashboard.Spec.Replicas != 3 { 329 | t.Errorf("Expected 3 replicas, got %d", *dashboard.Spec.Replicas) 330 | } 331 | 332 | if dashboard.Spec.ServiceGrouping.Strategy != "label" { 333 | t.Errorf("Expected strategy 'label', got '%s'", dashboard.Spec.ServiceGrouping.Strategy) 334 | } 335 | 336 | if dashboard.Spec.ServiceGrouping.LabelKey != "team" { 337 | t.Errorf("Expected label key 'team', got '%s'", dashboard.Spec.ServiceGrouping.LabelKey) 338 | } 339 | 340 | if dashboard.Spec.ValidationLevel != "warn" { 341 | t.Errorf("Expected validation level 'warn', got '%s'", dashboard.Spec.ValidationLevel) 342 | } 343 | 344 | if !dashboard.Spec.HealthCheck.Enabled { 345 | t.Error("Expected health check to be enabled") 346 | } 347 | 348 | if dashboard.Spec.HealthCheck.Interval != "45s" { 349 | t.Errorf("Expected interval '45s', got '%s'", dashboard.Spec.HealthCheck.Interval) 350 | } 351 | } 352 | 353 | // Helper function to create int32 pointer 354 | func int32Ptr(i int32) *int32 { 355 | return &i 356 | } 357 | -------------------------------------------------------------------------------- /pkg/homer/grouping_test.go: -------------------------------------------------------------------------------- 1 | package homer 2 | 3 | import ( 4 | "testing" 5 | 6 | networkingv1 "k8s.io/api/networking/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | ) 9 | 10 | func TestDetermineServiceGroup(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | namespace string 14 | labels map[string]string 15 | annotations map[string]string 16 | config *ServiceGroupingConfig 17 | expected string 18 | }{ 19 | { 20 | name: "Default namespace grouping", 21 | namespace: "production", 22 | labels: map[string]string{}, 23 | annotations: map[string]string{}, 24 | config: nil, 25 | expected: "production", 26 | }, 27 | { 28 | name: "Explicit namespace grouping", 29 | namespace: "production", 30 | labels: map[string]string{}, 31 | annotations: map[string]string{}, 32 | config: &ServiceGroupingConfig{Strategy: ServiceGroupingNamespace}, 33 | expected: "production", 34 | }, 35 | { 36 | name: "Label-based grouping", 37 | namespace: "production", 38 | labels: map[string]string{"team": "frontend"}, 39 | annotations: map[string]string{}, 40 | config: &ServiceGroupingConfig{Strategy: ServiceGroupingLabel, LabelKey: "team"}, 41 | expected: "frontend", 42 | }, 43 | { 44 | name: "Label-based grouping with fallback", 45 | namespace: "production", 46 | labels: map[string]string{"environment": "prod"}, 47 | annotations: map[string]string{}, 48 | config: &ServiceGroupingConfig{Strategy: ServiceGroupingLabel, LabelKey: "team"}, 49 | expected: "production", // fallback to namespace 50 | }, 51 | { 52 | name: "Annotation override", 53 | namespace: "production", 54 | labels: map[string]string{"team": "frontend"}, 55 | annotations: map[string]string{"service.homer.rajsingh.info/name": "Custom Service Group"}, 56 | config: &ServiceGroupingConfig{Strategy: ServiceGroupingLabel, LabelKey: "team"}, 57 | expected: "Custom Service Group", // annotation takes precedence 58 | }, 59 | { 60 | name: "Custom rules grouping", 61 | namespace: "production", 62 | labels: map[string]string{"app": "web-app", "environment": "prod"}, 63 | annotations: map[string]string{}, 64 | config: &ServiceGroupingConfig{ 65 | Strategy: ServiceGroupingCustom, 66 | CustomRules: []GroupingRule{ 67 | { 68 | Name: "Production Web Services", 69 | Condition: map[string]string{"app": "web-*", "environment": "prod"}, 70 | Priority: 1, 71 | }, 72 | { 73 | Name: "Development Services", 74 | Condition: map[string]string{"environment": "dev"}, 75 | Priority: 2, 76 | }, 77 | }, 78 | }, 79 | expected: "Production Web Services", 80 | }, 81 | { 82 | name: "Custom rules no match fallback", 83 | namespace: "production", 84 | labels: map[string]string{"app": "database", "environment": "staging"}, 85 | annotations: map[string]string{}, 86 | config: &ServiceGroupingConfig{ 87 | Strategy: ServiceGroupingCustom, 88 | CustomRules: []GroupingRule{ 89 | { 90 | Name: "Production Web Services", 91 | Condition: map[string]string{"app": "web-*", "environment": "prod"}, 92 | Priority: 1, 93 | }, 94 | }, 95 | }, 96 | expected: "production", // fallback to namespace 97 | }, 98 | { 99 | name: "Empty namespace defaults to 'default'", 100 | namespace: "", // Empty namespace 101 | labels: map[string]string{}, 102 | annotations: map[string]string{}, 103 | config: nil, 104 | expected: "default", // should default to "default" 105 | }, 106 | { 107 | name: "Empty namespace with label strategy fallback", 108 | namespace: "", // Empty namespace 109 | labels: map[string]string{"app": "web"}, 110 | annotations: map[string]string{}, 111 | config: &ServiceGroupingConfig{Strategy: ServiceGroupingLabel, LabelKey: "team"}, // team label doesn't exist 112 | expected: "default", // fallback to "default" 113 | }, 114 | { 115 | name: "Empty label value with fallback", 116 | namespace: "production", 117 | labels: map[string]string{"team": ""}, // Empty label value 118 | annotations: map[string]string{}, 119 | config: &ServiceGroupingConfig{Strategy: ServiceGroupingLabel, LabelKey: "team"}, 120 | expected: "production", // should fallback to namespace when label is empty 121 | }, 122 | } 123 | 124 | for _, tt := range tests { 125 | t.Run(tt.name, func(t *testing.T) { 126 | result := determineServiceGroup(tt.namespace, tt.labels, tt.annotations, tt.config) 127 | if result != tt.expected { 128 | t.Errorf("Expected service group '%s', got '%s'", tt.expected, result) 129 | } 130 | }) 131 | } 132 | } 133 | 134 | func TestMatchesPattern(t *testing.T) { 135 | tests := []struct { 136 | value string 137 | pattern string 138 | expected bool 139 | }{ 140 | {"web-app", "web-*", true}, 141 | {"web-service", "web-*", true}, 142 | {"api-gateway", "web-*", false}, 143 | {"production", "prod*", true}, 144 | {"production", "production", true}, 145 | {"staging", "production", false}, 146 | {"anything", "*", true}, 147 | } 148 | 149 | for _, tt := range tests { 150 | t.Run(tt.value+"_"+tt.pattern, func(t *testing.T) { 151 | result := matchesPattern(tt.value, tt.pattern) 152 | if result != tt.expected { 153 | t.Errorf("matchesPattern(%s, %s) = %v, expected %v", tt.value, tt.pattern, result, tt.expected) 154 | } 155 | }) 156 | } 157 | } 158 | 159 | func TestMatchesCondition(t *testing.T) { 160 | labels := map[string]string{ 161 | "app": "web-app", 162 | "environment": "prod", 163 | "team": "frontend", 164 | } 165 | annotations := map[string]string{ 166 | "deployment.kubernetes.io/revision": "1", 167 | "custom.annotation": "value", 168 | } 169 | 170 | tests := []struct { 171 | name string 172 | condition map[string]string 173 | expected bool 174 | }{ 175 | { 176 | name: "Match single label", 177 | condition: map[string]string{"environment": "prod"}, 178 | expected: true, 179 | }, 180 | { 181 | name: "Match multiple labels", 182 | condition: map[string]string{"environment": "prod", "team": "frontend"}, 183 | expected: true, 184 | }, 185 | { 186 | name: "Match with wildcard", 187 | condition: map[string]string{"app": "web-*"}, 188 | expected: true, 189 | }, 190 | { 191 | name: "No match", 192 | condition: map[string]string{"environment": "staging"}, 193 | expected: false, 194 | }, 195 | { 196 | name: "Partial match fails", 197 | condition: map[string]string{"environment": "prod", "team": "backend"}, 198 | expected: false, 199 | }, 200 | { 201 | name: "Match annotation", 202 | condition: map[string]string{"custom.annotation": "value"}, 203 | expected: true, 204 | }, 205 | { 206 | name: "Missing key", 207 | condition: map[string]string{"missing": "value"}, 208 | expected: false, 209 | }, 210 | } 211 | 212 | for _, tt := range tests { 213 | t.Run(tt.name, func(t *testing.T) { 214 | result := matchesCondition(labels, annotations, tt.condition) 215 | if result != tt.expected { 216 | t.Errorf("matchesCondition(%v) = %v, expected %v", tt.condition, result, tt.expected) 217 | } 218 | }) 219 | } 220 | } 221 | 222 | func TestFlexibleGroupingIntegration(t *testing.T) { 223 | // Create test ingress with labels 224 | ingress := networkingv1.Ingress{ 225 | ObjectMeta: metav1.ObjectMeta{ 226 | Name: "test-app", 227 | Namespace: "production", 228 | Labels: map[string]string{ 229 | "app": "web-app", 230 | "team": "frontend", 231 | }, 232 | Annotations: map[string]string{ 233 | "item.homer.rajsingh.info/name": "My Web App", 234 | }, 235 | }, 236 | Spec: networkingv1.IngressSpec{ 237 | Rules: []networkingv1.IngressRule{ 238 | { 239 | Host: "app.example.com", 240 | }, 241 | }, 242 | }, 243 | } 244 | 245 | config := &HomerConfig{} 246 | 247 | // Test with label-based grouping 248 | groupingConfig := &ServiceGroupingConfig{ 249 | Strategy: ServiceGroupingLabel, 250 | LabelKey: "team", 251 | } 252 | 253 | UpdateHomerConfigIngressWithGrouping(config, ingress, nil, groupingConfig) 254 | 255 | if len(config.Services) != 1 { 256 | t.Fatalf("Expected 1 service, got %d", len(config.Services)) 257 | } 258 | 259 | service := config.Services[0] 260 | expectedName := "frontend" 261 | actualName := "" 262 | if service.Parameters != nil { 263 | actualName = service.Parameters["name"] 264 | } 265 | if actualName != expectedName { 266 | t.Errorf("Expected service name '%s', got '%s'", expectedName, actualName) 267 | } 268 | 269 | if len(service.Items) != 1 { 270 | t.Fatalf("Expected 1 item, got %d", len(service.Items)) 271 | } 272 | 273 | item := service.Items[0] 274 | // In the dynamic system, annotation-provided names are stored in Parameters 275 | expectedItemName := "My Web App" 276 | actualItemName := "" 277 | if item.Parameters != nil && item.Parameters["name"] != "" { 278 | actualItemName = item.Parameters["name"] 279 | } 280 | if actualItemName != expectedItemName { 281 | t.Errorf("Expected item name '%s', got '%s'", expectedItemName, actualItemName) 282 | } 283 | 284 | // Test adding another service to the same group 285 | ingress2 := ingress 286 | ingress2.ObjectMeta.Name = "another-app" 287 | ingress2.ObjectMeta.Annotations["item.homer.rajsingh.info/name"] = "Another App" 288 | ingress2.Spec.Rules[0].Host = "another.example.com" 289 | 290 | UpdateHomerConfigIngressWithGrouping(config, ingress2, nil, groupingConfig) 291 | 292 | if len(config.Services) != 1 { 293 | t.Fatalf("Expected 1 service after adding second app, got %d", len(config.Services)) 294 | } 295 | 296 | if len(config.Services[0].Items) != 2 { 297 | t.Fatalf("Expected 2 items in service, got %d", len(config.Services[0].Items)) 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /pkg/homer/hide_feature_test.go: -------------------------------------------------------------------------------- 1 | package homer 2 | 3 | import ( 4 | "testing" 5 | 6 | networkingv1 "k8s.io/api/networking/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" 9 | ) 10 | 11 | func TestIngressHideFeature(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | ingress networkingv1.Ingress 15 | expectedItemCount int 16 | description string 17 | }{ 18 | { 19 | name: "ingress without hide annotation", 20 | ingress: networkingv1.Ingress{ 21 | ObjectMeta: metav1.ObjectMeta{ 22 | Name: "test-app", 23 | Namespace: "default", 24 | Annotations: map[string]string{ 25 | "item.homer.rajsingh.info/name": "Test App", 26 | }, 27 | }, 28 | Spec: networkingv1.IngressSpec{ 29 | Rules: []networkingv1.IngressRule{ 30 | {Host: "test.example.com"}, 31 | }, 32 | }, 33 | }, 34 | expectedItemCount: 1, 35 | description: "should create item when no hide annotation", 36 | }, 37 | { 38 | name: "ingress with hide=false", 39 | ingress: networkingv1.Ingress{ 40 | ObjectMeta: metav1.ObjectMeta{ 41 | Name: "test-app", 42 | Namespace: "default", 43 | Annotations: map[string]string{ 44 | "item.homer.rajsingh.info/name": "Test App", 45 | "item.homer.rajsingh.info/hide": "false", 46 | }, 47 | }, 48 | Spec: networkingv1.IngressSpec{ 49 | Rules: []networkingv1.IngressRule{ 50 | {Host: "test.example.com"}, 51 | }, 52 | }, 53 | }, 54 | expectedItemCount: 1, 55 | description: "should create item when hide=false", 56 | }, 57 | { 58 | name: "ingress with hide=true", 59 | ingress: networkingv1.Ingress{ 60 | ObjectMeta: metav1.ObjectMeta{ 61 | Name: "test-app", 62 | Namespace: "default", 63 | Annotations: map[string]string{ 64 | "item.homer.rajsingh.info/name": "Test App", 65 | "item.homer.rajsingh.info/hide": "true", 66 | }, 67 | }, 68 | Spec: networkingv1.IngressSpec{ 69 | Rules: []networkingv1.IngressRule{ 70 | {Host: "test.example.com"}, 71 | }, 72 | }, 73 | }, 74 | expectedItemCount: 0, 75 | description: "should not create item when hide=true", 76 | }, 77 | { 78 | name: "ingress with hide=1", 79 | ingress: networkingv1.Ingress{ 80 | ObjectMeta: metav1.ObjectMeta{ 81 | Name: "test-app", 82 | Namespace: "default", 83 | Annotations: map[string]string{ 84 | "item.homer.rajsingh.info/name": "Test App", 85 | "item.homer.rajsingh.info/hide": "1", 86 | }, 87 | }, 88 | Spec: networkingv1.IngressSpec{ 89 | Rules: []networkingv1.IngressRule{ 90 | {Host: "test.example.com"}, 91 | }, 92 | }, 93 | }, 94 | expectedItemCount: 0, 95 | description: "should not create item when hide=1", 96 | }, 97 | { 98 | name: "ingress with hide=yes", 99 | ingress: networkingv1.Ingress{ 100 | ObjectMeta: metav1.ObjectMeta{ 101 | Name: "test-app", 102 | Namespace: "default", 103 | Annotations: map[string]string{ 104 | "item.homer.rajsingh.info/name": "Test App", 105 | "item.homer.rajsingh.info/hide": "yes", 106 | }, 107 | }, 108 | Spec: networkingv1.IngressSpec{ 109 | Rules: []networkingv1.IngressRule{ 110 | {Host: "test.example.com"}, 111 | }, 112 | }, 113 | }, 114 | expectedItemCount: 0, 115 | description: "should not create item when hide=yes", 116 | }, 117 | { 118 | name: "ingress with multiple hosts, one hidden", 119 | ingress: networkingv1.Ingress{ 120 | ObjectMeta: metav1.ObjectMeta{ 121 | Name: "test-app", 122 | Namespace: "default", 123 | Annotations: map[string]string{ 124 | "item.homer.rajsingh.info/name": "Test App", 125 | "item.homer.rajsingh.info/hide": "true", 126 | }, 127 | }, 128 | Spec: networkingv1.IngressSpec{ 129 | Rules: []networkingv1.IngressRule{ 130 | {Host: "test1.example.com"}, 131 | {Host: "test2.example.com"}, 132 | }, 133 | }, 134 | }, 135 | expectedItemCount: 0, 136 | description: "should not create any items when hide=true applies to all", 137 | }, 138 | } 139 | 140 | for _, tt := range tests { 141 | t.Run(tt.name, func(t *testing.T) { 142 | config := &HomerConfig{Title: "Test Dashboard"} 143 | UpdateHomerConfigIngress(config, tt.ingress, nil) 144 | 145 | totalItems := 0 146 | for _, service := range config.Services { 147 | totalItems += len(service.Items) 148 | } 149 | 150 | if totalItems != tt.expectedItemCount { 151 | t.Errorf("%s: expected %d items, got %d items", tt.description, tt.expectedItemCount, totalItems) 152 | } 153 | }) 154 | } 155 | } 156 | 157 | func TestHTTPRouteHideFeature(t *testing.T) { 158 | tests := []struct { 159 | name string 160 | httproute gatewayv1.HTTPRoute 161 | expectedItemCount int 162 | description string 163 | }{ 164 | { 165 | name: "httproute without hide annotation", 166 | httproute: gatewayv1.HTTPRoute{ 167 | ObjectMeta: metav1.ObjectMeta{ 168 | Name: "test-route", 169 | Namespace: "default", 170 | Annotations: map[string]string{ 171 | "item.homer.rajsingh.info/name": "Test Route", 172 | }, 173 | }, 174 | Spec: gatewayv1.HTTPRouteSpec{ 175 | Hostnames: []gatewayv1.Hostname{"test.example.com"}, 176 | }, 177 | }, 178 | expectedItemCount: 1, 179 | description: "should create item when no hide annotation", 180 | }, 181 | { 182 | name: "httproute with hide=false", 183 | httproute: gatewayv1.HTTPRoute{ 184 | ObjectMeta: metav1.ObjectMeta{ 185 | Name: "test-route", 186 | Namespace: "default", 187 | Annotations: map[string]string{ 188 | "item.homer.rajsingh.info/name": "Test Route", 189 | "item.homer.rajsingh.info/hide": "false", 190 | }, 191 | }, 192 | Spec: gatewayv1.HTTPRouteSpec{ 193 | Hostnames: []gatewayv1.Hostname{"test.example.com"}, 194 | }, 195 | }, 196 | expectedItemCount: 1, 197 | description: "should create item when hide=false", 198 | }, 199 | { 200 | name: "httproute with hide=true", 201 | httproute: gatewayv1.HTTPRoute{ 202 | ObjectMeta: metav1.ObjectMeta{ 203 | Name: "test-route", 204 | Namespace: "default", 205 | Annotations: map[string]string{ 206 | "item.homer.rajsingh.info/name": "Test Route", 207 | "item.homer.rajsingh.info/hide": "true", 208 | }, 209 | }, 210 | Spec: gatewayv1.HTTPRouteSpec{ 211 | Hostnames: []gatewayv1.Hostname{"test.example.com"}, 212 | }, 213 | }, 214 | expectedItemCount: 0, 215 | description: "should not create item when hide=true", 216 | }, 217 | { 218 | name: "httproute with hide=0", 219 | httproute: gatewayv1.HTTPRoute{ 220 | ObjectMeta: metav1.ObjectMeta{ 221 | Name: "test-route", 222 | Namespace: "default", 223 | Annotations: map[string]string{ 224 | "item.homer.rajsingh.info/name": "Test Route", 225 | "item.homer.rajsingh.info/hide": "0", 226 | }, 227 | }, 228 | Spec: gatewayv1.HTTPRouteSpec{ 229 | Hostnames: []gatewayv1.Hostname{"test.example.com"}, 230 | }, 231 | }, 232 | expectedItemCount: 1, 233 | description: "should create item when hide=0", 234 | }, 235 | { 236 | name: "httproute with multiple hostnames, all hidden", 237 | httproute: gatewayv1.HTTPRoute{ 238 | ObjectMeta: metav1.ObjectMeta{ 239 | Name: "test-route", 240 | Namespace: "default", 241 | Annotations: map[string]string{ 242 | "item.homer.rajsingh.info/name": "Test Route", 243 | "item.homer.rajsingh.info/hide": "true", 244 | }, 245 | }, 246 | Spec: gatewayv1.HTTPRouteSpec{ 247 | Hostnames: []gatewayv1.Hostname{ 248 | "test1.example.com", 249 | "test2.example.com", 250 | }, 251 | }, 252 | }, 253 | expectedItemCount: 0, 254 | description: "should not create any items when hide=true applies to all", 255 | }, 256 | } 257 | 258 | for _, tt := range tests { 259 | t.Run(tt.name, func(t *testing.T) { 260 | config := &HomerConfig{Title: "Test Dashboard"} 261 | UpdateHomerConfigHTTPRoute(config, &tt.httproute, nil) 262 | 263 | totalItems := 0 264 | for _, service := range config.Services { 265 | totalItems += len(service.Items) 266 | } 267 | 268 | if totalItems != tt.expectedItemCount { 269 | t.Errorf("%s: expected %d items, got %d items", tt.description, tt.expectedItemCount, totalItems) 270 | } 271 | }) 272 | } 273 | } 274 | 275 | func TestHideFeatureWithDomainFilters(t *testing.T) { 276 | t.Run("hidden ingress with domain filters", func(t *testing.T) { 277 | ingress := networkingv1.Ingress{ 278 | ObjectMeta: metav1.ObjectMeta{ 279 | Name: "test-app", 280 | Namespace: "default", 281 | Annotations: map[string]string{ 282 | "item.homer.rajsingh.info/hide": "true", 283 | }, 284 | }, 285 | Spec: networkingv1.IngressSpec{ 286 | Rules: []networkingv1.IngressRule{ 287 | {Host: "test.example.com"}, 288 | }, 289 | }, 290 | } 291 | 292 | config := &HomerConfig{Title: "Test Dashboard"} 293 | UpdateHomerConfigIngress(config, ingress, []string{"example.com"}) 294 | 295 | totalItems := 0 296 | for _, service := range config.Services { 297 | totalItems += len(service.Items) 298 | } 299 | 300 | if totalItems != 0 { 301 | t.Errorf("Expected 0 items when hide=true even with matching domain filters, got %d", totalItems) 302 | } 303 | }) 304 | 305 | t.Run("hidden httproute with domain filters", func(t *testing.T) { 306 | httproute := gatewayv1.HTTPRoute{ 307 | ObjectMeta: metav1.ObjectMeta{ 308 | Name: "test-route", 309 | Namespace: "default", 310 | Annotations: map[string]string{ 311 | "item.homer.rajsingh.info/hide": "true", 312 | }, 313 | }, 314 | Spec: gatewayv1.HTTPRouteSpec{ 315 | Hostnames: []gatewayv1.Hostname{"test.example.com"}, 316 | }, 317 | } 318 | 319 | config := &HomerConfig{Title: "Test Dashboard"} 320 | UpdateHomerConfigHTTPRoute(config, &httproute, []string{"example.com"}) 321 | 322 | totalItems := 0 323 | for _, service := range config.Services { 324 | totalItems += len(service.Items) 325 | } 326 | 327 | if totalItems != 0 { 328 | t.Errorf("Expected 0 items when hide=true even with matching domain filters, got %d", totalItems) 329 | } 330 | }) 331 | } 332 | -------------------------------------------------------------------------------- /config/samples/homer_v1alpha1_dashboard.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: homer.rajsingh.info/v1alpha1 3 | kind: Dashboard 4 | metadata: 5 | name: dashboard-sample 6 | namespace: public 7 | labels: 8 | app: homer-dashboard 9 | tier: frontend 10 | spec: 11 | replicas: 2 12 | 13 | # DNS Configuration (optional) 14 | # Controls DNS policy and configuration for dashboard pods 15 | # dnsPolicy: "ClusterFirst" # ClusterFirst, ClusterFirstWithHostNet, Default, None 16 | # dnsConfig: | 17 | # { 18 | # "nameservers": ["8.8.8.8", "8.8.4.4"], 19 | # "searches": ["example.com", "svc.cluster.local"], 20 | # "options": [ 21 | # {"name": "ndots", "value": "2"}, 22 | # {"name": "timeout", "value": "5"} 23 | # ] 24 | # } 25 | 26 | # Resource Requirements (optional) 27 | # Define CPU and memory limits/requests for the Homer container 28 | # resources: 29 | # limits: 30 | # cpu: "500m" 31 | # memory: "512Mi" 32 | # requests: 33 | # cpu: "100m" 34 | # memory: "128Mi" 35 | homerConfig: 36 | title: "Raj Singh's" 37 | subtitle: "Infrastructure Dashboard" 38 | logo: "https://raw.githubusercontent.com/rajsinghtech/homer-operator/main/homer/Homer-Operator.png" 39 | header: true 40 | footer: '

Powered by Homer-Operator | GitHub

' 41 | colors: 42 | light: 43 | highlight-primary: "#d2b48c" 44 | highlight-secondary: "#c8a974" 45 | highlight-hover: "#b8956e" 46 | background: "#fefcf9" 47 | card-background: "#f6f0e8" 48 | text: "#3b2f2f" 49 | text-header: "#3b2f2f" 50 | text-title: "#2f2626" 51 | text-subtitle: "#6e5c5c" 52 | card-shadow: rgba(120, 100, 80, 0.1) 53 | link: "#a86c3f" # Warm medium brown 54 | link-hover: "#6b3e1d" # Darker brown for hover contrast 55 | 56 | dark: 57 | highlight-primary: "#d2b48c" 58 | highlight-secondary: "#a98e69" 59 | highlight-hover: "#917555" 60 | background: "#1b1a17" 61 | card-background: "#2a2824" 62 | text: "#e9e1d8" 63 | text-header: "#e9e1d8" 64 | text-title: "#f5f0e6" 65 | text-subtitle: "#cbb9a8" 66 | card-shadow: none 67 | link: "#e2b185" # Light warm tan (for contrast in dark mode) 68 | link-hover: "#f5cda3" # Brighter hover tan 69 | 70 | # Default layout settings 71 | defaults: 72 | layout: "columns" 73 | colorTheme: "auto" 74 | 75 | # Enhanced links with targets 76 | links: 77 | - name: "Homer-Operator GitHub" 78 | icon: "fab fa-github" 79 | url: "https://github.com/rajsinghtech/homer-operator" 80 | target: "_blank" 81 | - name: "Homer Documentation" 82 | icon: "fas fa-book" 83 | url: "https://github.com/bastienwirtz/homer" 84 | target: "_blank" 85 | - name: "Kubernetes Dashboard" 86 | icon: "fas fa-dharmachakra" 87 | url: "https://kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/" 88 | target: "_blank" 89 | 90 | # Comprehensive service catalog 91 | services: 92 | - parameters: 93 | name: "Infrastructure" 94 | icon: "fas fa-server" 95 | items: 96 | - parameters: 97 | name: "Tailscale" 98 | logo: "https://raw.githubusercontent.com/tailscale/tailscale/refs/heads/main/client/web/src/assets/icons/tailscale-icon.svg" 99 | subtitle: "Private Mesh Network" 100 | tag: "network" 101 | tagstyle: "is-info" 102 | url: "https://login.tailscale.com/admin/machines" 103 | 104 | - parameters: 105 | name: "Development Tools" 106 | icon: "fas fa-code" 107 | items: 108 | - parameters: 109 | name: "Kubernetes Manifests" 110 | logo: "https://raw.githubusercontent.com/kubernetes/community/master/icons/png/resources/labeled/deploy-128.png" 111 | subtitle: "GitOps Repository" 112 | tag: "git" 113 | tagstyle: "is-primary" 114 | url: "https://github.com/rajsinghtech/kubernetes-manifests" 115 | target: "_blank" 116 | - parameters: 117 | name: "ArgoCD" 118 | logo: "https://raw.githubusercontent.com/argoproj/argo-cd/master/ui/src/assets/images/argo.png" 119 | subtitle: "GitOps Controller" 120 | tag: "git" 121 | tagstyle: "is-primary" 122 | url: "https://argocd.rajsingh.info" 123 | target: "_blank" 124 | 125 | - parameters: 126 | name: "Home" 127 | icon: "fas fa-home" 128 | items: 129 | - parameters: 130 | name: "Frigate NVR" 131 | subtitle: "AI-powered Security Camera System" 132 | logo: "https://raw.githubusercontent.com/blakeblackshear/frigate/dev/docs/static/img/logo.svg" 133 | tag: "home" 134 | keywords: "security, camera, nvr, ai, monitoring" 135 | 136 | # Custom assets and PWA configuration 137 | # Enables custom branding, icons, and Progressive Web App features 138 | # assets: 139 | # configMapRef: 140 | # name: "dashboard-assets" 141 | # namespace: "public" # Optional: defaults to Dashboard namespace 142 | # icons: 143 | # favicon: "favicon.ico" # Custom favicon 144 | # appleTouchIcon: "apple-touch-icon.png" # iOS home screen icon 145 | # pwaIcon192: "pwa-192x192.png" # PWA icon 192x192 146 | # pwaIcon512: "pwa-512x512.png" # PWA icon 512x512 147 | # pwa: 148 | # enabled: true 149 | # name: "Homer Dashboard" # Full PWA name 150 | # shortName: "Homer" # Short name for home screen 151 | # description: "Homer Dashboard powered by Homer-Operator" 152 | # themeColor: "#3367d6" # Theme color for browser UI 153 | # backgroundColor: "#ffffff" # Background color for splash screen 154 | # display: "standalone" # Display mode: standalone, fullscreen, minimal-ui, browser 155 | # startUrl: "/" # Starting URL when launched from home screen 156 | 157 | # Secret references for smart cards and authentication 158 | # Enables secure storage of sensitive data for Homer smart cards 159 | # secrets: 160 | # apiKey: 161 | # name: "dashboard-secrets" 162 | # key: "api-key" 163 | # namespace: "public" # Optional: defaults to Dashboard namespace 164 | # token: 165 | # name: "dashboard-secrets" 166 | # key: "auth-token" 167 | # password: 168 | # name: "dashboard-secrets" 169 | # key: "password" 170 | # username: 171 | # name: "dashboard-secrets" 172 | # key: "username" 173 | # headers: # Custom authentication headers from secrets 174 | # Authorization: 175 | # name: "dashboard-secrets" 176 | # key: "bearer-token" 177 | # X-API-Key: 178 | # name: "dashboard-secrets" 179 | # key: "api-key" 180 | 181 | # Optional selectors and filters for controlling which resources are included 182 | 183 | # Gateway selector for filtering HTTPRoutes by Gateway labels (optional) 184 | # If not specified, all HTTPRoutes matching annotation criteria are included 185 | gatewaySelector: 186 | matchLabels: 187 | # external-dns: unifi 188 | gateway: public 189 | # matchExpressions: 190 | # - key: "app.kubernetes.io/name" 191 | # operator: In 192 | # values: ["istio", "envoy-gateway", "nginx-gateway"] 193 | 194 | # HTTPRoute selector for filtering HTTPRoutes by their own labels (optional) 195 | # Fine-grained control over which HTTPRoutes are included in the dashboard 196 | # httpRouteSelector: 197 | # matchLabels: 198 | # environment: "production" # Only include production HTTPRoutes 199 | # team: "platform" # Only include platform team HTTPRoutes 200 | # tier: "frontend" # Only include frontend HTTPRoutes 201 | # matchExpressions: 202 | # - key: "app.kubernetes.io/component" 203 | # operator: In 204 | # values: ["api", "frontend", "service"] 205 | # - key: "app.kubernetes.io/version" 206 | # operator: NotIn 207 | # values: ["v1.0.0", "legacy"] 208 | # - key: "monitoring.enabled" 209 | # operator: Exists # Include only HTTPRoutes with monitoring enabled 210 | # - key: "deprecated" 211 | # operator: DoesNotExist # Exclude deprecated HTTPRoutes 212 | 213 | # Ingress selector for filtering Ingresses by labels (optional) 214 | ingressSelector: 215 | matchLabels: 216 | noexist: "true" 217 | 218 | # Domain filters for filtering by hostname/domain (optional) 219 | # Only resources with hostnames matching these domains will be included 220 | # Supports exact match (example.com) and subdomain match (*.example.com) 221 | domainFilters: 222 | - "rajsingh.info" 223 | # - "mycompany.com" 224 | # - "internal.local" 225 | 226 | # Service Grouping Configuration (optional) 227 | # Controls how discovered services are organized into groups 228 | # serviceGrouping: 229 | # strategy: "namespace" # namespace, label, custom 230 | # # For label strategy - specify which label to use for grouping 231 | # labelKey: "app.kubernetes.io/component" 232 | # # For custom strategy - define custom grouping rules 233 | # customRules: 234 | # - name: "Frontend Services" 235 | # condition: 236 | # "app.kubernetes.io/component": "frontend" 237 | # "tier": "web" 238 | # priority: 10 239 | # - name: "API Services" 240 | # condition: 241 | # "app.kubernetes.io/component": "api" 242 | # priority: 5 243 | # - name: "Database Services" 244 | # condition: 245 | # "app.kubernetes.io/component": "database" 246 | # priority: 1 247 | 248 | # Validation Level (optional) 249 | # Controls strictness of annotation validation 250 | # validationLevel: "warn" # strict, warn, none 251 | 252 | # Health Check Configuration (optional) 253 | # Enables automatic health checking of discovered services 254 | # healthCheck: 255 | # enabled: true 256 | # interval: "30s" # Check interval 257 | # timeout: "10s" # Request timeout 258 | # healthPath: "/health" # Path to append for health checks 259 | # expectedCode: 200 # Expected HTTP status code 260 | # headers: # Custom headers for health requests 261 | # Authorization: "Bearer token" 262 | # X-Health-Check: "true" 263 | 264 | # Advanced Features Configuration (optional) 265 | # Enables advanced dashboard features and optimizations 266 | # advanced: 267 | # enableDependencyAnalysis: true # Auto-detect service dependencies 268 | # enableMetricsAggregation: true # Collect and display service metrics 269 | # enableLayoutOptimization: true # Auto-optimize service layout 270 | # maxServicesPerGroup: 20 # Limit services per group (0 = unlimited) 271 | # maxItemsPerService: 10 # Limit items per service (0 = unlimited) 272 | 273 | # ConfigMap for Homer configuration 274 | # configMap: 275 | # name: "dashboard-sample-homer-config" 276 | # key: "config.yml" 277 | --------------------------------------------------------------------------------