├── .github └── workflows │ ├── pr-checks.yml │ └── release.yml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── Makefile ├── README.md ├── artifacthub-repo.yml ├── cmd └── cyphernetes │ ├── api.go │ ├── autocomplete.go │ ├── autocomplete_test.go │ ├── default_macros.txt │ ├── graph.go │ ├── graph_test.go │ ├── macro.go │ ├── macro_test.go │ ├── main.go │ ├── main_test.go │ ├── manifests │ ├── operator-1.yaml │ ├── operator-10.yaml │ ├── operator-11.yaml │ ├── operator-12.yaml │ ├── operator-13.yaml │ ├── operator-2.yaml │ ├── operator-3.yaml │ ├── operator-4.yaml │ ├── operator-5.yaml │ ├── operator-6.yaml │ ├── operator-7.yaml │ ├── operator-8.yaml │ └── operator-9.yaml │ ├── operator.go │ ├── operator_test.go │ ├── query.go │ ├── query_test.go │ ├── root.go │ ├── root_test.go │ ├── shell.go │ ├── shell_test.go │ ├── web.go │ └── web │ ├── assets │ ├── index-5959fbc4.css │ └── index-aeb8f056.js │ ├── favicon.ico │ └── index.html ├── docs ├── .gitignore ├── LANGUAGE.md ├── README.md ├── docs │ ├── cli.md │ ├── examples.md │ ├── installation.md │ ├── integration.md │ ├── language.md │ ├── operator.md │ └── roadmap.md ├── docusaurus.config.ts ├── package-lock.json ├── package.json ├── sidebars.ts ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── markdown-page.md ├── static │ ├── .nojekyll │ └── img │ │ ├── cli.png │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── operators.png │ │ ├── visualization.png │ │ └── web.png └── tsconfig.json ├── go.mod ├── go.sum ├── operator ├── .dockerignore ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── Makefile ├── PROJECT ├── README.md ├── api │ └── v1 │ │ ├── dynamicoperator_types.go │ │ ├── groupversion_info.go │ │ └── zz_generated.deepcopy.go ├── cmd │ └── main.go ├── config │ ├── crd │ │ ├── bases │ │ │ └── cyphernetes-operator.cyphernet.es_dynamicoperators.yaml │ │ ├── kustomization.yaml │ │ └── kustomizeconfig.yaml │ ├── default │ │ ├── kustomization.yaml │ │ ├── manager_metrics_patch.yaml │ │ └── metrics_service.yaml │ ├── manager │ │ ├── kustomization.yaml │ │ └── manager.yaml │ ├── network-policy │ │ ├── allow-metrics-traffic.yaml │ │ └── kustomization.yaml │ ├── prometheus │ │ ├── kustomization.yaml │ │ └── monitor.yaml │ ├── rbac │ │ ├── dynamicoperator_editor_role.yaml │ │ ├── dynamicoperator_viewer_role.yaml │ │ ├── kustomization.yaml │ │ ├── leader_election_role.yaml │ │ ├── leader_election_role_binding.yaml │ │ ├── metrics_auth_role.yaml │ │ ├── metrics_auth_role_binding.yaml │ │ ├── metrics_reader_role.yaml │ │ ├── role.yaml │ │ ├── role_binding.yaml │ │ └── service_account.yaml │ └── samples │ │ ├── kustomization.yaml │ │ └── operator_v1_dynamicoperator.yaml ├── go.mod ├── go.sum ├── hack │ └── boilerplate.go.txt ├── helm │ └── cyphernetes-operator │ │ ├── Chart.yaml │ │ ├── charts │ │ └── crds │ │ │ └── Chart.yaml │ │ ├── crds │ │ └── dynamicoperators.cyphernetes-operator.cyphernet.es.yaml │ │ ├── templates │ │ ├── _helpers.tpl │ │ ├── deployment.yaml │ │ ├── rbac.yaml │ │ └── service.yaml │ │ └── values.yaml ├── internal │ └── controller │ │ ├── dynamicoperator_controller.go │ │ ├── dynamicoperator_controller_test.go │ │ └── suite_test.go └── test │ ├── e2e │ ├── e2e_suite_test.go │ ├── e2e_test.go │ └── samples │ │ ├── crd-exposeddeployments.yaml │ │ ├── dynamicoperator-exposeddeployments.yaml │ │ ├── dynamicoperator-ingressactivator.yaml │ │ └── exposeddeployments-sample-exposeddeployment.yaml │ └── utils │ └── utils.go ├── pkg ├── core │ ├── aggregate.go │ ├── aggregate_test.go │ ├── cache.go │ ├── e2e │ │ ├── advanced_query_operations_test.go │ │ ├── aggregation_query_operations_test.go │ │ ├── ambiguous_resource_kinds_test.go │ │ ├── annotation_and_label_operations_test.go │ │ ├── basic_query_operations_test.go │ │ ├── complex_query_operations_test.go │ │ ├── complex_relationship_operations_test.go │ │ ├── container_resource_update_operations_test.go │ │ ├── e2e_suite_test.go │ │ ├── input_validation_test.go │ │ ├── kindless_node_operations_test.go │ │ ├── multi_container_update_operations_test.go │ │ ├── multiple_field_update_operations_test.go │ │ ├── relationship_operations_test.go │ │ ├── resource_creation_operations_test.go │ │ └── resource_filtering_operations_test.go │ ├── engine.go │ ├── engine_test.go │ ├── execute.go │ ├── get.go │ ├── graph.go │ ├── jsonpath.go │ ├── kind_resolution.go │ ├── kind_resolution_test.go │ ├── lexer.go │ ├── lexer_test.go │ ├── multicontext.go │ ├── parser.go │ ├── parser_test.go │ ├── query.go │ ├── query_test.go │ ├── relationship.go │ ├── relationship_match.go │ ├── relationship_test.go │ ├── relationship_types.go │ ├── set.go │ ├── temporal.go │ ├── temporal_test.go │ ├── token.go │ ├── types.go │ └── utils.go └── provider │ ├── apiserver │ └── provider.go │ └── interface.go └── web ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public └── favicon.ico ├── src ├── App.css ├── App.tsx ├── __tests__ │ └── App.test.tsx ├── api │ └── queryApi.ts ├── components │ ├── GraphVisualization.css │ ├── GraphVisualization.tsx │ ├── HistoryModal.css │ ├── HistoryModal.tsx │ ├── QueryInput.css │ ├── QueryInput.tsx │ ├── ResultsDisplay.css │ ├── ResultsDisplay.test.tsx │ ├── ResultsDisplay.tsx │ └── __tests__ │ │ ├── GraphVisualization.test.tsx │ │ └── QueryInput.test.tsx ├── main.tsx └── setupTests.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Go 13 | uses: actions/setup-go@v4 14 | with: 15 | go-version-file: go.mod 16 | - name: Setup Kind 17 | uses: engineerd/setup-kind@v0.5.0 18 | with: 19 | version: "v0.24.0" 20 | - name: Install Envtest 21 | run: | 22 | go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest 23 | setup-envtest use 24 | echo "KUBEBUILDER_ASSETS=$(setup-envtest use -p path)" >> $GITHUB_ENV 25 | - name: Setup node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | - name: Setup pnpm 30 | uses: pnpm/action-setup@v4 31 | with: 32 | version: 9 33 | - name: Create a link to envtest in operator/bin 34 | run: mkdir -p ./operator/bin && ln -s $(which setup-envtest) ./operator/bin/setup-envtest 35 | - name: Create the operator manifests 36 | run: make operator-manifests 37 | - name: Run tests 38 | run: make test 39 | - name: Run web tests 40 | run: make web-test 41 | - name: Apply operator test CRDs 42 | run: kubectl apply -f ./operator/test/e2e/samples/crd-exposeddeployments.yaml 43 | - name: Run operator tests 44 | env: 45 | KUBEBUILDER_ASSETS: ${{ env.KUBEBUILDER_ASSETS }} 46 | run: make operator-test 47 | - name: Build operator docker image 48 | run: make -C operator docker-build 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version-file: go.mod 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v2 19 | - name: Login to DockerHub 20 | uses: docker/login-action@v3 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 24 | - name: Extract version 25 | id: get_version 26 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 27 | - name: Build and push operator image 28 | env: 29 | IMG: fatliverfreddy/cyphernetes-operator:${{ steps.get_version.outputs.VERSION }} 30 | run: | 31 | make -C operator docker-build IMG=$IMG 32 | docker push $IMG 33 | docker tag $IMG fatliverfreddy/cyphernetes-operator:latest 34 | docker push fatliverfreddy/cyphernetes-operator:latest 35 | - name: Set up Helm 36 | uses: azure/setup-helm@v3 37 | with: 38 | version: v3.12.0 39 | - name: Package and push Helm chart 40 | run: | 41 | helm package operator/helm/cyphernetes-operator 42 | echo ${{ secrets.PACKAGES_PUSH_TOKEN }} | helm registry login ghcr.io -u avitaltamir --password-stdin 43 | helm push cyphernetes-operator-*.tgz oci://ghcr.io/avitaltamir/cyphernetes 44 | - name: Build CLI for all platforms 45 | env: 46 | VERSION: ${{ steps.get_version.outputs.VERSION }} 47 | run: | 48 | make operator-manifests build-all-platforms 49 | - name: Create Release 50 | id: create_release 51 | uses: actions/create-release@v1 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.PACKAGES_PUSH_TOKEN }} 54 | with: 55 | tag_name: ${{ github.ref }} 56 | release_name: ${{ github.ref }} 57 | draft: true 58 | prerelease: false 59 | - name: Upload Release Asset - Linux AMD64 60 | id: upload-release-asset-linux-amd64 61 | uses: actions/upload-release-asset@v1 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.PACKAGES_PUSH_TOKEN }} 64 | with: 65 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 66 | asset_path: ./dist/cyphernetes-linux-amd64 67 | asset_name: cyphernetes-linux-amd64 68 | asset_content_type: application/octet-stream 69 | - name: Upload Release Asset - Linux ARM64 70 | uses: actions/upload-release-asset@v1 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.PACKAGES_PUSH_TOKEN }} 73 | with: 74 | upload_url: ${{ steps.create_release.outputs.upload_url }} 75 | asset_path: ./dist/cyphernetes-linux-arm64 76 | asset_name: cyphernetes-linux-arm64 77 | asset_content_type: application/octet-stream 78 | 79 | - name: Upload Release Asset - Darwin AMD64 80 | uses: actions/upload-release-asset@v1 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.PACKAGES_PUSH_TOKEN }} 83 | with: 84 | upload_url: ${{ steps.create_release.outputs.upload_url }} 85 | asset_path: ./dist/cyphernetes-darwin-amd64 86 | asset_name: cyphernetes-darwin-amd64 87 | asset_content_type: application/octet-stream 88 | 89 | - name: Upload Release Asset - Darwin ARM64 90 | uses: actions/upload-release-asset@v1 91 | env: 92 | GITHUB_TOKEN: ${{ secrets.PACKAGES_PUSH_TOKEN }} 93 | with: 94 | upload_url: ${{ steps.create_release.outputs.upload_url }} 95 | asset_path: ./dist/cyphernetes-darwin-arm64 96 | asset_name: cyphernetes-darwin-arm64 97 | asset_content_type: application/octet-stream 98 | - name: Upload Release Asset - Windows AMD64 99 | uses: actions/upload-release-asset@v1 100 | env: 101 | GITHUB_TOKEN: ${{ secrets.PACKAGES_PUSH_TOKEN }} 102 | with: 103 | upload_url: ${{ steps.create_release.outputs.upload_url }} 104 | asset_path: ./dist/cyphernetes-windows-amd64 105 | asset_name: cyphernetes-windows-amd64.exe 106 | asset_content_type: application/octet-stream 107 | 108 | - name: Upload Release Asset - Windows ARM64 109 | uses: actions/upload-release-asset@v1 110 | env: 111 | GITHUB_TOKEN: ${{ secrets.PACKAGES_PUSH_TOKEN }} 112 | with: 113 | upload_url: ${{ steps.create_release.outputs.upload_url }} 114 | asset_path: ./dist/cyphernetes-windows-arm64 115 | asset_name: cyphernetes-windows-arm64.exe 116 | asset_content_type: application/octet-stream 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go Modules 18 | vendor/ 19 | 20 | # Cyphernetes binary 21 | dist/ 22 | 23 | # IDE files 24 | .idea/ 25 | 26 | __debug* 27 | y.output 28 | .DS_Store 29 | 30 | .coverage 31 | node_modules 32 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.8.0", 3 | "configurations": [ 4 | 5 | { 6 | "name": "CLI:Shell", 7 | "type": "go", 8 | "request": "launch", 9 | "mode": "debug", 10 | "program": "${workspaceFolder}/cmd/cyphernetes", 11 | "args": ["shell"], 12 | "console": "integratedTerminal" 13 | }, 14 | { 15 | "name": "CLI:Query", 16 | "type": "go", 17 | "request": "launch", 18 | "mode": "debug", 19 | "program": "${workspaceFolder}/cmd/cyphernetes", 20 | "args": ["query", "MATCH (p:pod) RETURN count{p}"], 21 | "console": "integratedTerminal" 22 | }, 23 | { 24 | "name": "CLI:Test", 25 | "type": "go", 26 | "request": "launch", 27 | "mode": "test", 28 | "program": "${workspaceFolder}/cmd/cyphernetes", 29 | "args": ["-test.v"], 30 | "showLog": true 31 | }, 32 | { 33 | "name": "CLI:Completion", 34 | "type": "go", 35 | "request": "launch", 36 | "mode": "debug", 37 | "program": "${workspaceFolder}/cmd/cyphernetes", 38 | "args": ["completion", "bash"], 39 | "console": "integratedTerminal" 40 | }, 41 | { 42 | "name": "Operator:Run", 43 | "type": "go", 44 | "request": "launch", 45 | "mode": "debug", 46 | "program": "${workspaceFolder}/operator/cmd/main.go", 47 | "args": [], 48 | "env": { 49 | "KUBECONFIG": "${env:HOME}/.kube/config" 50 | }, 51 | "cwd": "${workspaceFolder}/operator", 52 | "buildFlags": "-tags=debug", 53 | "console": "integratedTerminal" 54 | }, 55 | { 56 | "name": "Operator:Test:Integration", 57 | "type": "go", 58 | "request": "launch", 59 | "mode": "test", 60 | "program": "${workspaceFolder}/operator/test/e2e", 61 | "args": ["-test.v"], 62 | "env": { 63 | "KUBEBUILDER_ASSETS": "${workspaceFolder}/operator/bin/k8s/${env:ENVTEST_K8S_VERSION}-${env:GOOS:-darwin}-${env:GOARCH:-arm64}", 64 | "ENVTEST_K8S_VERSION": "1.31.0", 65 | "GOOS": "${env:GOOS:-darwin}", 66 | "GOARCH": "${env:GOARCH:-arm64}" 67 | }, 68 | "preLaunchTask": "operator-test-setup", 69 | "showLog": true 70 | }, 71 | { 72 | "name": "Operator:Test:Unit", 73 | "type": "go", 74 | "request": "launch", 75 | "mode": "test", 76 | "program": "${workspaceFolder}/operator/internal/controller", 77 | "args": ["-test.v"], 78 | "env": { 79 | "KUBEBUILDER_ASSETS": "${workspaceFolder}/operator/bin/k8s/${env:ENVTEST_K8S_VERSION}-${env:GOOS:-darwin}-${env:GOARCH:-arm64}", 80 | "ENVTEST_K8S_VERSION": "1.31.0", 81 | "GOOS": "${env:GOOS:-darwin}", 82 | "GOARCH": "${env:GOARCH:-arm64}" 83 | }, 84 | "preLaunchTask": "operator-test-setup", 85 | "showLog": true 86 | } 87 | 88 | ] 89 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "makefile.configureOnOpen": false, 3 | "go.testEnvVars": { 4 | "CGO_ENABLED": "1" 5 | }, 6 | "go.testFlags": ["-v"], 7 | "go.testTimeout": "30s" 8 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "operator-test-setup", 6 | "type": "shell", 7 | "command": "make -C ${workspaceFolder}/operator envtest", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Define the binary name 2 | BINARY_NAME=cyphernetes 3 | TARGET_KERNELS=darwin linux windows 4 | TARGET_ARCHS=amd64 arm64 5 | VERSION ?= dev 6 | 7 | # Define the default make target 8 | all: operator-manifests bt 9 | @echo "🎉 Done!" 10 | 11 | # Build then Test 12 | bt: build test 13 | 14 | # Define how to build the project 15 | build: web-build 16 | @echo "👷 Building ${BINARY_NAME}..." 17 | (cd cmd/cyphernetes && go build -o ${BINARY_NAME} -ldflags "-X main.Version=${VERSION}" > /dev/null) 18 | mkdir -p dist/ 19 | mv cmd/cyphernetes/${BINARY_NAME} dist/cyphernetes 20 | 21 | build-all-platforms: 22 | @echo "👷 Building ${BINARY_NAME}..." 23 | @for kernel in $(TARGET_KERNELS); do \ 24 | for arch in $(TARGET_ARCHS); do \ 25 | echo " - $$kernel/$$arch"; \ 26 | cd cmd/cyphernetes && GOOS=$$kernel GOARCH=$$arch go build -o ${BINARY_NAME} -ldflags "-X main.Version=${VERSION}" > /dev/null; \ 27 | mkdir -p ../../dist/; \ 28 | mv ${BINARY_NAME} ../../dist/cyphernetes-$$kernel-$$arch; \ 29 | cd ../..; \ 30 | done; \ 31 | done 32 | @echo "🎉 Done!" 33 | 34 | test: 35 | @echo "🧪 Running tests..." 36 | go test ./... 37 | 38 | .PHONY: test-e2e 39 | test-e2e: 40 | go install github.com/onsi/ginkgo/v2/ginkgo@latest 41 | ginkgo -v ./pkg/core/e2e 42 | 43 | operator-manifests: 44 | @echo "🤖 Creating operator manifests..." 45 | $(MAKE) -C operator deployment-manifests > /dev/null 46 | 47 | operator-docker-build: 48 | @echo "🐳 Building operator docker image..." 49 | $(MAKE) -C operator docker-build IMG=fatliverfreddy/cyphernetes-operator:latest > /dev/null 50 | 51 | operator-docker-push: 52 | @echo "🐳 Pushing operator docker image..." 53 | $(MAKE) -C operator docker-push IMG=fatliverfreddy/cyphernetes-operator:latest > /dev/null 54 | 55 | # Define how to clean the build 56 | clean: 57 | @echo "💧 Cleaning..." 58 | go clean -cache > /dev/null 59 | rm -rf dist/ 60 | rm -rf coverage.out 61 | rm -rf cmd/cyphernetes/manifests 62 | 63 | coverage: 64 | mkdir -p .coverage 65 | @echo "🧪 Generating coverage report for cmd/cyphernetes..." 66 | go test ./... -coverprofile=.coverage/coverage.out 67 | go tool cover -func=.coverage/coverage.out | sed 's/^/ /g' 68 | go tool cover -html=.coverage/coverage.out -o .coverage/coverage.html 69 | @echo "🌎 Opening coverage report in browser..." 70 | open file://$$(pwd)/.coverage/coverage.html 71 | 72 | operator-test: 73 | @echo "🤖 Testing operator..." 74 | $(MAKE) -C operator test 75 | $(MAKE) -C operator test-e2e 76 | 77 | web-build: 78 | @echo "🌐 Building web interface..." 79 | cd web && pnpm install > /dev/null && pnpm run build > /dev/null 80 | @echo "📦 Copying web artifacts..." 81 | rm -rf cmd/cyphernetes/web 82 | cp -r web/dist cmd/cyphernetes/web 83 | 84 | web-test: 85 | @echo "🧪 Running web tests..." 86 | cd web && pnpm install && pnpm test 87 | 88 | web-run: build 89 | ./dist/cyphernetes web 90 | 91 | # Define a phony target for the clean command to ensure it always runs 92 | .PHONY: clean 93 | .SILENT: build test gen-parser clean coverage operator operator-test operator-manifests operator-docker-build operator-docker-push web-build web-test 94 | 95 | # Add a help command to list available targets 96 | help: 97 | @echo "Available commands:" 98 | @echo " all - Build the project." 99 | @echo " build - Compile the project into a binary." 100 | @echo " test - Run tests." 101 | @echo " clean - Remove binary and clean up." 102 | -------------------------------------------------------------------------------- /artifacthub-repo.yml: -------------------------------------------------------------------------------- 1 | # Artifact Hub repository metadata file 2 | # 3 | # Some settings like the verified publisher flag or the ignored packages won't 4 | # be applied until the next time the repository is processed. Please keep in 5 | # mind that the repository won't be processed if it has not changed since the 6 | # last time it was processed. Depending on the repository kind, this is checked 7 | # in a different way. For Helm http based repositories, we consider it has 8 | # changed if the `index.yaml` file changes. For git based repositories, it does 9 | # when the hash of the last commit in the branch you set up changes. This does 10 | # NOT apply to ownership claim operations, which are processed immediately. 11 | # 12 | repositoryID: 4b742fb6-f9de-4365-a5f6-b37dcef01046 13 | owners: 14 | - name: AvitalTamir 15 | email: avital@cyphernet.es 16 | -------------------------------------------------------------------------------- /cmd/cyphernetes/autocomplete_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestCyphernetesCompleterDo(t *testing.T) { 9 | completer := &CyphernetesCompleter{} 10 | 11 | tests := []struct { 12 | name string 13 | input string 14 | pos int 15 | expected []string 16 | length int 17 | }{ 18 | { 19 | name: "Empty input", 20 | input: "", 21 | pos: 0, 22 | expected: []string{}, 23 | length: 0, 24 | }, 25 | { 26 | name: "Keyword suggestion", 27 | input: "mat", 28 | pos: 3, 29 | expected: []string{"ch"}, 30 | length: 3, 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | suggestions, length := completer.Do([]rune(tt.input), tt.pos) 37 | 38 | // Convert [][]rune to []string for easier comparison 39 | gotSuggestions := make([]string, len(suggestions)) 40 | for i, s := range suggestions { 41 | gotSuggestions[i] = string(s) 42 | } 43 | 44 | if !reflect.DeepEqual(gotSuggestions, tt.expected) { 45 | t.Errorf("Expected suggestions %v, but got %v", tt.expected, gotSuggestions) 46 | } 47 | 48 | if length != tt.length { 49 | t.Errorf("Expected length %d, but got %d", tt.length, length) 50 | } 51 | }) 52 | } 53 | } 54 | 55 | func TestIsMacroContext(t *testing.T) { 56 | tests := []struct { 57 | name string 58 | input string 59 | expected bool 60 | }{ 61 | {"Macro context", ":getpo", true}, 62 | {"Not macro context", "MATCH (n:Pod)", false}, 63 | {"Empty string", "", false}, 64 | {"Colon only", ":", false}, 65 | } 66 | 67 | for _, tt := range tests { 68 | t.Run(tt.name, func(t *testing.T) { 69 | result := isMacroContext(tt.input) 70 | if result != tt.expected { 71 | t.Errorf("Expected %v, but got %v", tt.expected, result) 72 | } 73 | }) 74 | } 75 | } 76 | 77 | func TestGetKindForIdentifier(t *testing.T) { 78 | tests := []struct { 79 | name string 80 | line string 81 | identifier string 82 | expected string 83 | }{ 84 | {"Simple case", "MATCH (p:Pod)", "p", "Pod"}, 85 | {"Multiple identifiers", "MATCH (p:Pod), (d:Deployment)", "d", "Deployment"}, 86 | {"No match", "MATCH (p:Pod)", "x", ""}, 87 | {"Empty line", "", "p", ""}, 88 | } 89 | 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | result := getKindForIdentifier(tt.line, tt.identifier) 93 | if result != tt.expected { 94 | t.Errorf("Expected %q, but got %q", tt.expected, result) 95 | } 96 | }) 97 | } 98 | } 99 | 100 | func TestIsJSONPathContext(t *testing.T) { 101 | tests := []struct { 102 | name string 103 | line string 104 | pos int 105 | lastWord string 106 | expected bool 107 | }{ 108 | // {"JSONPath context", "RETURN p.met", 16, "p.metadata", true}, 109 | {"Not JSONPath context", "MATCH (p:Pod)", 12, "Pod", false}, 110 | {"Empty string", "", 0, "", false}, 111 | // {"SET context", "SET p.metadata.name", 19, "p.metadata.name", true}, 112 | {"WHERE context", "WHERE p.metadata.name", 21, "p.metadata.name", true}, 113 | } 114 | 115 | for _, tt := range tests { 116 | t.Run(tt.name, func(t *testing.T) { 117 | result := isJSONPathContext(tt.line, tt.pos, tt.lastWord) 118 | if result != tt.expected { 119 | t.Errorf("Expected %v, but got %v", tt.expected, result) 120 | } 121 | }) 122 | } 123 | } 124 | 125 | // Mock structures and functions 126 | 127 | type MockMacroManager struct { 128 | Macros map[string]*Macro 129 | } 130 | 131 | func (m *MockMacroManager) AddMacro(macro *Macro, overwrite bool) { 132 | m.Macros[macro.Name] = macro 133 | } 134 | 135 | func (m *MockMacroManager) ExecuteMacro(name string, args []string) ([]string, error) { 136 | return nil, nil 137 | } 138 | 139 | func (m *MockMacroManager) LoadMacrosFromFile(filename string) error { 140 | return nil 141 | } 142 | 143 | func (m *MockMacroManager) LoadMacrosFromString(name, content string) error { 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /cmd/cyphernetes/graph.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | 12 | "slices" 13 | 14 | "github.com/avitaltamir/cyphernetes/pkg/core" 15 | ) 16 | 17 | func stripAnsiEscapes(s string) string { 18 | // This regex matches ANSI escape sequences 19 | r := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) 20 | return r.ReplaceAllString(s, "") 21 | } 22 | 23 | func sanitizeGraph(g core.Graph, result string) (core.Graph, error) { 24 | // create a unique map of nodes 25 | nodeMap := make(map[string]core.Node) 26 | for _, node := range g.Nodes { 27 | nodeId := fmt.Sprintf("%s/%s", node.Kind, node.Name) 28 | nodeMap[nodeId] = node 29 | } 30 | g.Nodes = make([]core.Node, 0, len(nodeMap)) 31 | for _, node := range nodeMap { 32 | g.Nodes = append(g.Nodes, node) 33 | } 34 | 35 | // Strip ANSI escape sequences before unmarshalling 36 | cleanResult := stripAnsiEscapes(result) 37 | 38 | // unmarshal the result into a map[string]interface{} 39 | var resultMap map[string]interface{} 40 | err := json.Unmarshal([]byte(cleanResult), &resultMap) 41 | if err != nil { 42 | return g, fmt.Errorf("error unmarshalling result: %w", err) 43 | } 44 | 45 | // now let's filter out nodes that have no data (in g.Data) 46 | var filteredNodes []core.Node 47 | for _, node := range g.Nodes { 48 | if resultMap[node.Id] != nil { 49 | for _, resultMapNode := range resultMap[node.Id].([]interface{}) { 50 | if resultMapNode.(map[string]interface{})["name"] == node.Name { 51 | filteredNodes = append(filteredNodes, node) 52 | } 53 | } 54 | } 55 | } 56 | g.Nodes = filteredNodes 57 | 58 | filteredNodeIds := []string{} 59 | for _, node := range filteredNodes { 60 | nodeId := fmt.Sprintf("%s/%s", node.Kind, node.Name) 61 | filteredNodeIds = append(filteredNodeIds, nodeId) 62 | } 63 | // now let's filter out edges that point to nodes that don't exist 64 | var filteredEdges []core.Edge 65 | for _, edge := range g.Edges { 66 | if slices.Contains(filteredNodeIds, edge.From) && slices.Contains(filteredNodeIds, edge.To) { 67 | filteredEdges = append(filteredEdges, edge) 68 | } 69 | } 70 | g.Edges = filteredEdges 71 | return g, nil 72 | } 73 | 74 | func mergeGraphs(graph core.Graph, newGraph core.Graph) core.Graph { 75 | // merge the nodes 76 | graph.Nodes = append(graph.Nodes, newGraph.Nodes...) 77 | // merge the edges 78 | graph.Edges = append(graph.Edges, newGraph.Edges...) 79 | return graph 80 | } 81 | 82 | func drawGraph(graph core.Graph) (string, error) { 83 | var graphString strings.Builder 84 | graphString.WriteString("graph {\n") 85 | if graphLayoutLR { 86 | graphString.WriteString("\trankdir = LR;\n\n") 87 | } 88 | 89 | for _, edge := range graph.Edges { 90 | graphString.WriteString(fmt.Sprintf("\"*%s* %s\" -> \"*%s* %s\" [label=\":%s\"];\n", 91 | getKindFromNodeId(edge.From), 92 | getNameFromNodeId(edge.From), 93 | getKindFromNodeId(edge.To), 94 | getNameFromNodeId(edge.To), 95 | edge.Type)) 96 | } 97 | 98 | // iterate over graph.Nodes and find nodes which are not in the graphString 99 | for _, node := range graph.Nodes { 100 | if !strings.Contains(graphString.String(), fmt.Sprintf("\"%s %s\"", node.Kind, node.Name)) { 101 | graphString.WriteString(fmt.Sprintf("\"*%s* %s\";\n", node.Kind, node.Name)) 102 | } 103 | } 104 | 105 | graphString.WriteString("}") 106 | 107 | ascii, err := dotToAscii(graphString.String(), true) 108 | if err != nil { 109 | return "", fmt.Errorf("error converting graph to ASCII: %w", err) 110 | } 111 | 112 | return "\n" + ascii, nil 113 | } 114 | 115 | func getKindFromNodeId(nodeId string) string { 116 | parts := strings.Split(nodeId, "/") 117 | return parts[0] 118 | } 119 | 120 | func getNameFromNodeId(nodeId string) string { 121 | parts := strings.Split(nodeId, "/") 122 | return parts[1] 123 | } 124 | 125 | func dotToAscii(dot string, fancy bool) (string, error) { 126 | fmt.Println("Converting Graph to ASCII...") 127 | defer func() { 128 | fmt.Print("\033[1A\033[K") // Move cursor up one line and clear it 129 | }() 130 | 131 | url := "https://ascii.cyphernet.es/dot-to-ascii.php" 132 | boxart := 0 133 | if fancy { 134 | boxart = 1 135 | } 136 | 137 | req, err := http.NewRequest("GET", url, nil) 138 | if err != nil { 139 | return "", err 140 | } 141 | 142 | q := req.URL.Query() 143 | q.Add("boxart", strconv.Itoa(boxart)) 144 | q.Add("src", dot) 145 | req.URL.RawQuery = q.Encode() 146 | 147 | response, err := http.Get(req.URL.String()) 148 | if err != nil { 149 | return "", err 150 | } 151 | defer response.Body.Close() 152 | body, err := io.ReadAll(response.Body) 153 | if err != nil { 154 | return "", err 155 | } 156 | return string(body), nil 157 | } 158 | -------------------------------------------------------------------------------- /cmd/cyphernetes/graph_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/avitaltamir/cyphernetes/pkg/core" 8 | ) 9 | 10 | func TestSanitizeGraph(t *testing.T) { 11 | testCases := []struct { 12 | name string 13 | input core.Graph 14 | result string 15 | expected core.Graph 16 | }{ 17 | { 18 | name: "Filter out nodes and edges", 19 | input: core.Graph{ 20 | Nodes: []core.Node{ 21 | {Id: "Pod", Kind: "Pod", Name: "pod1"}, 22 | {Id: "Service", Kind: "Service", Name: "svc1"}, 23 | }, 24 | Edges: []core.Edge{ 25 | {From: "Pod", To: "Service", Type: "EXPOSE"}, 26 | }, 27 | }, 28 | result: `{"Pod":[{"name":"pod1"}]}`, 29 | expected: core.Graph{ 30 | Nodes: []core.Node{ 31 | {Id: "Pod", Kind: "Pod", Name: "pod1"}, 32 | }, 33 | Edges: []core.Edge(nil), 34 | }, 35 | }, 36 | } 37 | 38 | for _, tc := range testCases { 39 | t.Run(tc.name, func(t *testing.T) { 40 | result, err := sanitizeGraph(tc.input, tc.result) 41 | if err != nil { 42 | t.Fatalf("Unexpected error: %v", err) 43 | } 44 | if !reflect.DeepEqual(result, tc.expected) { 45 | t.Errorf("\nExpected: %#v\n Got: %#v", tc.expected, result) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | func TestMergeGraphs(t *testing.T) { 52 | graph1 := core.Graph{ 53 | Nodes: []core.Node{{Id: "Pod/pod1", Kind: "Pod", Name: "pod1"}}, 54 | Edges: []core.Edge{{From: "Pod/pod1", To: "Service/svc1", Type: "EXPOSE"}}, 55 | } 56 | graph2 := core.Graph{ 57 | Nodes: []core.Node{{Id: "Service/svc1", Kind: "Service", Name: "svc1"}}, 58 | Edges: []core.Edge{{From: "Service/svc1", To: "Ingress/ing1", Type: "ROUTE"}}, 59 | } 60 | expected := core.Graph{ 61 | Nodes: []core.Node{ 62 | {Id: "Pod/pod1", Kind: "Pod", Name: "pod1"}, 63 | {Id: "Service/svc1", Kind: "Service", Name: "svc1"}, 64 | }, 65 | Edges: []core.Edge{ 66 | {From: "Pod/pod1", To: "Service/svc1", Type: "EXPOSE"}, 67 | {From: "Service/svc1", To: "Ingress/ing1", Type: "ROUTE"}, 68 | }, 69 | } 70 | 71 | result := mergeGraphs(graph1, graph2) 72 | if !reflect.DeepEqual(result, expected) { 73 | t.Errorf("Expected %+v, but got %+v", expected, result) 74 | } 75 | } 76 | 77 | func TestDrawGraph(t *testing.T) { 78 | graph := core.Graph{ 79 | Nodes: []core.Node{ 80 | {Id: "Pod/pod1", Kind: "Pod", Name: "pod1"}, 81 | {Id: "Service/svc1", Kind: "Service", Name: "svc1"}, 82 | }, 83 | Edges: []core.Edge{ 84 | {From: "Pod/pod1", To: "Service/svc1", Type: "EXPOSES"}, 85 | }, 86 | } 87 | 88 | _, err := drawGraph(graph) 89 | if err != nil { 90 | t.Fatalf("Unexpected error: %v", err) 91 | } 92 | // Note: We're not checking the actual ASCII output here as it depends on an external service 93 | } 94 | 95 | func TestGetKindFromNodeId(t *testing.T) { 96 | testCases := []struct { 97 | nodeId string 98 | expected string 99 | }{ 100 | {"Pod/pod1", "Pod"}, 101 | {"Service/svc1", "Service"}, 102 | } 103 | 104 | for _, tc := range testCases { 105 | result := getKindFromNodeId(tc.nodeId) 106 | if result != tc.expected { 107 | t.Errorf("For nodeId %s, expected %s, but got %s", tc.nodeId, tc.expected, result) 108 | } 109 | } 110 | } 111 | 112 | func TestGetNameFromNodeId(t *testing.T) { 113 | testCases := []struct { 114 | nodeId string 115 | expected string 116 | }{ 117 | {"Pod/pod1", "pod1"}, 118 | {"Service/svc1", "svc1"}, 119 | } 120 | 121 | for _, tc := range testCases { 122 | result := getNameFromNodeId(tc.nodeId) 123 | if result != tc.expected { 124 | t.Errorf("For nodeId %s, expected %s, but got %s", tc.nodeId, tc.expected, result) 125 | } 126 | } 127 | } 128 | 129 | // Note: We're not testing dotToAscii function as it depends on an external service 130 | -------------------------------------------------------------------------------- /cmd/cyphernetes/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Avital Tamir 3 | */ 4 | package main 5 | 6 | func main() { 7 | Execute() 8 | } 9 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-1.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | annotations: 5 | controller-gen.kubebuilder.io/version: v0.16.1 6 | name: dynamicoperators.cyphernetes-operator.cyphernet.es 7 | spec: 8 | group: cyphernetes-operator.cyphernet.es 9 | names: 10 | kind: DynamicOperator 11 | listKind: DynamicOperatorList 12 | plural: dynamicoperators 13 | singular: dynamicoperator 14 | scope: Namespaced 15 | versions: 16 | - additionalPrinterColumns: 17 | - jsonPath: .spec.resourceKind 18 | name: ResourceKind 19 | type: string 20 | - jsonPath: .spec.namespace 21 | name: Namespace 22 | type: string 23 | - jsonPath: .status.activeWatchers 24 | name: ActiveWatchers 25 | type: integer 26 | name: v1 27 | schema: 28 | openAPIV3Schema: 29 | description: DynamicOperator is the Schema for the dynamicoperators API 30 | properties: 31 | apiVersion: 32 | description: |- 33 | APIVersion defines the versioned schema of this representation of an object. 34 | Servers should convert recognized schemas to the latest internal value, and 35 | may reject unrecognized values. 36 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 37 | type: string 38 | kind: 39 | description: |- 40 | Kind is a string value representing the REST resource this object represents. 41 | Servers may infer this from the endpoint the client submits requests to. 42 | Cannot be updated. 43 | In CamelCase. 44 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 45 | type: string 46 | metadata: 47 | type: object 48 | spec: 49 | description: DynamicOperatorSpec defines the desired state of DynamicOperator 50 | properties: 51 | namespace: 52 | description: Namespace specifies the namespace to watch. If empty, 53 | it watches all namespaces 54 | type: string 55 | onCreate: 56 | description: OnCreate is the Cyphernetes query to execute when a resource 57 | is created 58 | type: string 59 | onDelete: 60 | description: OnDelete is the Cyphernetes query to execute when a resource 61 | is deleted 62 | type: string 63 | onUpdate: 64 | description: OnUpdate is the Cyphernetes query to execute when a resource 65 | is updated 66 | type: string 67 | resourceKind: 68 | description: ResourceKind specifies the Kubernetes resource kind to 69 | watch 70 | type: string 71 | required: 72 | - resourceKind 73 | type: object 74 | x-kubernetes-validations: 75 | - message: At least one of onCreate, onUpdate, or onDelete must be specified 76 | rule: self.onCreate != "" || self.onUpdate != "" || self.onDelete != 77 | "" 78 | status: 79 | description: DynamicOperatorStatus defines the observed state of DynamicOperator 80 | properties: 81 | activeWatchers: 82 | description: ActiveWatchers is the number of active watchers for this 83 | DynamicOperator 84 | type: integer 85 | lastExecutedQuery: 86 | description: LastExecutedQuery is the last Cyphernetes query that 87 | was executed 88 | type: string 89 | lastExecutionTime: 90 | description: LastExecutionTime is the timestamp of the last query 91 | execution 92 | format: date-time 93 | type: string 94 | required: 95 | - activeWatchers 96 | type: object 97 | type: object 98 | served: true 99 | storage: true 100 | subresources: 101 | status: {} 102 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-10.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/managed-by: kustomize 6 | app.kubernetes.io/name: cyphernetes-operator 7 | name: cyphernetes-operator-manager-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: cyphernetes-operator-manager-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: cyphernetes-operator-controller-manager 15 | namespace: cyphernetes-system 16 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-11.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: cyphernetes-operator-metrics-auth-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: cyphernetes-operator-metrics-auth-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: cyphernetes-operator-controller-manager 12 | namespace: cyphernetes-system 13 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-12.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app.kubernetes.io/managed-by: kustomize 6 | app.kubernetes.io/name: cyphernetes-operator 7 | control-plane: controller-manager 8 | name: cyphernetes-operator-controller-manager-metrics-service 9 | namespace: cyphernetes-system 10 | spec: 11 | ports: 12 | - name: https 13 | port: 8443 14 | protocol: TCP 15 | targetPort: 8443 16 | selector: 17 | control-plane: controller-manager 18 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-13.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app.kubernetes.io/managed-by: kustomize 6 | app.kubernetes.io/name: cyphernetes-operator 7 | control-plane: controller-manager 8 | name: cyphernetes-operator-controller-manager 9 | namespace: cyphernetes-system 10 | spec: 11 | replicas: 1 12 | selector: 13 | matchLabels: 14 | control-plane: controller-manager 15 | template: 16 | metadata: 17 | annotations: 18 | kubectl.kubernetes.io/default-container: manager 19 | labels: 20 | control-plane: controller-manager 21 | spec: 22 | containers: 23 | - args: 24 | - --metrics-bind-address=:8443 25 | - --leader-elect 26 | - --health-probe-bind-address=:8081 27 | command: 28 | - /manager 29 | image: fatliverfreddy/cyphernetes-operator:latest 30 | livenessProbe: 31 | httpGet: 32 | path: /healthz 33 | port: 8081 34 | initialDelaySeconds: 15 35 | periodSeconds: 20 36 | name: manager 37 | readinessProbe: 38 | httpGet: 39 | path: /readyz 40 | port: 8081 41 | initialDelaySeconds: 5 42 | periodSeconds: 10 43 | resources: 44 | limits: 45 | cpu: 500m 46 | memory: 128Mi 47 | requests: 48 | cpu: 10m 49 | memory: 64Mi 50 | securityContext: 51 | allowPrivilegeEscalation: false 52 | capabilities: 53 | drop: 54 | - ALL 55 | securityContext: 56 | runAsNonRoot: true 57 | serviceAccountName: cyphernetes-operator-controller-manager 58 | terminationGracePeriodSeconds: 10 59 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-2.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/managed-by: kustomize 6 | app.kubernetes.io/name: cyphernetes-operator 7 | name: cyphernetes-operator-controller-manager 8 | namespace: cyphernetes-system 9 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-3.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | labels: 5 | app.kubernetes.io/managed-by: kustomize 6 | app.kubernetes.io/name: cyphernetes-operator 7 | name: cyphernetes-operator-leader-election-role 8 | namespace: cyphernetes-system 9 | rules: 10 | - apiGroups: 11 | - "" 12 | resources: 13 | - configmaps 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - patch 21 | - delete 22 | - apiGroups: 23 | - coordination.k8s.io 24 | resources: 25 | - leases 26 | verbs: 27 | - get 28 | - list 29 | - watch 30 | - create 31 | - update 32 | - patch 33 | - delete 34 | - apiGroups: 35 | - "" 36 | resources: 37 | - events 38 | verbs: 39 | - create 40 | - patch 41 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-4.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/managed-by: kustomize 6 | app.kubernetes.io/name: cyphernetes-operator 7 | name: cyphernetes-operator-dynamicoperator-editor-role 8 | rules: 9 | - apiGroups: 10 | - cyphernetes-operator.cyphernet.es 11 | resources: 12 | - dynamicoperators 13 | verbs: 14 | - create 15 | - delete 16 | - get 17 | - list 18 | - patch 19 | - update 20 | - watch 21 | - apiGroups: 22 | - cyphernetes-operator.cyphernet.es 23 | resources: 24 | - dynamicoperators/status 25 | verbs: 26 | - get 27 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-5.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/managed-by: kustomize 6 | app.kubernetes.io/name: cyphernetes-operator 7 | name: cyphernetes-operator-dynamicoperator-viewer-role 8 | rules: 9 | - apiGroups: 10 | - cyphernetes-operator.cyphernet.es 11 | resources: 12 | - dynamicoperators 13 | verbs: 14 | - get 15 | - list 16 | - watch 17 | - apiGroups: 18 | - cyphernetes-operator.cyphernet.es 19 | resources: 20 | - dynamicoperators/status 21 | verbs: 22 | - get 23 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-6.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: cyphernetes-operator-manager-role 5 | rules: 6 | - apiGroups: 7 | - cyphernetes-operator.cyphernet.es 8 | resources: 9 | - dynamicoperators 10 | verbs: 11 | - create 12 | - delete 13 | - get 14 | - list 15 | - patch 16 | - update 17 | - watch 18 | - apiGroups: 19 | - cyphernetes-operator.cyphernet.es 20 | resources: 21 | - dynamicoperators/finalizers 22 | verbs: 23 | - update 24 | - apiGroups: 25 | - cyphernetes-operator.cyphernet.es 26 | resources: 27 | - dynamicoperators/status 28 | verbs: 29 | - get 30 | - patch 31 | - update 32 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-7.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: cyphernetes-operator-metrics-auth-role 5 | rules: 6 | - apiGroups: 7 | - authentication.k8s.io 8 | resources: 9 | - tokenreviews 10 | verbs: 11 | - create 12 | - apiGroups: 13 | - authorization.k8s.io 14 | resources: 15 | - subjectaccessreviews 16 | verbs: 17 | - create 18 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-8.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: cyphernetes-operator-metrics-reader 5 | rules: 6 | - nonResourceURLs: 7 | - /metrics 8 | verbs: 9 | - get 10 | -------------------------------------------------------------------------------- /cmd/cyphernetes/manifests/operator-9.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/managed-by: kustomize 6 | app.kubernetes.io/name: cyphernetes-operator 7 | name: cyphernetes-operator-leader-election-rolebinding 8 | namespace: cyphernetes-system 9 | roleRef: 10 | apiGroup: rbac.authorization.k8s.io 11 | kind: Role 12 | name: cyphernetes-operator-leader-election-role 13 | subjects: 14 | - kind: ServiceAccount 15 | name: cyphernetes-operator-controller-manager 16 | namespace: cyphernetes-system 17 | -------------------------------------------------------------------------------- /cmd/cyphernetes/query.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | "github.com/avitaltamir/cyphernetes/pkg/core" 11 | "github.com/avitaltamir/cyphernetes/pkg/provider/apiserver" 12 | "github.com/spf13/cobra" 13 | "golang.org/x/term" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | var ( 18 | parseQuery = core.ParseQuery 19 | newQueryExecutor = core.NewQueryExecutor 20 | executeMethod = (*core.QueryExecutor).Execute 21 | ) 22 | 23 | var queryCmd = &cobra.Command{ 24 | Use: "query [Cypher-inspired query]", 25 | Short: "Execute a Cypher-inspired query against Kubernetes", 26 | Long: `Use the 'query' subcommand to execute a single Cypher-inspired query against your Kubernetes resources.`, 27 | Args: cobra.ExactArgs(1), 28 | PreRunE: func(cmd *cobra.Command, args []string) error { 29 | // Set CleanOutput to true before validating format 30 | core.CleanOutput = true 31 | 32 | // Validate format flag 33 | f := cmd.Flag("format").Value.String() 34 | if f != "yaml" && f != "json" { 35 | return fmt.Errorf("invalid value for --format: must be 'json' or 'yaml'") 36 | } 37 | // Initialize kubernetes before running the command 38 | return initializeKubernetes() 39 | }, 40 | Run: func(cmd *cobra.Command, args []string) { 41 | provider, err := apiserver.NewAPIServerProviderWithOptions(&apiserver.APIServerProviderConfig{ 42 | QuietMode: true, 43 | }) 44 | if err != nil { 45 | fmt.Fprintln(os.Stderr, "Error creating provider: ", err) 46 | os.Exit(1) 47 | } 48 | executor = core.GetQueryExecutorInstance(provider) 49 | if executor == nil { 50 | os.Exit(1) 51 | } 52 | 53 | if err := core.InitResourceSpecs(executor.Provider()); err != nil { 54 | fmt.Printf("Error initializing resource specs: %v\n", err) 55 | } 56 | runQuery(args, os.Stdout) 57 | }, 58 | } 59 | 60 | func runQuery(args []string, w io.Writer) { 61 | // Create the API server provider 62 | p, err := apiserver.NewAPIServerProviderWithOptions(&apiserver.APIServerProviderConfig{ 63 | DryRun: DryRun, 64 | QuietMode: true, 65 | }) 66 | if err != nil { 67 | fmt.Fprintln(w, "Error creating provider: ", err) 68 | return 69 | } 70 | 71 | // Create query executor with the provider 72 | executor, err := newQueryExecutor(p) 73 | if err != nil { 74 | fmt.Fprintln(w, "Error creating query executor: ", err) 75 | return 76 | } 77 | 78 | // Get the query string 79 | queryStr := args[0] 80 | 81 | // Check if the input query consists only of comments and whitespace 82 | isOnlyComments := true 83 | potentialLines := strings.Split(queryStr, "\n") 84 | for _, potentialLine := range potentialLines { 85 | trimmedLine := strings.TrimSpace(potentialLine) 86 | if trimmedLine != "" && !strings.HasPrefix(trimmedLine, "//") { 87 | isOnlyComments = false 88 | break 89 | } 90 | } 91 | 92 | if isOnlyComments { 93 | // If only comments or empty, do nothing and exit cleanly. 94 | return 95 | } 96 | 97 | // Parse the query to get an AST 98 | ast, err := parseQuery(queryStr) 99 | if err != nil { 100 | fmt.Fprintln(w, "Error parsing query: ", err) 101 | return 102 | } 103 | 104 | // Execute the query against the Kubernetes API. 105 | results, err := executeMethod(executor, ast, "") 106 | if err != nil { 107 | fmt.Fprintln(w, "Error executing query: ", err) 108 | return 109 | } 110 | 111 | // Marshal data based on the output format 112 | var output []byte 113 | if core.OutputFormat == "json" { 114 | output, err = json.MarshalIndent(results.Data, "", " ") 115 | if err == nil && !returnRawJsonOutput && term.IsTerminal(int(os.Stdout.Fd())) { 116 | output = []byte(formatJson(string(output))) 117 | } 118 | } else { 119 | output, err = yaml.Marshal(results.Data) 120 | } 121 | 122 | // Handle marshalling errors 123 | if err != nil { 124 | fmt.Fprintln(w, "Error marshalling results: ", err) 125 | return 126 | } 127 | 128 | if string(output) != "{}" { 129 | fmt.Fprintln(w, string(output)) 130 | } 131 | } 132 | 133 | func init() { 134 | rootCmd.AddCommand(queryCmd) 135 | queryCmd.Flags().StringVar(&core.OutputFormat, "format", "json", "Output format (json or yaml)") 136 | queryCmd.PersistentFlags().BoolVarP(&returnRawJsonOutput, "raw-output", "r", false, "Disable JSON output formatting") 137 | } 138 | -------------------------------------------------------------------------------- /cmd/cyphernetes/query_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/avitaltamir/cyphernetes/pkg/core" 9 | "github.com/avitaltamir/cyphernetes/pkg/provider" 10 | ) 11 | 12 | // MockQueryExecutor implements the QueryExecutor interface 13 | type MockQueryExecutor struct { 14 | ExecuteFunc func(expr *core.Expression, namespace string) (core.QueryResult, error) 15 | } 16 | 17 | func (m *MockQueryExecutor) Execute(expr *core.Expression, namespace string) (core.QueryResult, error) { 18 | return m.ExecuteFunc(expr, namespace) 19 | } 20 | 21 | func (m *MockQueryExecutor) Provider() provider.Provider { 22 | return &MockProvider{} 23 | } 24 | 25 | // Add a mock provider 26 | type MockProvider struct { 27 | provider.Provider 28 | } 29 | 30 | func TestRunQuery(t *testing.T) { 31 | // Store original functions to restore later 32 | originalParseQuery := parseQuery 33 | originalNewQueryExecutor := newQueryExecutor 34 | 35 | tests := []struct { 36 | name string 37 | args []string 38 | setup func() 39 | wantOut string 40 | mockParseQuery func(string) (*core.Expression, error) 41 | mockExecute func(*core.Expression, string) (core.QueryResult, error) 42 | mockNewExecutor func(provider.Provider) (*core.QueryExecutor, error) 43 | }{ 44 | { 45 | name: "New executor error", 46 | args: []string{"match (p:pods)"}, 47 | mockNewExecutor: func(p provider.Provider) (*core.QueryExecutor, error) { 48 | return nil, fmt.Errorf("executor error") 49 | }, 50 | wantOut: "Error creating query executor: executor error\n", 51 | }, 52 | { 53 | name: "Successful query", 54 | args: []string{"MATCH (n:Pod)"}, 55 | mockParseQuery: func(query string) (*core.Expression, error) { 56 | return &core.Expression{}, nil 57 | }, 58 | mockExecute: func(expr *core.Expression, namespace string) (core.QueryResult, error) { 59 | return core.QueryResult{ 60 | Data: map[string]interface{}{ 61 | "test": "data", 62 | }, 63 | }, nil 64 | }, 65 | wantOut: `{ 66 | "test": "data" 67 | } 68 | `, 69 | }, 70 | { 71 | name: "Successful query in YAML format", 72 | args: []string{"MATCH (n:Pod)"}, 73 | mockParseQuery: func(query string) (*core.Expression, error) { 74 | return &core.Expression{}, nil 75 | }, 76 | mockExecute: func(expr *core.Expression, namespace string) (core.QueryResult, error) { 77 | core.OutputFormat = "yaml" 78 | return core.QueryResult{ 79 | Data: map[string]interface{}{ 80 | "test": "data", 81 | }, 82 | }, nil 83 | }, 84 | wantOut: "test: data\n\n", 85 | }, 86 | { 87 | name: "Parse query error", 88 | args: []string{"INVALID QUERY"}, 89 | mockParseQuery: func(query string) (*core.Expression, error) { 90 | return nil, fmt.Errorf("parse error") 91 | }, 92 | wantOut: "Error parsing query: parse error\n", 93 | }, 94 | { 95 | name: "Execute error", 96 | args: []string{"MATCH (n:Pod)"}, 97 | mockParseQuery: func(query string) (*core.Expression, error) { 98 | return &core.Expression{}, nil 99 | }, 100 | mockExecute: func(expr *core.Expression, namespace string) (core.QueryResult, error) { 101 | return core.QueryResult{}, fmt.Errorf("execution error") 102 | }, 103 | wantOut: "Error executing query: execution error\n", 104 | }, 105 | } 106 | 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | if tt.setup != nil { 110 | tt.setup() 111 | } 112 | 113 | // Set up mocks for this test 114 | if tt.mockParseQuery != nil { 115 | parseQuery = tt.mockParseQuery 116 | } 117 | if tt.mockNewExecutor != nil { 118 | newQueryExecutor = tt.mockNewExecutor 119 | } 120 | if tt.mockExecute != nil { 121 | newQueryExecutor = func(p provider.Provider) (*core.QueryExecutor, error) { 122 | return &core.QueryExecutor{}, nil 123 | } 124 | executeMethod = func(_ *core.QueryExecutor, expr *core.Expression, namespace string) (core.QueryResult, error) { 125 | return tt.mockExecute(expr, namespace) 126 | } 127 | } 128 | 129 | out := &bytes.Buffer{} 130 | runQuery(tt.args, out) 131 | 132 | if got := out.String(); got != tt.wantOut { 133 | t.Errorf("unexpected output:\ngot:\n%s\nwant:\n%s", got, tt.wantOut) 134 | } 135 | 136 | // Reset mocks after test 137 | parseQuery = originalParseQuery 138 | newQueryExecutor = originalNewQueryExecutor 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /cmd/cyphernetes/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Avital Tamir 3 | */ 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/avitaltamir/cyphernetes/pkg/core" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var ( 15 | Version = "dev" 16 | DryRun = false 17 | ) 18 | 19 | func getVersionInfo() string { 20 | return fmt.Sprintf( 21 | "Cyphernetes %s\n"+ 22 | "License: Apache 2.0\n"+ 23 | "Source: https://github.com/avitaltamir/cyphernetes\n", 24 | Version, 25 | ) 26 | } 27 | 28 | var LogLevel = "info" 29 | 30 | // rootCmd represents the base command when called without any subcommands 31 | var rootCmd = &cobra.Command{ 32 | Use: "cyphernetes", 33 | Short: "Cyphernetes is a tool for querying Kubernetes resources", 34 | Long: `Cyphernetes allows you to query Kubernetes resources using a Cypher-like query language.`, 35 | Run: func(cmd *cobra.Command, args []string) { 36 | versionFlag, _ := cmd.Flags().GetBool("version") 37 | if versionFlag { 38 | fmt.Print(getVersionInfo()) 39 | os.Exit(0) 40 | } 41 | cmd.Help() 42 | }, 43 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 44 | // Only run version check here 45 | versionFlag, _ := cmd.Flags().GetBool("version") 46 | if versionFlag { 47 | fmt.Print(getVersionInfo()) 48 | os.Exit(0) 49 | } 50 | }, 51 | } 52 | 53 | // Execute adds all child commands to the root command and sets flags appropriately. 54 | // This is called by main.main(). It only needs to happen once to the rootCmd. 55 | func Execute() { 56 | err := rootCmd.Execute() 57 | if err != nil { 58 | os.Exit(1) 59 | } 60 | } 61 | 62 | // TestExecute is a helper function for testing the Execute function 63 | func TestExecute(args []string) error { 64 | // Save the original os.Args 65 | oldArgs := os.Args 66 | defer func() { os.Args = oldArgs }() 67 | 68 | // Set up the new os.Args for testing 69 | os.Args = append([]string{"cmd"}, args...) 70 | 71 | // Create a new root command for testing 72 | cmd := &cobra.Command{Use: "test"} 73 | cmd.AddCommand(rootCmd) 74 | 75 | // Execute the command 76 | return cmd.Execute() 77 | } 78 | 79 | func init() { 80 | // First set the log level 81 | rootCmd.PersistentFlags().StringVarP(&LogLevel, "loglevel", "l", "info", "The log level to use (debug, info, warn, error, fatal, panic)") 82 | 83 | // Move format validation to a separate function 84 | rootCmd.PersistentPreRunE = validateGlobalFlags 85 | 86 | // Add other flags 87 | rootCmd.PersistentFlags().StringVarP(&core.Namespace, "namespace", "n", "default", "The namespace to query against") 88 | rootCmd.PersistentFlags().BoolVarP(&core.AllNamespaces, "all-namespaces", "A", false, "Query all namespaces") 89 | rootCmd.PersistentFlags().BoolVar(&core.NoColor, "no-color", false, "Disable colored output in shell and query results") 90 | rootCmd.PersistentFlags().BoolP("version", "v", false, "Show version and exit") 91 | rootCmd.PersistentFlags().BoolVar(&DryRun, "dry-run", false, "Enable dry-run mode for all operations") 92 | 93 | // Add version command 94 | rootCmd.AddCommand(&cobra.Command{ 95 | Use: "version", 96 | Short: "Show version information", 97 | Run: func(cmd *cobra.Command, args []string) { 98 | fmt.Print(getVersionInfo()) 99 | }, 100 | }) 101 | 102 | // Add completion command 103 | rootCmd.AddCommand(&cobra.Command{ 104 | Use: "completion [bash|zsh|fish|powershell]", 105 | Short: "Generate completion script", 106 | Long: `To load completions: 107 | 108 | Bash: 109 | $ source <(cyphernetes completion bash) 110 | 111 | Zsh: 112 | $ source <(cyphernetes completion zsh) 113 | 114 | fish: 115 | $ cyphernetes completion fish | source 116 | 117 | PowerShell: 118 | PS> cyphernetes completion powershell | Out-String | Invoke-Expression 119 | `, 120 | ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, 121 | Args: cobra.ExactValidArgs(1), 122 | Run: func(cmd *cobra.Command, args []string) { 123 | switch args[0] { 124 | case "bash": 125 | cmd.Root().GenBashCompletion(os.Stdout) 126 | case "zsh": 127 | cmd.Root().GenZshCompletion(os.Stdout) 128 | case "fish": 129 | cmd.Root().GenFishCompletion(os.Stdout, true) 130 | case "powershell": 131 | cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) 132 | } 133 | }, 134 | }) 135 | 136 | // Add the web command 137 | rootCmd.AddCommand(WebCmd) 138 | } 139 | 140 | // validateGlobalFlags validates global flags without initializing k8s 141 | func validateGlobalFlags(cmd *cobra.Command, args []string) error { 142 | // Set LogLevel after flag parsing 143 | LogLevel = cmd.Flag("loglevel").Value.String() 144 | core.LogLevel = LogLevel 145 | 146 | return nil 147 | } 148 | 149 | // initializeKubernetes initializes kubernetes context - call this in commands that need k8s 150 | func initializeKubernetes() error { 151 | // Add any kubernetes initialization code here 152 | return nil 153 | } 154 | 155 | func logDebug(v ...interface{}) { 156 | if core.LogLevel == "debug" { 157 | fmt.Println(append([]interface{}{"[DEBUG] "}, v...)...) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /cmd/cyphernetes/root_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestExecuteNoArgs(t *testing.T) { 12 | // Capture stdout 13 | oldStdout := os.Stdout 14 | r, w, _ := os.Pipe() 15 | os.Stdout = w 16 | 17 | // Test the Execute function 18 | Execute() 19 | 20 | // Restore stdout 21 | w.Close() 22 | os.Stdout = oldStdout 23 | 24 | // Read the captured output 25 | var buf bytes.Buffer 26 | io.Copy(&buf, r) 27 | output := buf.String() 28 | 29 | // Check if the output contains expected content 30 | expectedContent := "Use \"cyphernetes [command] --help\" for more information about a command." 31 | if !strings.Contains(output, expectedContent) { 32 | t.Errorf("Execute() output does not contain expected content.\nExpected: %s\nGot: %s", expectedContent, output) 33 | } 34 | } 35 | 36 | func TestExecuteWithArgs(t *testing.T) { 37 | testCases := []struct { 38 | name string 39 | args []string 40 | expectedError bool 41 | }{ 42 | {"No args", []string{}, false}, 43 | {"Help flag", []string{"--help"}, false}, 44 | {"Invalid flag", []string{"--invalid-flag"}, true}, 45 | } 46 | 47 | for _, tc := range testCases { 48 | t.Run(tc.name, func(t *testing.T) { 49 | err := TestExecute(tc.args) 50 | if tc.expectedError && err == nil { 51 | t.Errorf("Expected an error, but got none") 52 | } 53 | if !tc.expectedError && err != nil { 54 | t.Errorf("Unexpected error: %v", err) 55 | } 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cmd/cyphernetes/web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "io/fs" 8 | "net" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | "strconv" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/avitaltamir/cyphernetes/pkg/core" 17 | "github.com/avitaltamir/cyphernetes/pkg/provider/apiserver" 18 | "github.com/gin-gonic/gin" 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | //go:embed web/* 23 | var webFS embed.FS 24 | 25 | var WebCmd = &cobra.Command{ 26 | Use: "web", 27 | Short: "Start the Cyphernetes web interface", 28 | Long: `Start the Cyphernetes web interface. 29 | 30 | If the specified port is in use, it will attempt to find the next available port.`, 31 | Run: runWeb, 32 | } 33 | 34 | var ( 35 | webPort string 36 | maxPortTry = 10 // Maximum number of ports to try 37 | ) 38 | 39 | func init() { 40 | WebCmd.Flags().StringVarP(&webPort, "port", "p", "8080", "Port to run the web interface on") 41 | } 42 | 43 | // checkPort attempts to listen on a port to check if it's available 44 | func checkPort(port string) error { 45 | // Try to bind to all interfaces, just like the actual server will 46 | listener, err := net.Listen("tcp", ":"+port) 47 | if err != nil { 48 | return err 49 | } 50 | listener.Close() 51 | return nil 52 | } 53 | 54 | // findAvailablePort finds the next available port starting from the given port 55 | func findAvailablePort(startPort string) (string, error) { 56 | port, err := strconv.Atoi(startPort) 57 | if err != nil { 58 | return "", fmt.Errorf("invalid port number: %s", startPort) 59 | } 60 | 61 | for i := 0; i < maxPortTry; i++ { 62 | currentPort := strconv.Itoa(port + i) 63 | err := checkPort(currentPort) 64 | if err == nil { 65 | return currentPort, nil 66 | } 67 | } 68 | return "", fmt.Errorf("no available ports found in range %d-%d", port, port+maxPortTry-1) 69 | } 70 | 71 | func runWeb(cmd *cobra.Command, args []string) { 72 | // Find an available port 73 | port, err := findAvailablePort(webPort) 74 | if err != nil { 75 | fmt.Printf("Error finding available port: %v\n", err) 76 | os.Exit(1) 77 | } 78 | 79 | // If we're using a different port than requested, inform the user 80 | if port != webPort { 81 | fmt.Printf("Port %s is in use, using port %s instead\n", webPort, port) 82 | } 83 | 84 | url := fmt.Sprintf("http://localhost:%s", port) 85 | 86 | // Create the API server provider 87 | providerConfig := &apiserver.APIServerProviderConfig{ 88 | DryRun: DryRun, 89 | } 90 | provider, err := apiserver.NewAPIServerProviderWithOptions(providerConfig) 91 | if err != nil { 92 | fmt.Printf("Error creating provider: %v\n", err) 93 | os.Exit(1) 94 | } 95 | 96 | // Initialize the executor instance with the provider 97 | executor = core.GetQueryExecutorInstance(provider) 98 | if executor == nil { 99 | fmt.Printf("Error initializing query executor\n") 100 | return 101 | } 102 | 103 | // Set Gin to release mode to disable logging 104 | gin.SetMode(gin.ReleaseMode) 105 | router := gin.New() 106 | 107 | // Setup API routes first 108 | setupAPIRoutes(router) 109 | 110 | // Serve embedded files from the 'web' directory 111 | webContent, err := fs.Sub(webFS, "web") 112 | if err != nil { 113 | fmt.Printf("Error accessing embedded web files: %v\n", err) 114 | return 115 | } 116 | router.NoRoute(gin.WrapH(http.FileServer(http.FS(webContent)))) 117 | 118 | // Create a new http.Server 119 | srv := &http.Server{ 120 | Addr: ":" + port, // Bind to all interfaces for better compatibility 121 | Handler: router, 122 | } 123 | 124 | // Create a channel to signal when the server has finished shutting down 125 | serverClosed := make(chan struct{}) 126 | 127 | // Start the server in a goroutine 128 | go func() { 129 | fmt.Printf("\nStarting Cyphernetes web interface at %s\n", url) 130 | fmt.Println("Press Ctrl+C to stop") 131 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 132 | fmt.Printf("Error starting server: %v\n", err) 133 | } 134 | close(serverClosed) 135 | }() 136 | 137 | // Wait for interrupt signal to gracefully shutdown the server 138 | quit := make(chan os.Signal, 1) 139 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 140 | <-quit 141 | 142 | fmt.Println("Shutting down server...") 143 | 144 | // Create a deadline to wait for. 145 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 146 | defer cancel() 147 | 148 | // Doesn't block if no connections, but will otherwise wait 149 | // until the timeout deadline. 150 | if err := srv.Shutdown(ctx); err != nil { 151 | fmt.Printf("Server forced to shutdown: %v\n", err) 152 | } 153 | 154 | // Wait for the server to finish shutting down 155 | <-serverClosed 156 | 157 | fmt.Println("Server exiting") 158 | } 159 | -------------------------------------------------------------------------------- /cmd/cyphernetes/web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/cmd/cyphernetes/web/favicon.ico -------------------------------------------------------------------------------- /cmd/cyphernetes/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cyphernetes Web Interface 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/LANGUAGE.md: -------------------------------------------------------------------------------- 1 | # New Documentation Website 2 | 3 | The Cyphernetes docs have moved! Please visit the new docs site at [cyphernet.es/docs/language/](https://cyphernet.es/docs/language/). 4 | 5 | The old docs page is no longer maintained. -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /docs/docs/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | # Examples 6 | 7 | This guide provides practical examples of using Cyphernetes in various scenarios. Each example includes explanations and variations to help you understand how to adapt them to your needs. 8 | 9 | ## Basic Patterns 10 | 11 | ### Node Patterns 12 | 13 | Basic node patterns with and without variables: 14 | 15 | ```graphql 16 | // Basic node pattern 17 | MATCH (p:Pod) 18 | RETURN p; 19 | 20 | // Node with properties 21 | MATCH (d:Deployment {metadata: {name: "nginx"}}) 22 | RETURN d; 23 | 24 | // Anonymous nodes 25 | MATCH (p:Pod)->(:Service)->(e:Endpoints) 26 | RETURN p, e; 27 | 28 | // Kindless nodes (without specified resource type) 29 | MATCH (d:Deployment {metadata: {name: "nginx"}})->(x) 30 | RETURN p, x.kind; 31 | ``` 32 | 33 | ### Resource Relationships 34 | 35 | Different ways to express relationships between resources: 36 | 37 | ```graphql 38 | // Right direction relationship 39 | MATCH (p:Pod)->(s:Service) 40 | RETURN p.metadata.name, s.metadata.name; 41 | 42 | // Relationship direction doesn't matter 43 | MATCH (p:Pod)<-(s:Service) 44 | RETURN p.metadata.name, s.metadata.name; 45 | 46 | // Chained relationships 47 | MATCH (d:Deployment)->(rs:ReplicaSet)->(p:Pod) 48 | RETURN d.metadata.name, rs.metadata.name, p.metadata.name; 49 | 50 | // Anonymous, kindless relationships 51 | MATCH (d:Deployment)->()->(p:Pod) 52 | RETURN d.metadata.name, p.metadata.name; 53 | 54 | // Find all resources related to a deployment 55 | MATCH (d:Deployment {app: "my-app"})->(x) 56 | RETURN d, x.kind, x.metadata.name; 57 | 58 | // Complex relationship chains 59 | MATCH (d:Deployment {app: "my-app"})->(rs:ReplicaSet)->(p:Pod)->(s:Service)->(i:Ingress) 60 | RETURN d.metadata.name, rs.metadata.name, p.metadata.name, s.metadata.name, i.metadata.name; 61 | ``` 62 | 63 | ## Resource Management 64 | 65 | ### Pod Management 66 | 67 | Find and manage pods in your cluster: 68 | 69 | ```graphql 70 | // Delete all pods that aren't running 71 | MATCH (p:Pod) 72 | WHERE p.status.phase != "Running" 73 | DELETE p; 74 | 75 | // Find pods with no node assigned 76 | MATCH (p:Pod) 77 | WHERE p.spec.nodeName = NULL 78 | RETURN p.metadata.name; 79 | 80 | // Find pods with specific labels (with escaped dots) 81 | MATCH (p:Pod) 82 | WHERE p.metadata.labels.kubernetes\.io/name = "nginx" 83 | RETURN p.metadata.name; 84 | 85 | // Find pods with high restart counts 86 | MATCH (p:Pod) 87 | WHERE p.status.containerStatuses[0].restartCount > 5 88 | RETURN p.metadata.name, p.status.containerStatuses[0].restartCount; 89 | ``` 90 | 91 | ### Deployment Management 92 | 93 | Work with deployments and their related resources: 94 | 95 | ```graphql 96 | // Scale deployments in a namespace 97 | MATCH (d:Deployment {namespace: "production"}) 98 | SET d.spec.replicas = 3; 99 | 100 | // Find deployments with mismatched replicas 101 | MATCH (d:Deployment) 102 | WHERE d.status.availableReplicas < d.spec.replicas 103 | RETURN d.metadata.name, d.spec.replicas, d.status.availableReplicas; 104 | 105 | // List pods for a specific deployment 106 | MATCH (d:Deployment {app: "my-app"})->(:ReplicaSet)->(p:Pod) 107 | RETURN p.metadata.name, p.status.phase; 108 | 109 | // Update container images 110 | MATCH (d:Deployment {app: "my-app"}) 111 | SET d.spec.template.spec.containers[0].image = "nginx:latest" 112 | RETURN d.metadata.name; 113 | ``` 114 | 115 | ### Cluster Maintenance 116 | 117 | ```graphql 118 | // Find configmaps not used by any pod 119 | MATCH (cm:ConfigMap) 120 | WHERE NOT (cm)->(:Pod) 121 | RETURN cm.metadata.name; 122 | 123 | // Find orphaned PersistentVolumeClaims 124 | MATCH (pvc:PersistentVolumeClaim) 125 | WHERE NOT (pvc)->(:PersistentVolume) 126 | AND pvc.status.phase != "Bound" 127 | RETURN pvc.metadata.name; 128 | 129 | // Delete pods that are not running and were created more than 7 days ago 130 | MATCH (p:Pod) 131 | WHERE p.status.phase != "Running" 132 | AND p.metadata.creationTimestamp < datetime() - duration("P7D") 133 | DELETE p; 134 | ``` 135 | 136 | ### Service and Endpoint Analysis 137 | 138 | ```graphql 139 | // Find services without endpoints 140 | MATCH (s:Service) 141 | WHERE NOT (s)->(:core.Endpoints) 142 | RETURN s.metadata.name; 143 | 144 | // Find services with specific labels 145 | MATCH (s:Service {app: "frontend"}) 146 | RETURN s.metadata.name; 147 | 148 | // Find services in multiple contexts 149 | IN production, staging 150 | MATCH (s:Service {name: "api"}) 151 | RETURN s.metadata.name, s.spec.clusterIP; 152 | ``` -------------------------------------------------------------------------------- /docs/docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Installation 6 | 7 | 8 | 9 | Cyphernetes can be installed in multiple ways depending on your operating system and preferences. 10 | 11 | import Tabs from '@theme/Tabs'; 12 | import TabItem from '@theme/TabItem'; 13 | 14 | 15 | 16 | 17 | The easiest way to install Cyphernetes on macOS or Linux is via Homebrew: 18 | 19 | ```bash 20 | brew install cyphernetes 21 | ``` 22 | 23 | 24 | 25 | ```bash 26 | go install github.com/avitaltamir/cyphernetes/cmd/cyphernetes@latest 27 | ``` 28 | 29 | 30 | 31 | You can download the pre-compiled binary for your operating system: 32 | 33 |

Linux

34 | ```bash 35 | # For AMD64 36 | curl -LO https://github.com/avitaltamir/cyphernetes/releases/latest/download/cyphernetes-linux-amd64 37 | chmod +x cyphernetes-linux-amd64 38 | sudo mv cyphernetes-linux-amd64 /usr/local/bin/cyphernetes 39 | 40 | # For ARM64 41 | curl -LO https://github.com/avitaltamir/cyphernetes/releases/latest/download/cyphernetes-linux-arm64 42 | chmod +x cyphernetes-linux-arm64 43 | sudo mv cyphernetes-linux-arm64 /usr/local/bin/cyphernetes 44 | ``` 45 | 46 |

macOS

47 | ```bash 48 | # For AMD64 49 | curl -LO https://github.com/avitaltamir/cyphernetes/releases/latest/download/cyphernetes-darwin-amd64 50 | chmod +x cyphernetes-darwin-amd64 51 | sudo mv cyphernetes-darwin-amd64 /usr/local/bin/cyphernetes 52 | 53 | # For ARM64 (Apple Silicon) 54 | curl -LO https://github.com/avitaltamir/cyphernetes/releases/latest/download/cyphernetes-darwin-arm64 55 | chmod +x cyphernetes-darwin-arm64 56 | sudo mv cyphernetes-darwin-arm64 /usr/local/bin/cyphernetes 57 | ``` 58 | 59 |

Windows

60 | Download the latest Windows binary from our [releases page](https://github.com/avitaltamir/cyphernetes/releases/latest). 61 | 62 |
63 | 64 | 65 | To build Cyphernetes from source, you'll need: 66 | 67 | - Go (Latest) 68 | - Make 69 | - NodeJS (Latest) 70 | - pnpm (9+) 71 | 72 | ```bash 73 | # Clone the repository 74 | git clone https://github.com/avitaltamir/cyphernetes.git 75 | 76 | # Navigate to the project directory 77 | cd cyphernetes 78 | 79 | # Build the project 80 | make 81 | 82 | # The binary will be available in the dist/ directory 83 | sudo mv dist/cyphernetes /usr/local/bin/cyphernetes 84 | ``` 85 | 86 | 87 |
88 | 89 | ## Verifying the Installation 90 | 91 | After installation, verify that Cyphernetes is working correctly: 92 | 93 | ```bash 94 | cyphernetes --version 95 | ``` 96 | 97 | ## Running Cyphernetes 98 | 99 | There are multiple ways to run Cyphernetes: 100 | 101 | 1. **Web Interface** 102 | ```bash 103 | cyphernetes web 104 | ``` 105 | Then visit `http://localhost:8080` in your browser. 106 | 107 | 2. **Interactive Shell** 108 | ```bash 109 | cyphernetes shell 110 | ``` 111 | 112 | 3. **Single Query** 113 | ```bash 114 | cyphernetes query "MATCH (p:Pod) RETURN p" 115 | ``` -------------------------------------------------------------------------------- /docs/docs/operator.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Operator 6 | 7 | Cyphernetes is available as a Kubernetes Operator that can be used to define child operators on-the-fly. 8 | 9 | ## Usage 10 | 11 | The cyphernetes-operator watches for CustomResourceDefinitions (CRDs) of type `DynamicOperator` and sets up watches on the specified Kubernetes resources. 12 | When a change is detected, the operator executes the Cypher queries and updates the resources accordingly. 13 | 14 | Here is a simple example of a DynamicOperator that sets the ingress class name to "inactive" when the deployment has 0 replicas and to "active" when the deployment has more than 0 replicas: 15 | ```yaml 16 | apiVersion: cyphernetes-operator.cyphernet.es/v1 17 | kind: DynamicOperator 18 | metadata: 19 | name: ingress-activator-operator 20 | spec: 21 | resourceKind: deployments 22 | namespace: default 23 | onUpdate: | 24 | MATCH (d:Deployment {name: "{{$.metadata.name}}"})->(s:Service)->(i:Ingress) 25 | WHERE d.spec.replicas = 0 26 | SET i.spec.ingressClassName = "inactive"; 27 | MATCH (d:Deployment {name: "{{$.metadata.name}}"})->(s:Service)->(i:Ingress) 28 | WHERE d.spec.replicas > 0 29 | SET i.spec.ingressClassName = "active"; 30 | ``` 31 | 32 | In addition to the `onUpdate` field, the operator also supports the `onCreate` and `onDelete` fields. 33 | 34 | ## Installation 35 | 36 | The operator can be installed either using helm, or using the Cyphernetes CLI. 37 | 38 | ### Helm 39 | 40 | To install the operator using helm, run the following command: 41 | ```bash 42 | helm pull oci://ghcr.io/avitaltamir/cyphernetes/cyphernetes-operator 43 | tar -xvf cyphernetes-operator-*.tgz 44 | cd cyphernetes-operator 45 | helm upgrade --install cyphernetes-operator . --namespace cyphernetes-operator --create-namespace 46 | ``` 47 | 48 | Make sure to edit the values.yaml file and configure the operator's RBAC rules. 49 | By default, the operator will have no permissions and will not be able to watch any resources. 50 | 51 | ### Cyphernetes CLI 52 | 53 | Alternatively, you can install the operator using the Cyphernetes CLI - this is meant for development and testing purposes: 54 | ```bash 55 | cyphernetes operator deploy 56 | ``` 57 | (or to remove): 58 | ```bash 59 | cyphernetes operator remove 60 | ``` 61 | 62 | ## Using the operator 63 | 64 | To start watching resources, you need to provision your first `DynamicOperator` resource. 65 | ```yaml 66 | apiVersion: cyphernetes-operator.cyphernet.es/v1 67 | kind: DynamicOperator 68 | metadata: 69 | name: ingress-activator-operator 70 | namespace: default 71 | spec: 72 | resourceKind: deployments 73 | namespace: default 74 | onUpdate: | 75 | MATCH (d:Deployment {name: "{{$.metadata.name}}"})->(s:Service)->(i:Ingress) 76 | WHERE d.spec.replicas = 0 77 | SET i.spec.ingressClassName = "inactive"; 78 | MATCH (d:Deployment {name: "{{$.metadata.name}}"})->(s:Service)->(i:Ingress) 79 | WHERE d.spec.replicas > 0 80 | SET i.spec.ingressClassName = "active"; 81 | ``` 82 | 83 | The operator will now watch the `deployments` resource in the `default` namespace and update the ingress class name accordingly. 84 | In addition to the `onUpdate` field, the operator also supports the `onCreate` and `onDelete` fields. 85 | 86 | You can easily template `DynamicOperator` resources using the cyphernetes cli: 87 | ```bash 88 | cyphernetes operator create my-operator --on-create "MATCH (n) RETURN n" | kubectl apply -f - 89 | ``` 90 | 91 | 92 | -------------------------------------------------------------------------------- /docs/docs/roadmap.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 8 3 | --- 4 | 5 | # Roadmap 6 | 7 | For the most up-to-date project roadmap and development status, please visit our [GitHub Issues](https://github.com/avitaltamir/cyphernetes/issues) page. 8 | 9 | We actively track feature requests, bug reports, and development progress through GitHub Issues. Feel free to: 10 | - Submit feature requests 11 | - Report bugs 12 | - Contribute to discussions 13 | - Track development progress 14 | 15 | Visit our [GitHub repository](https://github.com/avitaltamir/cyphernetes) to get involved! -------------------------------------------------------------------------------- /docs/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import { themes as prismThemes } from "prism-react-renderer"; 2 | import type { Config } from "@docusaurus/types"; 3 | import type * as Preset from "@docusaurus/preset-classic"; 4 | 5 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 6 | 7 | const config: Config = { 8 | title: "Cyphernetes", 9 | tagline: "A Kubernetes Query Language", 10 | favicon: "img/favicon.ico", 11 | 12 | // Set the production url of your site here 13 | url: "https://docs.cyphernet.es", 14 | // Set the // pathname under which your site is served 15 | // For GitHub pages deployment, it is often '//' 16 | baseUrl: "/", 17 | 18 | // GitHub pages deployment config. 19 | // If you aren't using GitHub pages, you don't need these. 20 | organizationName: "avitaltamir", // Usually your GitHub org/user name. 21 | projectName: "cyphernetes", // Usually your repo name. 22 | 23 | onBrokenLinks: "throw", 24 | onBrokenMarkdownLinks: "warn", 25 | 26 | // Even if you don't use internationalization, you can use this field to set 27 | // useful metadata like html lang. For example, if your site is Chinese, you 28 | // may want to replace "en" with "zh-Hans". 29 | i18n: { 30 | defaultLocale: "en", 31 | locales: ["en"], 32 | }, 33 | 34 | presets: [ 35 | [ 36 | "classic", 37 | { 38 | docs: { 39 | sidebarPath: "./sidebars.ts", 40 | editUrl: "https://github.com/avitaltamir/cyphernetes/tree/main/docs/", 41 | breadcrumbs: false, 42 | }, 43 | blog: false, 44 | theme: { 45 | customCss: "./src/css/custom.css", 46 | }, 47 | } satisfies Preset.Options, 48 | ], 49 | ], 50 | 51 | themeConfig: { 52 | announcementBar: { 53 | id: "kcd_new_york", 54 | content: 55 | '🎤 We\'re speaking at KCD New York! Join us for our lightning talk', 56 | backgroundColor: "#1E293B", 57 | textColor: "#FFFFFF", 58 | isCloseable: true, 59 | }, 60 | docs: { 61 | sidebar: { 62 | hideable: true, 63 | }, 64 | }, 65 | colorMode: { 66 | defaultMode: "dark", 67 | respectPrefersColorScheme: true, 68 | }, 69 | navbar: { 70 | title: "Cyphernetes", 71 | logo: { 72 | alt: "Cyphernetes Logo", 73 | src: "img/logo.png", 74 | }, 75 | items: [ 76 | { 77 | type: "docSidebar", 78 | sidebarId: "tutorialSidebar", 79 | position: "left", 80 | label: "Documentation", 81 | }, 82 | { 83 | href: "https://github.com/avitaltamir/cyphernetes", 84 | position: "right", 85 | label: "GitHub", 86 | className: "header-github-link", 87 | "aria-label": "GitHub repository", 88 | }, 89 | ], 90 | }, 91 | footer: { 92 | style: "dark", 93 | links: [ 94 | { 95 | title: "Documentation", 96 | items: [ 97 | { 98 | label: "Getting Started", 99 | to: "/docs/installation", 100 | }, 101 | { 102 | label: "Examples", 103 | to: "/docs/examples", 104 | }, 105 | ], 106 | }, 107 | { 108 | title: "Community", 109 | items: [ 110 | { 111 | label: "GitHub", 112 | href: "https://github.com/avitaltamir/cyphernetes", 113 | }, 114 | { 115 | label: "Discussions", 116 | href: "https://github.com/avitaltamir/cyphernetes/discussions", 117 | }, 118 | ], 119 | }, 120 | { 121 | title: "Social", 122 | items: [ 123 | { 124 | label: "LinkedIn", 125 | href: "https://www.linkedin.com/company/cyphernetes", 126 | }, 127 | ], 128 | }, 129 | { 130 | title: "Contact", 131 | items: [ 132 | { 133 | label: "team@cyphernet.es", 134 | href: "mailto:team@cyphernet.es", 135 | }, 136 | ], 137 | }, 138 | ], 139 | copyright: `Copyright © ${new Date().getFullYear()} Cyphernetes`, 140 | }, 141 | prism: { 142 | theme: prismThemes.vsDark, 143 | darkTheme: prismThemes.vsDark, 144 | defaultLanguage: "bash", 145 | }, 146 | } satisfies Preset.ThemeConfig, 147 | }; 148 | 149 | export default config; 150 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.7.0", 19 | "@docusaurus/preset-classic": "3.7.0", 20 | "@mdx-js/react": "^3.0.0", 21 | "clsx": "^2.0.0", 22 | "prism-react-renderer": "^2.3.0", 23 | "react": "^19.0.0", 24 | "react-dom": "^19.0.0" 25 | }, 26 | "devDependencies": { 27 | "@docusaurus/module-type-aliases": "3.7.0", 28 | "@docusaurus/tsconfig": "3.7.0", 29 | "@docusaurus/types": "3.7.0", 30 | "typescript": "~5.6.2" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.5%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 3 chrome version", 40 | "last 3 firefox version", 41 | "last 5 safari version" 42 | ] 43 | }, 44 | "engines": { 45 | "node": ">=18.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; 2 | 3 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 4 | 5 | /** 6 | * Creating a sidebar enables you to: 7 | - create an ordered group of docs 8 | - render a sidebar for each doc of that group 9 | - provide next/previous navigation 10 | 11 | The sidebars can be generated from the filesystem, or explicitly defined here. 12 | 13 | Create as many sidebars as you want. 14 | */ 15 | const sidebars: SidebarsConfig = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | export default sidebars; 34 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/docs/static/img/cli.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/operators.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/docs/static/img/operators.png -------------------------------------------------------------------------------- /docs/static/img/visualization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/docs/static/img/visualization.png -------------------------------------------------------------------------------- /docs/static/img/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/docs/static/img/web.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | }, 7 | "exclude": [".docusaurus", "build"] 8 | } 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/avitaltamir/cyphernetes 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/AvitalTamir/jsonpath v0.0.0 9 | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 10 | github.com/gin-gonic/gin v1.10.0 11 | github.com/gobwas/glob v0.2.3 12 | github.com/google/gnostic v0.7.0 13 | github.com/onsi/ginkgo/v2 v2.22.2 14 | github.com/onsi/gomega v1.36.2 15 | github.com/spf13/cobra v1.8.1 16 | github.com/wader/readline v0.0.0-20230307172220-bcb7158e7448 17 | k8s.io/apiextensions-apiserver v0.31.0 18 | k8s.io/apimachinery v0.32.0 19 | k8s.io/client-go v0.31.0 20 | sigs.k8s.io/controller-runtime v0.19.4 21 | 22 | ) 23 | 24 | require ( 25 | github.com/bytedance/sonic v1.11.6 // indirect 26 | github.com/bytedance/sonic/loader v0.1.1 // indirect 27 | github.com/cloudwego/base64x v0.1.4 // indirect 28 | github.com/cloudwego/iasm v0.2.0 // indirect 29 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 30 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 31 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 32 | github.com/gin-contrib/sse v0.1.0 // indirect 33 | github.com/go-logr/zapr v1.3.0 // indirect 34 | github.com/go-playground/locales v0.14.1 // indirect 35 | github.com/go-playground/universal-translator v0.18.1 // indirect 36 | github.com/go-playground/validator/v10 v10.20.0 // indirect 37 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 38 | github.com/goccy/go-json v0.10.2 // indirect 39 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 40 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 41 | github.com/leodido/go-urn v1.4.0 // indirect 42 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 43 | github.com/pkg/errors v0.9.1 // indirect 44 | github.com/stretchr/testify v1.10.0 // indirect 45 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 46 | github.com/ugorji/go/codec v1.2.12 // indirect 47 | github.com/x448/float16 v0.8.4 // indirect 48 | go.uber.org/multierr v1.11.0 // indirect 49 | go.uber.org/zap v1.26.0 // indirect 50 | golang.org/x/arch v0.8.0 // indirect 51 | golang.org/x/crypto v0.36.0 // indirect 52 | golang.org/x/net v0.38.0 // indirect 53 | golang.org/x/tools v0.28.0 // indirect 54 | ) 55 | 56 | require ( 57 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 58 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 59 | github.com/fatih/color v1.16.0 // indirect 60 | github.com/go-logr/logr v1.4.2 // indirect 61 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 62 | github.com/go-openapi/jsonreference v0.20.2 // indirect 63 | github.com/go-openapi/swag v0.23.0 // indirect 64 | github.com/gogo/protobuf v1.3.2 // indirect 65 | github.com/golang/protobuf v1.5.4 // indirect 66 | github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect 67 | github.com/google/go-cmp v0.6.0 // indirect 68 | github.com/google/gofuzz v1.2.0 // indirect 69 | github.com/google/uuid v1.6.0 70 | github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect 71 | github.com/imdario/mergo v0.3.12 // indirect 72 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 73 | github.com/josharian/intern v1.0.0 // indirect 74 | github.com/json-iterator/go v1.1.12 // indirect 75 | github.com/mailru/easyjson v0.7.7 // indirect 76 | github.com/mattn/go-colorable v0.1.13 // indirect 77 | github.com/mattn/go-isatty v0.0.20 // indirect 78 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 79 | github.com/modern-go/reflect2 v1.0.2 // indirect 80 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 81 | github.com/spf13/pflag v1.0.5 // indirect 82 | golang.org/x/oauth2 v0.21.0 // indirect 83 | golang.org/x/sys v0.31.0 // indirect 84 | golang.org/x/term v0.30.0 85 | golang.org/x/text v0.23.0 // indirect 86 | golang.org/x/time v0.7.0 // indirect 87 | google.golang.org/protobuf v1.36.1 88 | gopkg.in/inf.v0 v0.9.1 // indirect 89 | gopkg.in/yaml.v2 v2.4.0 90 | gopkg.in/yaml.v3 v3.0.1 91 | k8s.io/api v0.31.0 92 | k8s.io/klog/v2 v2.130.1 // indirect 93 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect 94 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 95 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 96 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect 97 | sigs.k8s.io/yaml v1.4.0 // indirect 98 | ) 99 | -------------------------------------------------------------------------------- /operator/.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | -------------------------------------------------------------------------------- /operator/.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin/* 8 | Dockerfile.cross 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Go workspace file 17 | go.work 18 | 19 | # Kubernetes Generated files - skip generated files, except for vendored files 20 | !vendor/**/zz_generated.* 21 | 22 | # editor and IDE paraphernalia 23 | .idea 24 | .vscode 25 | *.swp 26 | *.swo 27 | *~ 28 | 29 | # Cyphernetes parser 30 | cyphernetes/ -------------------------------------------------------------------------------- /operator/.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | allow-parallel-runners: true 4 | 5 | issues: 6 | # don't skip warning about doc comments 7 | # don't exclude the default set of lint 8 | exclude-use-default: false 9 | # restore some of the defaults 10 | # (fill in the rest as needed) 11 | exclude-rules: 12 | - path: "api/*" 13 | linters: 14 | - lll 15 | - path: "internal/*" 16 | linters: 17 | - dupl 18 | - lll 19 | linters: 20 | disable-all: true 21 | enable: 22 | - dupl 23 | - errcheck 24 | - exportloopref 25 | - ginkgolinter 26 | - goconst 27 | - gocyclo 28 | - gofmt 29 | - goimports 30 | - gosimple 31 | - govet 32 | - ineffassign 33 | - lll 34 | - misspell 35 | - nakedret 36 | - prealloc 37 | - revive 38 | - staticcheck 39 | - typecheck 40 | - unconvert 41 | - unparam 42 | - unused 43 | 44 | linters-settings: 45 | revive: 46 | rules: 47 | - name: comment-spacings 48 | -------------------------------------------------------------------------------- /operator/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.23 AS builder 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | 6 | WORKDIR /workspace 7 | # Copy the Go Modules manifests 8 | COPY go.mod go.mod 9 | COPY go.sum go.sum 10 | 11 | # Patch go.mod to use local cyphernetes directory 12 | RUN sed -i 's|=> ../|=> ./cyphernetes/|g' go.mod 13 | 14 | # Copy the local cyphernetes module 15 | COPY cyphernetes/ cyphernetes/ 16 | 17 | # Download dependencies 18 | RUN go mod download 19 | 20 | # Copy the go source 21 | COPY cmd/main.go cmd/main.go 22 | COPY api/ api/ 23 | COPY internal/controller/ internal/controller/ 24 | 25 | # Build 26 | # the GOARCH has not a default value to allow the binary be built according to the host where the command 27 | # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO 28 | # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, 29 | # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. 30 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go 31 | 32 | # Use distroless as minimal base image to package the manager binary 33 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 34 | FROM gcr.io/distroless/static:nonroot 35 | WORKDIR / 36 | COPY --from=builder /workspace/manager . 37 | USER 65532:65532 38 | 39 | ENTRYPOINT ["/manager"] 40 | -------------------------------------------------------------------------------- /operator/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: cyphernet.es 6 | layout: 7 | - go.kubebuilder.io/v4 8 | projectname: operator 9 | repo: github.com/avitaltamir/cyphernetes/operator 10 | resources: 11 | - api: 12 | crdVersion: v1 13 | namespaced: true 14 | controller: true 15 | domain: cyphernet.es 16 | group: cyphernetes-operator 17 | kind: DynamicOperator 18 | path: github.com/avitaltamir/cyphernetes/operator/api/v1 19 | version: v1 20 | version: "3" 21 | -------------------------------------------------------------------------------- /operator/README.md: -------------------------------------------------------------------------------- 1 | # operator 2 | // TODO(user): Add simple overview of use/purpose 3 | 4 | ## Description 5 | // TODO(user): An in-depth paragraph about your project and overview of use 6 | 7 | ## Getting Started 8 | 9 | ### Prerequisites 10 | - go version v1.22.0+ 11 | - docker version 17.03+. 12 | - kubectl version v1.11.3+. 13 | - Access to a Kubernetes v1.11.3+ cluster. 14 | 15 | ### To Deploy on the cluster 16 | **Build and push your image to the location specified by `IMG`:** 17 | 18 | ```sh 19 | make docker-build docker-push IMG=/operator:tag 20 | ``` 21 | 22 | **NOTE:** This image ought to be published in the personal registry you specified. 23 | And it is required to have access to pull the image from the working environment. 24 | Make sure you have the proper permission to the registry if the above commands don’t work. 25 | 26 | **Install the CRDs into the cluster:** 27 | 28 | ```sh 29 | make install 30 | ``` 31 | 32 | **Deploy the Manager to the cluster with the image specified by `IMG`:** 33 | 34 | ```sh 35 | make deploy IMG=/operator:tag 36 | ``` 37 | 38 | > **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin 39 | privileges or be logged in as admin. 40 | 41 | **Create instances of your solution** 42 | You can apply the samples (examples) from the config/sample: 43 | 44 | ```sh 45 | kubectl apply -k config/samples/ 46 | ``` 47 | 48 | >**NOTE**: Ensure that the samples has default values to test it out. 49 | 50 | ### To Uninstall 51 | **Delete the instances (CRs) from the cluster:** 52 | 53 | ```sh 54 | kubectl delete -k config/samples/ 55 | ``` 56 | 57 | **Delete the APIs(CRDs) from the cluster:** 58 | 59 | ```sh 60 | make uninstall 61 | ``` 62 | 63 | **UnDeploy the controller from the cluster:** 64 | 65 | ```sh 66 | make undeploy 67 | ``` 68 | 69 | ## Project Distribution 70 | 71 | Following are the steps to build the installer and distribute this project to users. 72 | 73 | 1. Build the installer for the image built and published in the registry: 74 | 75 | ```sh 76 | make build-installer IMG=/operator:tag 77 | ``` 78 | 79 | NOTE: The makefile target mentioned above generates an 'install.yaml' 80 | file in the dist directory. This file contains all the resources built 81 | with Kustomize, which are necessary to install this project without 82 | its dependencies. 83 | 84 | 2. Using the installer 85 | 86 | Users can just run kubectl apply -f to install the project, i.e.: 87 | 88 | ```sh 89 | kubectl apply -f https://raw.githubusercontent.com//operator//dist/install.yaml 90 | ``` 91 | 92 | ## Contributing 93 | // TODO(user): Add detailed information on how you would like others to contribute to this project 94 | 95 | **NOTE:** Run `make help` for more information on all potential `make` targets 96 | 97 | More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) 98 | 99 | ## License 100 | 101 | Copyright 2024. 102 | 103 | Licensed under the Apache License, Version 2.0 (the "License"); 104 | you may not use this file except in compliance with the License. 105 | You may obtain a copy of the License at 106 | 107 | http://www.apache.org/licenses/LICENSE-2.0 108 | 109 | Unless required by applicable law or agreed to in writing, software 110 | distributed under the License is distributed on an "AS IS" BASIS, 111 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 112 | See the License for the specific language governing permissions and 113 | limitations under the License. 114 | 115 | -------------------------------------------------------------------------------- /operator/api/v1/dynamicoperator_types.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | // DynamicOperatorSpec defines the desired state of DynamicOperator 8 | // +kubebuilder:validation:XValidation:rule="self.onCreate != \"\" || self.onUpdate != \"\" || self.onDelete != \"\"",message="At least one of onCreate, onUpdate, or onDelete must be specified" 9 | type DynamicOperatorSpec struct { 10 | // ResourceKind specifies the Kubernetes resource kind to watch 11 | // +kubebuilder:validation:Required 12 | ResourceKind string `json:"resourceKind"` 13 | 14 | // Namespace specifies the namespace to watch. If empty, it watches all namespaces 15 | Namespace string `json:"namespace,omitempty"` 16 | 17 | // OnCreate is the Cyphernetes query to execute when a resource is created 18 | OnCreate string `json:"onCreate,omitempty"` 19 | 20 | // OnUpdate is the Cyphernetes query to execute when a resource is updated 21 | OnUpdate string `json:"onUpdate,omitempty"` 22 | 23 | // OnDelete is the Cyphernetes query to execute when a resource is deleted 24 | OnDelete string `json:"onDelete,omitempty"` 25 | } 26 | 27 | // DynamicOperatorStatus defines the observed state of DynamicOperator 28 | type DynamicOperatorStatus struct { 29 | // ActiveWatchers is the number of active watchers for this DynamicOperator 30 | ActiveWatchers int `json:"activeWatchers"` 31 | 32 | // LastExecutedQuery is the last Cyphernetes query that was executed 33 | LastExecutedQuery string `json:"lastExecutedQuery,omitempty"` 34 | 35 | // LastExecutionTime is the timestamp of the last query execution 36 | LastExecutionTime *metav1.Time `json:"lastExecutionTime,omitempty"` 37 | } 38 | 39 | //+kubebuilder:object:root=true 40 | //+kubebuilder:subresource:status 41 | //+kubebuilder:printcolumn:name="ResourceKind",type=string,JSONPath=`.spec.resourceKind` 42 | //+kubebuilder:printcolumn:name="Namespace",type=string,JSONPath=`.spec.namespace` 43 | //+kubebuilder:printcolumn:name="ActiveWatchers",type=integer,JSONPath=`.status.activeWatchers` 44 | 45 | // DynamicOperator is the Schema for the dynamicoperators API 46 | type DynamicOperator struct { 47 | metav1.TypeMeta `json:",inline"` 48 | metav1.ObjectMeta `json:"metadata,omitempty"` 49 | 50 | Spec DynamicOperatorSpec `json:"spec,omitempty"` 51 | Status DynamicOperatorStatus `json:"status,omitempty"` 52 | } 53 | 54 | //+kubebuilder:object:root=true 55 | 56 | // DynamicOperatorList contains a list of DynamicOperator 57 | type DynamicOperatorList struct { 58 | metav1.TypeMeta `json:",inline"` 59 | metav1.ListMeta `json:"metadata,omitempty"` 60 | Items []DynamicOperator `json:"items"` 61 | } 62 | 63 | func init() { 64 | SchemeBuilder.Register(&DynamicOperator{}, &DynamicOperatorList{}) 65 | } 66 | -------------------------------------------------------------------------------- /operator/api/v1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package v1 contains API Schema definitions for the operator v1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=cyphernetes-operator.cyphernet.es 20 | package v1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | var ( 28 | // GroupVersion is group version used to register these objects 29 | GroupVersion = schema.GroupVersion{Group: "cyphernetes-operator.cyphernet.es", Version: "v1"} 30 | 31 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 32 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 33 | 34 | // AddToScheme adds the types in this group-version to the given scheme. 35 | AddToScheme = SchemeBuilder.AddToScheme 36 | ) 37 | -------------------------------------------------------------------------------- /operator/api/v1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | /* 4 | Copyright 2024. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by controller-gen. DO NOT EDIT. 20 | 21 | package v1 22 | 23 | import ( 24 | runtime "k8s.io/apimachinery/pkg/runtime" 25 | ) 26 | 27 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 28 | func (in *DynamicOperator) DeepCopyInto(out *DynamicOperator) { 29 | *out = *in 30 | out.TypeMeta = in.TypeMeta 31 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 32 | out.Spec = in.Spec 33 | in.Status.DeepCopyInto(&out.Status) 34 | } 35 | 36 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicOperator. 37 | func (in *DynamicOperator) DeepCopy() *DynamicOperator { 38 | if in == nil { 39 | return nil 40 | } 41 | out := new(DynamicOperator) 42 | in.DeepCopyInto(out) 43 | return out 44 | } 45 | 46 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 47 | func (in *DynamicOperator) DeepCopyObject() runtime.Object { 48 | if c := in.DeepCopy(); c != nil { 49 | return c 50 | } 51 | return nil 52 | } 53 | 54 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 55 | func (in *DynamicOperatorList) DeepCopyInto(out *DynamicOperatorList) { 56 | *out = *in 57 | out.TypeMeta = in.TypeMeta 58 | in.ListMeta.DeepCopyInto(&out.ListMeta) 59 | if in.Items != nil { 60 | in, out := &in.Items, &out.Items 61 | *out = make([]DynamicOperator, len(*in)) 62 | for i := range *in { 63 | (*in)[i].DeepCopyInto(&(*out)[i]) 64 | } 65 | } 66 | } 67 | 68 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicOperatorList. 69 | func (in *DynamicOperatorList) DeepCopy() *DynamicOperatorList { 70 | if in == nil { 71 | return nil 72 | } 73 | out := new(DynamicOperatorList) 74 | in.DeepCopyInto(out) 75 | return out 76 | } 77 | 78 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 79 | func (in *DynamicOperatorList) DeepCopyObject() runtime.Object { 80 | if c := in.DeepCopy(); c != nil { 81 | return c 82 | } 83 | return nil 84 | } 85 | 86 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 87 | func (in *DynamicOperatorSpec) DeepCopyInto(out *DynamicOperatorSpec) { 88 | *out = *in 89 | } 90 | 91 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicOperatorSpec. 92 | func (in *DynamicOperatorSpec) DeepCopy() *DynamicOperatorSpec { 93 | if in == nil { 94 | return nil 95 | } 96 | out := new(DynamicOperatorSpec) 97 | in.DeepCopyInto(out) 98 | return out 99 | } 100 | 101 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 102 | func (in *DynamicOperatorStatus) DeepCopyInto(out *DynamicOperatorStatus) { 103 | *out = *in 104 | if in.LastExecutionTime != nil { 105 | in, out := &in.LastExecutionTime, &out.LastExecutionTime 106 | *out = (*in).DeepCopy() 107 | } 108 | } 109 | 110 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicOperatorStatus. 111 | func (in *DynamicOperatorStatus) DeepCopy() *DynamicOperatorStatus { 112 | if in == nil { 113 | return nil 114 | } 115 | out := new(DynamicOperatorStatus) 116 | in.DeepCopyInto(out) 117 | return out 118 | } 119 | -------------------------------------------------------------------------------- /operator/cmd/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | 24 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 25 | // to ensure that exec-entrypoint and run can make use of them. 26 | _ "k8s.io/client-go/plugin/pkg/client/auth" 27 | 28 | "k8s.io/apimachinery/pkg/runtime" 29 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 30 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 31 | ctrl "sigs.k8s.io/controller-runtime" 32 | "sigs.k8s.io/controller-runtime/pkg/healthz" 33 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 34 | "sigs.k8s.io/controller-runtime/pkg/metrics/filters" 35 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 36 | "sigs.k8s.io/controller-runtime/pkg/webhook" 37 | 38 | operatorv1 "github.com/avitaltamir/cyphernetes/operator/api/v1" 39 | "github.com/avitaltamir/cyphernetes/operator/internal/controller" 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 | utilruntime.Must(operatorv1.AddToScheme(scheme)) 51 | // +kubebuilder:scaffold:scheme 52 | } 53 | 54 | func main() { 55 | var ( 56 | metricsAddr string 57 | enableLeaderElection bool 58 | probeAddr string 59 | secureMetrics bool 60 | enableHTTP2 bool 61 | ) 62 | 63 | flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ 64 | "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") 65 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 66 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, 67 | "Enable leader election for controller manager. "+ 68 | "Enabling this will ensure there is only one active controller manager.") 69 | flag.BoolVar(&secureMetrics, "metrics-secure", true, 70 | "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") 71 | flag.BoolVar(&enableHTTP2, "enable-http2", false, 72 | "If set, HTTP/2 will be enabled for the metrics and webhook servers") 73 | 74 | opts := zap.Options{ 75 | Development: true, 76 | } 77 | opts.BindFlags(flag.CommandLine) 78 | flag.Parse() 79 | 80 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 81 | 82 | var tlsOpts []func(*tls.Config) 83 | if !enableHTTP2 { 84 | tlsOpts = append(tlsOpts, func(c *tls.Config) { 85 | setupLog.Info("disabling http/2") 86 | c.NextProtos = []string{"http/1.1"} 87 | }) 88 | } 89 | 90 | webhookServer := webhook.NewServer(webhook.Options{ 91 | TLSOpts: tlsOpts, 92 | }) 93 | 94 | metricsServerOptions := metricsserver.Options{ 95 | BindAddress: metricsAddr, 96 | SecureServing: secureMetrics, 97 | TLSOpts: tlsOpts, 98 | } 99 | 100 | if secureMetrics { 101 | metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization 102 | } 103 | 104 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 105 | Scheme: scheme, 106 | Metrics: metricsServerOptions, 107 | WebhookServer: webhookServer, 108 | HealthProbeBindAddress: probeAddr, 109 | LeaderElection: enableLeaderElection, 110 | LeaderElectionID: "8ab42d44.cyphernet.es", 111 | }) 112 | if err != nil { 113 | setupLog.Error(err, "unable to start manager") 114 | os.Exit(1) 115 | } 116 | 117 | setupLog.Info("Setting up controller") 118 | reconciler := &controller.DynamicOperatorReconciler{ 119 | Client: mgr.GetClient(), 120 | Scheme: mgr.GetScheme(), 121 | } 122 | if err = reconciler.SetupWithManager(mgr); err != nil { 123 | setupLog.Error(err, "unable to create controller", "controller", "DynamicOperator") 124 | os.Exit(1) 125 | } 126 | setupLog.Info("Controller setup complete") 127 | // +kubebuilder:scaffold:builder 128 | 129 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 130 | setupLog.Error(err, "unable to set up health check") 131 | os.Exit(1) 132 | } 133 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 134 | setupLog.Error(err, "unable to set up ready check") 135 | os.Exit(1) 136 | } 137 | 138 | setupLog.Info("starting manager") 139 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 140 | setupLog.Error(err, "problem running manager") 141 | os.Exit(1) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /operator/config/crd/bases/cyphernetes-operator.cyphernet.es_dynamicoperators.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.16.1 7 | name: dynamicoperators.cyphernetes-operator.cyphernet.es 8 | spec: 9 | group: cyphernetes-operator.cyphernet.es 10 | names: 11 | kind: DynamicOperator 12 | listKind: DynamicOperatorList 13 | plural: dynamicoperators 14 | singular: dynamicoperator 15 | scope: Namespaced 16 | versions: 17 | - additionalPrinterColumns: 18 | - jsonPath: .spec.resourceKind 19 | name: ResourceKind 20 | type: string 21 | - jsonPath: .spec.namespace 22 | name: Namespace 23 | type: string 24 | - jsonPath: .status.activeWatchers 25 | name: ActiveWatchers 26 | type: integer 27 | name: v1 28 | schema: 29 | openAPIV3Schema: 30 | description: DynamicOperator is the Schema for the dynamicoperators API 31 | properties: 32 | apiVersion: 33 | description: |- 34 | APIVersion defines the versioned schema of this representation of an object. 35 | Servers should convert recognized schemas to the latest internal value, and 36 | may reject unrecognized values. 37 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 38 | type: string 39 | kind: 40 | description: |- 41 | Kind is a string value representing the REST resource this object represents. 42 | Servers may infer this from the endpoint the client submits requests to. 43 | Cannot be updated. 44 | In CamelCase. 45 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 46 | type: string 47 | metadata: 48 | type: object 49 | spec: 50 | description: DynamicOperatorSpec defines the desired state of DynamicOperator 51 | properties: 52 | namespace: 53 | description: Namespace specifies the namespace to watch. If empty, 54 | it watches all namespaces 55 | type: string 56 | onCreate: 57 | description: OnCreate is the Cyphernetes query to execute when a resource 58 | is created 59 | type: string 60 | onDelete: 61 | description: OnDelete is the Cyphernetes query to execute when a resource 62 | is deleted 63 | type: string 64 | onUpdate: 65 | description: OnUpdate is the Cyphernetes query to execute when a resource 66 | is updated 67 | type: string 68 | resourceKind: 69 | description: ResourceKind specifies the Kubernetes resource kind to 70 | watch 71 | type: string 72 | required: 73 | - resourceKind 74 | type: object 75 | x-kubernetes-validations: 76 | - message: At least one of onCreate, onUpdate, or onDelete must be specified 77 | rule: self.onCreate != "" || self.onUpdate != "" || self.onDelete != 78 | "" 79 | status: 80 | description: DynamicOperatorStatus defines the observed state of DynamicOperator 81 | properties: 82 | activeWatchers: 83 | description: ActiveWatchers is the number of active watchers for this 84 | DynamicOperator 85 | type: integer 86 | lastExecutedQuery: 87 | description: LastExecutedQuery is the last Cyphernetes query that 88 | was executed 89 | type: string 90 | lastExecutionTime: 91 | description: LastExecutionTime is the timestamp of the last query 92 | execution 93 | format: date-time 94 | type: string 95 | required: 96 | - activeWatchers 97 | type: object 98 | type: object 99 | served: true 100 | storage: true 101 | subresources: 102 | status: {} 103 | -------------------------------------------------------------------------------- /operator/config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/cyphernetes-operator.cyphernet.es_dynamicoperators.yaml 6 | # +kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patches: 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | # +kubebuilder:scaffold:crdkustomizewebhookpatch 12 | 13 | # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. 14 | # patches here are for enabling the CA injection for each CRD 15 | #- path: patches/cainjection_in_dynamicoperators.yaml 16 | # +kubebuilder:scaffold:crdkustomizecainjectionpatch 17 | 18 | # [WEBHOOK] To enable webhook, uncomment the following section 19 | # the following config is for teaching kustomize how to do kustomization for CRDs. 20 | 21 | #configurations: 22 | #- kustomizeconfig.yaml 23 | -------------------------------------------------------------------------------- /operator/config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | version: v1 14 | group: apiextensions.k8s.io 15 | path: spec/conversion/webhook/clientConfig/service/namespace 16 | create: false 17 | 18 | varReference: 19 | - path: metadata/annotations 20 | -------------------------------------------------------------------------------- /operator/config/default/manager_metrics_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch adds the args to allow exposing the metrics endpoint using HTTPS 2 | - op: add 3 | path: /spec/template/spec/containers/0/args/0 4 | value: --metrics-bind-address=:8443 5 | -------------------------------------------------------------------------------- /operator/config/default/metrics_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: cyphernetes-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: controller-manager-metrics-service 9 | namespace: system 10 | spec: 11 | ports: 12 | - name: https 13 | port: 8443 14 | protocol: TCP 15 | targetPort: 8443 16 | selector: 17 | control-plane: controller-manager 18 | -------------------------------------------------------------------------------- /operator/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: fatliverfreddy/cyphernetes-operator 8 | newTag: latest 9 | -------------------------------------------------------------------------------- /operator/config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: cyphernetes-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: system 9 | --- 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | metadata: 13 | name: controller-manager 14 | namespace: system 15 | labels: 16 | control-plane: controller-manager 17 | app.kubernetes.io/name: cyphernetes-operator 18 | app.kubernetes.io/managed-by: kustomize 19 | spec: 20 | selector: 21 | matchLabels: 22 | control-plane: controller-manager 23 | replicas: 1 24 | template: 25 | metadata: 26 | annotations: 27 | kubectl.kubernetes.io/default-container: manager 28 | labels: 29 | control-plane: controller-manager 30 | spec: 31 | # TODO(user): Uncomment the following code to configure the nodeAffinity expression 32 | # according to the platforms which are supported by your solution. 33 | # It is considered best practice to support multiple architectures. You can 34 | # build your manager image using the makefile target docker-buildx. 35 | # affinity: 36 | # nodeAffinity: 37 | # requiredDuringSchedulingIgnoredDuringExecution: 38 | # nodeSelectorTerms: 39 | # - matchExpressions: 40 | # - key: kubernetes.io/arch 41 | # operator: In 42 | # values: 43 | # - amd64 44 | # - arm64 45 | # - ppc64le 46 | # - s390x 47 | # - key: kubernetes.io/os 48 | # operator: In 49 | # values: 50 | # - linux 51 | securityContext: 52 | runAsNonRoot: true 53 | # TODO(user): For common cases that do not require escalating privileges 54 | # it is recommended to ensure that all your Pods/Containers are restrictive. 55 | # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted 56 | # Please uncomment the following code if your project does NOT have to work on old Kubernetes 57 | # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ). 58 | # seccompProfile: 59 | # type: RuntimeDefault 60 | containers: 61 | - command: 62 | - /manager 63 | args: 64 | - --leader-elect 65 | - --health-probe-bind-address=:8081 66 | image: controller:latest 67 | name: manager 68 | securityContext: 69 | allowPrivilegeEscalation: false 70 | capabilities: 71 | drop: 72 | - "ALL" 73 | livenessProbe: 74 | httpGet: 75 | path: /healthz 76 | port: 8081 77 | initialDelaySeconds: 15 78 | periodSeconds: 20 79 | readinessProbe: 80 | httpGet: 81 | path: /readyz 82 | port: 8081 83 | initialDelaySeconds: 5 84 | periodSeconds: 10 85 | # TODO(user): Configure the resources accordingly based on the project requirements. 86 | # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ 87 | resources: 88 | limits: 89 | cpu: 500m 90 | memory: 128Mi 91 | requests: 92 | cpu: 10m 93 | memory: 64Mi 94 | serviceAccountName: controller-manager 95 | terminationGracePeriodSeconds: 10 96 | -------------------------------------------------------------------------------- /operator/config/network-policy/allow-metrics-traffic.yaml: -------------------------------------------------------------------------------- 1 | # This NetworkPolicy allows ingress traffic 2 | # with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those 3 | # namespaces are able to gathering data from the metrics endpoint. 4 | apiVersion: networking.k8s.io/v1 5 | kind: NetworkPolicy 6 | metadata: 7 | labels: 8 | app.kubernetes.io/name: cyphernetes-operator 9 | app.kubernetes.io/managed-by: kustomize 10 | name: allow-metrics-traffic 11 | namespace: system 12 | spec: 13 | podSelector: 14 | matchLabels: 15 | control-plane: controller-manager 16 | policyTypes: 17 | - Ingress 18 | ingress: 19 | # This allows ingress traffic from any namespace with the label metrics: enabled 20 | - from: 21 | - namespaceSelector: 22 | matchLabels: 23 | metrics: enabled # Only from namespaces with this label 24 | ports: 25 | - port: 8443 26 | protocol: TCP 27 | -------------------------------------------------------------------------------- /operator/config/network-policy/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - allow-metrics-traffic.yaml 3 | -------------------------------------------------------------------------------- /operator/config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /operator/config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | # Prometheus Monitor Service (Metrics) 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | labels: 6 | control-plane: controller-manager 7 | app.kubernetes.io/name: cyphernetes-operator 8 | app.kubernetes.io/managed-by: kustomize 9 | name: controller-manager-metrics-monitor 10 | namespace: system 11 | spec: 12 | endpoints: 13 | - path: /metrics 14 | port: https # Ensure this is the name of the port that exposes HTTPS metrics 15 | scheme: https 16 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 17 | tlsConfig: 18 | # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables 19 | # certificate verification. This poses a significant security risk by making the system vulnerable to 20 | # man-in-the-middle attacks, where an attacker could intercept and manipulate the communication between 21 | # Prometheus and the monitored services. This could lead to unauthorized access to sensitive metrics data, 22 | # compromising the integrity and confidentiality of the information. 23 | # Please use the following options for secure configurations: 24 | # caFile: /etc/metrics-certs/ca.crt 25 | # certFile: /etc/metrics-certs/tls.crt 26 | # keyFile: /etc/metrics-certs/tls.key 27 | insecureSkipVerify: true 28 | selector: 29 | matchLabels: 30 | control-plane: controller-manager 31 | -------------------------------------------------------------------------------- /operator/config/rbac/dynamicoperator_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit dynamicoperators. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: cyphernetes-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: dynamicoperator-editor-role 9 | rules: 10 | - apiGroups: 11 | - cyphernetes-operator.cyphernet.es 12 | resources: 13 | - dynamicoperators 14 | verbs: 15 | - create 16 | - delete 17 | - get 18 | - list 19 | - patch 20 | - update 21 | - watch 22 | - apiGroups: 23 | - cyphernetes-operator.cyphernet.es 24 | resources: 25 | - dynamicoperators/status 26 | verbs: 27 | - get 28 | 29 | -------------------------------------------------------------------------------- /operator/config/rbac/dynamicoperator_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view dynamicoperators. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: cyphernetes-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: dynamicoperator-viewer-role 9 | rules: 10 | - apiGroups: 11 | - cyphernetes-operator.cyphernet.es 12 | resources: 13 | - dynamicoperators 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - apiGroups: 19 | - cyphernetes-operator.cyphernet.es 20 | resources: 21 | - dynamicoperators/status 22 | verbs: 23 | - get 24 | -------------------------------------------------------------------------------- /operator/config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | # All RBAC will be applied under this service account in 3 | # the deployment namespace. You may comment out this resource 4 | # if your manager will use a service account that exists at 5 | # runtime. Be sure to update RoleBinding and ClusterRoleBinding 6 | # subjects if changing service account names. 7 | - service_account.yaml 8 | - role.yaml 9 | - role_binding.yaml 10 | - leader_election_role.yaml 11 | - leader_election_role_binding.yaml 12 | # The following RBAC configurations are used to protect 13 | # the metrics endpoint with authn/authz. These configurations 14 | # ensure that only authorized users and service accounts 15 | # can access the metrics endpoint. Comment the following 16 | # permissions if you want to disable this protection. 17 | # More info: https://book.kubebuilder.io/reference/metrics.html 18 | - metrics_auth_role.yaml 19 | - metrics_auth_role_binding.yaml 20 | - metrics_reader_role.yaml 21 | # For each CRD, "Editor" and "Viewer" roles are scaffolded by 22 | # default, aiding admins in cluster management. Those roles are 23 | # not used by the Project itself. You can comment the following lines 24 | # if you do not want those helpers be installed with your Project. 25 | - dynamicoperator_editor_role.yaml 26 | - dynamicoperator_viewer_role.yaml 27 | 28 | -------------------------------------------------------------------------------- /operator/config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: cyphernetes-operator 7 | app.kubernetes.io/managed-by: kustomize 8 | name: leader-election-role 9 | rules: 10 | - apiGroups: 11 | - "" 12 | resources: 13 | - configmaps 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - patch 21 | - delete 22 | - apiGroups: 23 | - coordination.k8s.io 24 | resources: 25 | - leases 26 | verbs: 27 | - get 28 | - list 29 | - watch 30 | - create 31 | - update 32 | - patch 33 | - delete 34 | - apiGroups: 35 | - "" 36 | resources: 37 | - events 38 | verbs: 39 | - create 40 | - patch 41 | -------------------------------------------------------------------------------- /operator/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: cyphernetes-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: leader-election-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: Role 11 | name: leader-election-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: controller-manager 15 | namespace: system 16 | -------------------------------------------------------------------------------- /operator/config/rbac/metrics_auth_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-auth-role 5 | rules: 6 | - apiGroups: 7 | - authentication.k8s.io 8 | resources: 9 | - tokenreviews 10 | verbs: 11 | - create 12 | - apiGroups: 13 | - authorization.k8s.io 14 | resources: 15 | - subjectaccessreviews 16 | verbs: 17 | - create 18 | -------------------------------------------------------------------------------- /operator/config/rbac/metrics_auth_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: metrics-auth-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: metrics-auth-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /operator/config/rbac/metrics_reader_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-reader 5 | rules: 6 | - nonResourceURLs: 7 | - "/metrics" 8 | verbs: 9 | - get 10 | -------------------------------------------------------------------------------- /operator/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 | - cyphernetes-operator.cyphernet.es 9 | resources: 10 | - dynamicoperators 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - cyphernetes-operator.cyphernet.es 21 | resources: 22 | - dynamicoperators/finalizers 23 | verbs: 24 | - update 25 | - apiGroups: 26 | - cyphernetes-operator.cyphernet.es 27 | resources: 28 | - dynamicoperators/status 29 | verbs: 30 | - get 31 | - patch 32 | - update 33 | -------------------------------------------------------------------------------- /operator/config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: cyphernetes-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: manager-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: manager-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: controller-manager 15 | namespace: system 16 | # Remove the following ClusterRoleBinding 17 | # --- 18 | # apiVersion: rbac.authorization.k8s.io/v1 19 | # kind: ClusterRoleBinding 20 | # metadata: 21 | # name: additional-manager-rolebinding 22 | # roleRef: 23 | # apiGroup: rbac.authorization.k8s.io 24 | # kind: ClusterRole 25 | # name: additional-manager-role 26 | # subjects: 27 | # - kind: ServiceAccount 28 | # name: controller-manager 29 | # namespace: system 30 | -------------------------------------------------------------------------------- /operator/config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: cyphernetes-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: controller-manager 8 | namespace: system 9 | -------------------------------------------------------------------------------- /operator/config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## Append samples of your project ## 2 | resources: 3 | - operator_v1_dynamicoperator.yaml 4 | # +kubebuilder:scaffold:manifestskustomizesamples 5 | -------------------------------------------------------------------------------- /operator/config/samples/operator_v1_dynamicoperator.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cyphernetes-operator.cyphernet.es/v1 2 | kind: DynamicOperator 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: cyphernetes-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: dynamicoperator-sample 8 | spec: 9 | # TODO(user): Add fields here 10 | -------------------------------------------------------------------------------- /operator/hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | */ -------------------------------------------------------------------------------- /operator/helm/cyphernetes-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: cyphernetes-operator 3 | description: A Helm chart for the Cyphernetes Operator 4 | type: application 5 | version: 0.1.0 6 | appVersion: "1.0.0" 7 | kubeVersion: ">= 1.16.0-0" 8 | keywords: 9 | - cyphernetes 10 | - operator 11 | maintainers: 12 | - name: Avital Tamir 13 | email: avital@cyphernet.es 14 | # Add this line to include the CRD 15 | dependencies: 16 | - name: crds 17 | version: "0.1.0" 18 | condition: installCRDs -------------------------------------------------------------------------------- /operator/helm/cyphernetes-operator/charts/crds/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: crds 3 | description: Custom Resource Definitions for Cyphernetes Operator 4 | type: application 5 | version: 0.1.0 6 | appVersion: "1.0.0" -------------------------------------------------------------------------------- /operator/helm/cyphernetes-operator/crds/dynamicoperators.cyphernetes-operator.cyphernet.es.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | annotations: 5 | helm.sh/hook: pre-install,pre-upgrade 6 | helm.sh/hook-weight: "-5" 7 | name: dynamicoperators.cyphernetes-operator.cyphernet.es 8 | spec: 9 | group: cyphernetes-operator.cyphernet.es 10 | names: 11 | kind: DynamicOperator 12 | listKind: DynamicOperatorList 13 | plural: dynamicoperators 14 | singular: dynamicoperator 15 | scope: Namespaced 16 | versions: 17 | - additionalPrinterColumns: 18 | - jsonPath: .spec.resourceKind 19 | name: ResourceKind 20 | type: string 21 | - jsonPath: .spec.namespace 22 | name: Namespace 23 | type: string 24 | - jsonPath: .status.activeWatchers 25 | name: ActiveWatchers 26 | type: integer 27 | name: v1 28 | schema: 29 | openAPIV3Schema: 30 | description: DynamicOperator is the Schema for the dynamicoperators API 31 | properties: 32 | apiVersion: 33 | description: APIVersion defines the versioned schema of this representation of an object. 34 | type: string 35 | kind: 36 | description: Kind is a string value representing the REST resource this object represents. 37 | type: string 38 | metadata: 39 | type: object 40 | spec: 41 | description: DynamicOperatorSpec defines the desired state of DynamicOperator 42 | properties: 43 | finalizer: 44 | description: Finalizer specifies whether the operator should register itself as a finalizer on the watched resources 45 | type: boolean 46 | namespace: 47 | description: Namespace specifies the namespace to watch. If empty, it watches all namespaces 48 | type: string 49 | onCreate: 50 | description: OnCreate is the Cyphernetes query to execute when a resource is created 51 | type: string 52 | onDelete: 53 | description: OnDelete is the Cyphernetes query to execute when a resource is deleted 54 | type: string 55 | onUpdate: 56 | description: OnUpdate is the Cyphernetes query to execute when a resource is updated 57 | type: string 58 | resourceKind: 59 | description: ResourceKind specifies the Kubernetes resource kind to watch 60 | type: string 61 | required: 62 | - resourceKind 63 | type: object 64 | x-kubernetes-validations: 65 | - message: At least one of onCreate, onUpdate, or onDelete must be specified 66 | rule: self.onCreate != "" || self.onUpdate != "" || self.onDelete != "" 67 | status: 68 | description: DynamicOperatorStatus defines the observed state of DynamicOperator 69 | properties: 70 | activeWatchers: 71 | description: ActiveWatchers is the number of active watchers for this DynamicOperator 72 | type: integer 73 | lastExecutedQuery: 74 | description: LastExecutedQuery is the last Cyphernetes query that was executed 75 | type: string 76 | lastExecutionTime: 77 | description: LastExecutionTime is the timestamp of the last query execution 78 | format: date-time 79 | type: string 80 | required: 81 | - activeWatchers 82 | type: object 83 | type: object 84 | served: true 85 | storage: true 86 | subresources: 87 | status: {} 88 | -------------------------------------------------------------------------------- /operator/helm/cyphernetes-operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "cyphernetes-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 "cyphernetes-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 "cyphernetes-operator.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "cyphernetes-operator.labels" -}} 37 | helm.sh/chart: {{ include "cyphernetes-operator.chart" . }} 38 | {{ include "cyphernetes-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 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "cyphernetes-operator.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "cyphernetes-operator.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "cyphernetes-operator.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "cyphernetes-operator.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | 64 | {{/* Validate values */}} 65 | {{- define "cyphernetes-operator.validateValues" -}} 66 | {{- if not .Values.managedKinds -}} 67 | {{- fail "At least one kind must be specified in .Values.managedKinds" -}} 68 | {{- end -}} 69 | {{- range .Values.extraPermissions -}} 70 | {{- if not .kind -}} 71 | {{- fail "Each item in extraPermissions must have a 'kind' field" -}} 72 | {{- end -}} 73 | {{- if not .verbs -}} 74 | {{- fail "Each item in extraPermissions must have a 'verbs' field" -}} 75 | {{- end -}} 76 | {{- end -}} 77 | {{- end -}} 78 | 79 | {{/* Get API Group */}} 80 | {{- define "cyphernetes-operator.getAPIGroup" -}} 81 | {{- $parts := splitList "." . -}} 82 | {{- if gt (len $parts) 1 -}} 83 | {{- $group := rest $parts | join "." -}} 84 | {{- $group -}} 85 | {{- else -}} 86 | {{- "" -}} 87 | {{- end -}} 88 | {{- end -}} 89 | 90 | {{/* Get Resource */}} 91 | {{- define "cyphernetes-operator.getResource" -}} 92 | {{- $parts := splitList "." . -}} 93 | {{- if gt (len $parts) 0 -}} 94 | {{- $resource := first $parts -}} 95 | {{- $resource -}} 96 | {{- else -}} 97 | {{- . -}} 98 | {{- end -}} 99 | {{- end -}} -------------------------------------------------------------------------------- /operator/helm/cyphernetes-operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "cyphernetes-operator.fullname" . }}-controller-manager 5 | namespace: {{ .Release.Namespace }} 6 | labels: 7 | control-plane: controller-manager 8 | {{- include "cyphernetes-operator.labels" . | nindent 4 }} 9 | app: cyphernetes-operator 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | selector: 13 | matchLabels: 14 | control-plane: controller-manager 15 | template: 16 | metadata: 17 | annotations: 18 | kubectl.kubernetes.io/default-container: manager 19 | labels: 20 | control-plane: controller-manager 21 | app: cyphernetes-operator 22 | spec: 23 | securityContext: 24 | runAsNonRoot: true 25 | containers: 26 | - command: 27 | - /manager 28 | args: 29 | - --metrics-bind-address=:8443 30 | - --leader-elect 31 | - --health-probe-bind-address=:8081 32 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 33 | name: manager 34 | securityContext: 35 | allowPrivilegeEscalation: false 36 | capabilities: 37 | drop: 38 | - ALL 39 | livenessProbe: 40 | httpGet: 41 | path: /healthz 42 | port: 8081 43 | initialDelaySeconds: 15 44 | periodSeconds: 20 45 | readinessProbe: 46 | httpGet: 47 | path: /readyz 48 | port: 8081 49 | initialDelaySeconds: 5 50 | periodSeconds: 10 51 | resources: 52 | {{- toYaml .Values.resources | nindent 12 }} 53 | serviceAccountName: {{ include "cyphernetes-operator.fullname" . }}-controller-manager 54 | terminationGracePeriodSeconds: 10 -------------------------------------------------------------------------------- /operator/helm/cyphernetes-operator/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "cyphernetes-operator.fullname" . }}-controller-manager-metrics-service 5 | namespace: {{ .Release.Namespace }} 6 | labels: 7 | control-plane: controller-manager 8 | {{- include "cyphernetes-operator.labels" . | nindent 4 }} 9 | app: cyphernetes-operator 10 | spec: 11 | ports: 12 | - name: https 13 | port: 8443 14 | targetPort: 8443 15 | selector: 16 | control-plane: controller-manager -------------------------------------------------------------------------------- /operator/helm/cyphernetes-operator/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | 3 | image: 4 | repository: fatliverfreddy/cyphernetes-operator 5 | pullPolicy: IfNotPresent 6 | tag: "latest" 7 | 8 | imagePullSecrets: [] 9 | nameOverride: "" 10 | fullnameOverride: "" 11 | 12 | serviceAccount: 13 | create: true 14 | annotations: {} 15 | name: "" 16 | 17 | podAnnotations: {} 18 | 19 | podSecurityContext: {} 20 | 21 | securityContext: {} 22 | 23 | resources: 24 | limits: 25 | cpu: 500m 26 | memory: 128Mi 27 | requests: 28 | cpu: 10m 29 | memory: 64Mi 30 | 31 | nodeSelector: {} 32 | 33 | tolerations: [] 34 | 35 | affinity: {} 36 | 37 | # At least one kind must be specified. 38 | # These are the kinds that dynamic operators created by this controller can manage. 39 | managedKinds: 40 | # - exposeddeployments # For exposeddeployment operator 41 | - deployments.apps # For active/inactive ingress operator 42 | 43 | # These are the permissions to run Cyphernetes queries by the dynamic operators created by this controller. 44 | extraPermissions: 45 | # Example permissions for exposeddeployments operator 46 | # - kind: deployments.apps 47 | # verbs: 48 | # - get 49 | # - list 50 | # - watch 51 | # - create 52 | # - update 53 | # - patch 54 | # - delete 55 | # - kind: services 56 | # verbs: 57 | # - get 58 | # - list 59 | # - watch 60 | # - create 61 | # - update 62 | # - patch 63 | # - delete 64 | # Example permissions for ingresses active/inactive operator 65 | - kind: services 66 | verbs: 67 | - get 68 | - list 69 | - watch 70 | - kind: ingresses.networking.k8s.io 71 | verbs: 72 | - get 73 | - list 74 | - watch 75 | - update 76 | - patch 77 | -------------------------------------------------------------------------------- /operator/internal/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "testing" 23 | "time" 24 | 25 | . "github.com/onsi/ginkgo/v2" 26 | . "github.com/onsi/gomega" 27 | 28 | "k8s.io/client-go/kubernetes/scheme" 29 | "sigs.k8s.io/controller-runtime/pkg/client" 30 | "sigs.k8s.io/controller-runtime/pkg/envtest" 31 | logf "sigs.k8s.io/controller-runtime/pkg/log" 32 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 33 | 34 | operatorv1 "github.com/avitaltamir/cyphernetes/operator/api/v1" 35 | ctrl "sigs.k8s.io/controller-runtime" 36 | ) 37 | 38 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 39 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 40 | 41 | var k8sClient client.Client 42 | var testEnv *envtest.Environment 43 | var ctx context.Context 44 | var cancel context.CancelFunc 45 | 46 | func TestControllers(t *testing.T) { 47 | RegisterFailHandler(Fail) 48 | RunSpecs(t, "Controller Suite") 49 | } 50 | 51 | var _ = BeforeSuite(func() { 52 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 53 | 54 | ctx, cancel = context.WithCancel(context.TODO()) 55 | 56 | By("bootstrapping test environment") 57 | useExistingCluster := true 58 | testEnv = &envtest.Environment{ 59 | UseExistingCluster: &useExistingCluster, 60 | CRDDirectoryPaths: []string{"../../config/crd/bases"}, 61 | ErrorIfCRDPathMissing: true, 62 | } 63 | 64 | // Use the KUBEBUILDER_ASSETS environment variable set by the Makefile 65 | kubebuilderAssets := os.Getenv("KUBEBUILDER_ASSETS") 66 | if kubebuilderAssets == "" { 67 | Fail("KUBEBUILDER_ASSETS environment variable is not set") 68 | } 69 | 70 | cfg, err := testEnv.Start() 71 | Expect(err).NotTo(HaveOccurred()) 72 | Expect(cfg).NotTo(BeNil()) 73 | 74 | err = operatorv1.AddToScheme(scheme.Scheme) 75 | Expect(err).NotTo(HaveOccurred()) 76 | 77 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 78 | Expect(err).NotTo(HaveOccurred()) 79 | Expect(k8sClient).NotTo(BeNil()) 80 | 81 | k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ 82 | Scheme: scheme.Scheme, 83 | }) 84 | Expect(err).ToNot(HaveOccurred()) 85 | 86 | err = (&DynamicOperatorReconciler{ 87 | Client: k8sManager.GetClient(), 88 | Scheme: k8sManager.GetScheme(), 89 | }).SetupWithManager(k8sManager) 90 | Expect(err).ToNot(HaveOccurred()) 91 | 92 | go func() { 93 | err = k8sManager.Start(ctx) 94 | Expect(err).ToNot(HaveOccurred()) 95 | }() 96 | }) 97 | 98 | var _ = AfterSuite(func() { 99 | By("tearing down the test environment") 100 | cancel() // Cancel the context first 101 | err := testEnv.Stop() 102 | Expect(err).NotTo(HaveOccurred()) 103 | 104 | // Add a delay to allow for graceful shutdown 105 | time.Sleep(time.Second * 2) 106 | }) 107 | -------------------------------------------------------------------------------- /operator/test/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package e2e 18 | 19 | import ( 20 | "fmt" 21 | "testing" 22 | 23 | . "github.com/onsi/ginkgo/v2" 24 | . "github.com/onsi/gomega" 25 | ) 26 | 27 | // Run e2e tests using the Ginkgo runner. 28 | func TestE2E(t *testing.T) { 29 | RegisterFailHandler(Fail) 30 | _, _ = fmt.Fprintf(GinkgoWriter, "Starting operator suite\n") 31 | RunSpecs(t, "e2e suite") 32 | } 33 | -------------------------------------------------------------------------------- /operator/test/e2e/samples/crd-exposeddeployments.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: exposeddeployments.cyphernet.es 5 | namespace: default 6 | spec: 7 | group: cyphernet.es 8 | names: 9 | kind: ExposedDeployment 10 | listKind: ExposedDeploymentList 11 | plural: exposeddeployments 12 | singular: exposeddeployment 13 | scope: Namespaced 14 | versions: 15 | - name: v1 16 | served: true 17 | storage: true 18 | schema: 19 | openAPIV3Schema: 20 | type: object 21 | properties: 22 | spec: 23 | type: object 24 | properties: 25 | image: 26 | type: string 27 | required: 28 | - image -------------------------------------------------------------------------------- /operator/test/e2e/samples/dynamicoperator-exposeddeployments.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cyphernetes-operator.cyphernet.es/v1 2 | kind: DynamicOperator 3 | metadata: 4 | name: exposeddeployment-operator 5 | namespace: default 6 | spec: 7 | resourceKind: exposeddeployments 8 | namespace: default 9 | onCreate: | 10 | CREATE (d:Deployment { 11 | "metadata": { 12 | "name": "child-of-{{$.metadata.name}}", 13 | "labels": { 14 | "app": "child-of-{{$.metadata.name}}" 15 | } 16 | }, 17 | "spec": { 18 | "selector": { 19 | "matchLabels": { 20 | "app": "child-of-{{$.metadata.name}}" 21 | } 22 | }, 23 | "template": { 24 | "metadata": { 25 | "labels": { 26 | "app": "child-of-{{$.metadata.name}}" 27 | } 28 | }, 29 | "spec": { 30 | "containers": [ 31 | { 32 | "name": "child-of-{{$.metadata.name}}", 33 | "image": "{{$.spec.image}}" 34 | } 35 | ] 36 | } 37 | } 38 | } 39 | }); 40 | MATCH (d:Deployment {name: "child-of-{{$.metadata.name}}"}) 41 | CREATE (d)->(s:Service); 42 | onDelete: | 43 | MATCH (d:Deployment {name: "child-of-{{$.metadata.name}}"})->(s:Service) 44 | DELETE d, s; 45 | -------------------------------------------------------------------------------- /operator/test/e2e/samples/dynamicoperator-ingressactivator.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cyphernetes-operator.cyphernet.es/v1 2 | kind: DynamicOperator 3 | metadata: 4 | name: ingress-activator-operator 5 | namespace: default 6 | spec: 7 | resourceKind: deployments 8 | namespace: default 9 | onUpdate: | 10 | MATCH (d:Deployment {name: "{{$.metadata.name}}"})->(s:Service)->(i:Ingress) 11 | WHERE d.spec.replicas = 0 12 | SET i.spec.ingressClassName = "inactive"; 13 | MATCH (d:Deployment {name: "{{$.metadata.name}}"})->(s:Service)->(i:Ingress) 14 | WHERE d.spec.replicas > 0 15 | SET i.spec.ingressClassName = "active"; 16 | -------------------------------------------------------------------------------- /operator/test/e2e/samples/exposeddeployments-sample-exposeddeployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cyphernet.es/v1 2 | kind: ExposedDeployment 3 | metadata: 4 | name: sample-exposeddeployment 5 | namespace: default 6 | spec: 7 | image: nginx -------------------------------------------------------------------------------- /operator/test/utils/utils.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utils 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | "os/exec" 23 | "strings" 24 | 25 | . "github.com/onsi/ginkgo/v2" //nolint:golint,revive 26 | ) 27 | 28 | const ( 29 | prometheusOperatorVersion = "v0.72.0" 30 | prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" + 31 | "releases/download/%s/bundle.yaml" 32 | 33 | certmanagerVersion = "v1.14.4" 34 | certmanagerURLTmpl = "https://github.com/jetstack/cert-manager/releases/download/%s/cert-manager.yaml" 35 | ) 36 | 37 | func warnError(err error) { 38 | _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) 39 | } 40 | 41 | // InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. 42 | func InstallPrometheusOperator() error { 43 | url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) 44 | cmd := exec.Command("kubectl", "create", "-f", url) 45 | _, err := Run(cmd) 46 | return err 47 | } 48 | 49 | // Run executes the provided command within this context 50 | func Run(cmd *exec.Cmd) ([]byte, error) { 51 | dir, _ := GetProjectDir() 52 | cmd.Dir = dir 53 | 54 | if err := os.Chdir(cmd.Dir); err != nil { 55 | _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) 56 | } 57 | 58 | cmd.Env = append(os.Environ(), "GO111MODULE=on") 59 | command := strings.Join(cmd.Args, " ") 60 | _, _ = fmt.Fprintf(GinkgoWriter, "running: %s\n", command) 61 | output, err := cmd.CombinedOutput() 62 | if err != nil { 63 | return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) 64 | } 65 | 66 | return output, nil 67 | } 68 | 69 | // UninstallPrometheusOperator uninstalls the prometheus 70 | func UninstallPrometheusOperator() { 71 | url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) 72 | cmd := exec.Command("kubectl", "delete", "-f", url) 73 | if _, err := Run(cmd); err != nil { 74 | warnError(err) 75 | } 76 | } 77 | 78 | // UninstallCertManager uninstalls the cert manager 79 | func UninstallCertManager() { 80 | url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) 81 | cmd := exec.Command("kubectl", "delete", "-f", url) 82 | if _, err := Run(cmd); err != nil { 83 | warnError(err) 84 | } 85 | } 86 | 87 | // InstallCertManager installs the cert manager bundle. 88 | func InstallCertManager() error { 89 | url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) 90 | cmd := exec.Command("kubectl", "apply", "-f", url) 91 | if _, err := Run(cmd); err != nil { 92 | return err 93 | } 94 | // Wait for cert-manager-webhook to be ready, which can take time if cert-manager 95 | // was re-installed after uninstalling on a cluster. 96 | cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", 97 | "--for", "condition=Available", 98 | "--namespace", "cert-manager", 99 | "--timeout", "5m", 100 | ) 101 | 102 | _, err := Run(cmd) 103 | return err 104 | } 105 | 106 | // LoadImageToKindClusterWithName loads a local docker image to the kind cluster 107 | func LoadImageToKindClusterWithName(name string) error { 108 | cluster := "kind" 109 | if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { 110 | cluster = v 111 | } 112 | kindOptions := []string{"load", "docker-image", name, "--name", cluster} 113 | cmd := exec.Command("kind", kindOptions...) 114 | _, err := Run(cmd) 115 | return err 116 | } 117 | 118 | // GetNonEmptyLines converts given command output string into individual objects 119 | // according to line breakers, and ignores the empty elements in it. 120 | func GetNonEmptyLines(output string) []string { 121 | var res []string 122 | elements := strings.Split(output, "\n") 123 | for _, element := range elements { 124 | if element != "" { 125 | res = append(res, element) 126 | } 127 | } 128 | 129 | return res 130 | } 131 | 132 | // GetProjectDir will return the directory where the project is 133 | func GetProjectDir() (string, error) { 134 | wd, err := os.Getwd() 135 | if err != nil { 136 | return wd, err 137 | } 138 | wd = strings.Replace(wd, "/test/e2e", "", -1) 139 | return wd, nil 140 | } 141 | -------------------------------------------------------------------------------- /pkg/core/cache.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/avitaltamir/cyphernetes/pkg/provider" 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | ) 9 | 10 | func InitGVRCache(p provider.Provider) error { 11 | if GvrCache == nil { 12 | GvrCache = make(map[string]schema.GroupVersionResource) 13 | } 14 | 15 | // Let the provider handle caching internally 16 | // We'll just initialize an empty cache 17 | return nil 18 | } 19 | 20 | func InitResourceSpecs(p provider.Provider) error { 21 | if ResourceSpecs == nil { 22 | ResourceSpecs = make(map[string][]string) 23 | } 24 | 25 | debugLog("Getting OpenAPI resource specs...") 26 | specs, err := p.GetOpenAPIResourceSpecs() 27 | if err != nil { 28 | return fmt.Errorf("error getting resource specs: %w", err) 29 | } 30 | 31 | debugLog("Got specs for %d resources", len(specs)) 32 | ResourceSpecs = specs 33 | 34 | return nil 35 | } 36 | 37 | func (q *QueryExecutor) resourcePropertyName(n *NodePattern) (string, error) { 38 | var ns string 39 | 40 | gvr, err := q.provider.FindGVR(n.ResourceProperties.Kind) 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | if n.ResourceProperties.Properties == nil { 46 | return fmt.Sprintf("%s_%s", Namespace, gvr.Resource), nil 47 | } 48 | 49 | for _, prop := range n.ResourceProperties.Properties.PropertyList { 50 | if prop.Key == "namespace" || prop.Key == "metadata.namespace" { 51 | ns = prop.Value.(string) 52 | break 53 | } 54 | } 55 | 56 | if ns == "" { 57 | ns = Namespace 58 | } 59 | 60 | return fmt.Sprintf("%s_%s", ns, gvr.Resource), nil 61 | } 62 | 63 | func (q *QueryExecutor) GetOpenAPIResourceSpecs() (map[string][]string, error) { 64 | specs, err := q.provider.GetOpenAPIResourceSpecs() 65 | if err != nil { 66 | return nil, fmt.Errorf("error getting OpenAPI resource specs: %w", err) 67 | } 68 | return specs, nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/core/e2e/aggregation_query_operations_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | appsv1 "k8s.io/api/apps/v1" 9 | corev1 "k8s.io/api/core/v1" 10 | "k8s.io/apimachinery/pkg/api/resource" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/util/intstr" 13 | "k8s.io/utils/ptr" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | 16 | "github.com/avitaltamir/cyphernetes/pkg/core" 17 | "github.com/avitaltamir/cyphernetes/pkg/provider/apiserver" 18 | ) 19 | 20 | var _ = Describe("Aggregation Query Operations", func() { 21 | var ctx context.Context 22 | 23 | BeforeEach(func() { 24 | ctx = context.Background() 25 | }) 26 | 27 | It("Should calculate resource totals correctly", func() { 28 | By("Creating test resources") 29 | testDeployment := &appsv1.Deployment{ 30 | ObjectMeta: metav1.ObjectMeta{ 31 | Name: "test-deployment-8", 32 | Namespace: testNamespace, 33 | Labels: map[string]string{ 34 | "app": "test-agg", 35 | }, 36 | }, 37 | Spec: appsv1.DeploymentSpec{ 38 | Replicas: ptr.To(int32(3)), 39 | Selector: &metav1.LabelSelector{ 40 | MatchLabels: map[string]string{ 41 | "app": "test-agg", 42 | }, 43 | }, 44 | Template: corev1.PodTemplateSpec{ 45 | ObjectMeta: metav1.ObjectMeta{ 46 | Labels: map[string]string{ 47 | "app": "test-agg", 48 | }, 49 | }, 50 | Spec: corev1.PodSpec{ 51 | Containers: []corev1.Container{ 52 | { 53 | Name: "nginx", 54 | Image: "nginx:1.19", 55 | Resources: corev1.ResourceRequirements{ 56 | Requests: corev1.ResourceList{ 57 | corev1.ResourceCPU: resource.MustParse("100m"), 58 | corev1.ResourceMemory: resource.MustParse("128Mi"), 59 | }, 60 | }, 61 | }, 62 | { 63 | Name: "sidecar", 64 | Image: "busybox:1.32", 65 | Resources: corev1.ResourceRequirements{ 66 | Requests: corev1.ResourceList{ 67 | corev1.ResourceCPU: resource.MustParse("50m"), 68 | corev1.ResourceMemory: resource.MustParse("64Mi"), 69 | }, 70 | }, 71 | }, 72 | }, 73 | }, 74 | }, 75 | }, 76 | } 77 | Expect(k8sClient.Create(ctx, testDeployment)).Should(Succeed()) 78 | 79 | testService := &corev1.Service{ 80 | ObjectMeta: metav1.ObjectMeta{ 81 | Name: "test-service", 82 | Namespace: testNamespace, 83 | Labels: map[string]string{ 84 | "app": "test-agg", 85 | }, 86 | }, 87 | Spec: corev1.ServiceSpec{ 88 | Selector: map[string]string{ 89 | "app": "test-agg", 90 | }, 91 | Ports: []corev1.ServicePort{ 92 | { 93 | Port: 80, 94 | TargetPort: intstr.FromInt(80), 95 | }, 96 | }, 97 | }, 98 | } 99 | Expect(k8sClient.Create(ctx, testService)).Should(Succeed()) 100 | 101 | By("Waiting for deployment to be created") 102 | Eventually(func() error { 103 | return k8sClient.Get(ctx, client.ObjectKey{ 104 | Namespace: testNamespace, 105 | Name: "test-deployment-8", 106 | }, &appsv1.Deployment{}) 107 | }, timeout, interval).Should(Succeed()) 108 | 109 | By("Executing aggregation query") 110 | provider, err := apiserver.NewAPIServerProvider() 111 | Expect(err).NotTo(HaveOccurred()) 112 | 113 | executor, err := core.NewQueryExecutor(provider) 114 | Expect(err).NotTo(HaveOccurred()) 115 | 116 | ast, err := core.ParseQuery(` 117 | MATCH (d:Deployment) 118 | WHERE d.metadata.name = "test-deployment-8" 119 | RETURN SUM{d.spec.template.spec.containers[*].resources.requests.cpu} AS totalCPUReq, 120 | SUM{d.spec.template.spec.containers[*].resources.requests.memory} AS totalMemReq 121 | `) 122 | Expect(err).NotTo(HaveOccurred()) 123 | 124 | result, err := executor.Execute(ast, testNamespace) 125 | Expect(err).NotTo(HaveOccurred()) 126 | 127 | By("Verifying the aggregation results") 128 | Expect(result.Data).To(HaveKey("aggregate")) 129 | aggregateData, ok := result.Data["aggregate"].(map[string]interface{}) 130 | Expect(ok).To(BeTrue(), "Expected aggregate to be a map") 131 | 132 | Expect(aggregateData).To(HaveKey("totalCPUReq")) 133 | cpuReqs, ok := aggregateData["totalCPUReq"].([]interface{}) 134 | Expect(ok).To(BeTrue(), "Expected totalCPUReq to be a slice") 135 | Expect(cpuReqs).To(ConsistOf("100m", "50m")) 136 | 137 | Expect(aggregateData).To(HaveKey("totalMemReq")) 138 | memReqs, ok := aggregateData["totalMemReq"].([]interface{}) 139 | Expect(ok).To(BeTrue(), "Expected totalMemReq to be a slice") 140 | Expect(memReqs).To(ConsistOf("128Mi", "64Mi")) 141 | 142 | By("Cleaning up") 143 | Expect(k8sClient.Delete(ctx, testDeployment)).Should(Succeed()) 144 | Expect(k8sClient.Delete(ctx, testService)).Should(Succeed()) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /pkg/core/e2e/complex_query_operations_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | appsv1 "k8s.io/api/apps/v1" 10 | corev1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/utils/ptr" 13 | 14 | "github.com/avitaltamir/cyphernetes/pkg/core" 15 | "github.com/avitaltamir/cyphernetes/pkg/provider/apiserver" 16 | ) 17 | 18 | var _ = Describe("Complex Query Operations", func() { 19 | var ctx context.Context 20 | 21 | BeforeEach(func() { 22 | ctx = context.Background() 23 | }) 24 | 25 | It("Should retrieve deployment information correctly", func() { 26 | By("Creating test resources") 27 | testDeployment := &appsv1.Deployment{ 28 | ObjectMeta: metav1.ObjectMeta{ 29 | Name: "test-deployment-7", 30 | Namespace: testNamespace, 31 | Labels: map[string]string{ 32 | "app": "test", 33 | "env": "staging", 34 | }, 35 | }, 36 | Spec: appsv1.DeploymentSpec{ 37 | Replicas: ptr.To(int32(2)), 38 | Selector: &metav1.LabelSelector{ 39 | MatchLabels: map[string]string{ 40 | "app": "test", 41 | }, 42 | }, 43 | Template: corev1.PodTemplateSpec{ 44 | ObjectMeta: metav1.ObjectMeta{ 45 | Labels: map[string]string{ 46 | "app": "test", 47 | }, 48 | }, 49 | Spec: corev1.PodSpec{ 50 | Containers: []corev1.Container{ 51 | { 52 | Name: "nginx", 53 | Image: "nginx:1.19", 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | } 60 | Expect(k8sClient.Create(ctx, testDeployment)).Should(Succeed()) 61 | 62 | By("Executing a MATCH query to retrieve deployment information") 63 | provider, err := apiserver.NewAPIServerProvider() 64 | Expect(err).NotTo(HaveOccurred()) 65 | 66 | executor, err := core.NewQueryExecutor(provider) 67 | Expect(err).NotTo(HaveOccurred()) 68 | 69 | ast, err := core.ParseQuery(` 70 | MATCH (d:Deployment) 71 | WHERE d.metadata.labels.env = "staging" 72 | RETURN d.metadata.name, d.spec.replicas, d.metadata.labels.app 73 | `) 74 | Expect(err).NotTo(HaveOccurred()) 75 | 76 | result, err := executor.Execute(ast, testNamespace) 77 | Expect(err).NotTo(HaveOccurred()) 78 | 79 | By("Verifying the retrieved information") 80 | Expect(result.Data).To(HaveKey("d")) 81 | deployments, ok := result.Data["d"].([]interface{}) 82 | Expect(ok).To(BeTrue(), "Expected result.Data['d'] to be a slice") 83 | Expect(deployments).NotTo(BeEmpty(), "Expected at least one deployment") 84 | 85 | deploymentInfo, ok := deployments[0].(map[string]interface{}) 86 | Expect(ok).To(BeTrue(), "Expected deployment info to be a map") 87 | 88 | metadata, ok := deploymentInfo["metadata"].(map[string]interface{}) 89 | Expect(ok).To(BeTrue(), "Expected metadata to be a map") 90 | Expect(metadata["name"]).To(Equal("test-deployment-7")) 91 | 92 | spec, ok := deploymentInfo["spec"].(map[string]interface{}) 93 | Expect(ok).To(BeTrue(), "Expected spec to be a map") 94 | 95 | var replicas int64 96 | switch r := spec["replicas"].(type) { 97 | case float64: 98 | replicas = int64(r) 99 | case int64: 100 | replicas = r 101 | default: 102 | Fail(fmt.Sprintf("Unexpected type for replicas: %T", spec["replicas"])) 103 | } 104 | Expect(replicas).To(Equal(int64(2))) 105 | 106 | labels, ok := metadata["labels"].(map[string]interface{}) 107 | Expect(ok).To(BeTrue(), "Expected labels to be a map") 108 | Expect(labels["app"]).To(Equal("test")) 109 | 110 | By("Cleaning up") 111 | Expect(k8sClient.Delete(ctx, testDeployment)).Should(Succeed()) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /pkg/core/e2e/container_resource_update_operations_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | appsv1 "k8s.io/api/apps/v1" 9 | corev1 "k8s.io/api/core/v1" 10 | "k8s.io/apimachinery/pkg/api/resource" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | 14 | "github.com/avitaltamir/cyphernetes/pkg/core" 15 | "github.com/avitaltamir/cyphernetes/pkg/provider/apiserver" 16 | ) 17 | 18 | var _ = Describe("Container Resource Update Operations", func() { 19 | var ctx context.Context 20 | 21 | BeforeEach(func() { 22 | ctx = context.Background() 23 | }) 24 | 25 | It("Should update container resource limits correctly", func() { 26 | By("Creating test resources") 27 | testDeployment := &appsv1.Deployment{ 28 | ObjectMeta: metav1.ObjectMeta{ 29 | Name: "test-deployment-5", 30 | Namespace: testNamespace, 31 | Labels: map[string]string{ 32 | "app": "test", 33 | }, 34 | }, 35 | Spec: appsv1.DeploymentSpec{ 36 | Selector: &metav1.LabelSelector{ 37 | MatchLabels: map[string]string{ 38 | "app": "test", 39 | }, 40 | }, 41 | Template: corev1.PodTemplateSpec{ 42 | ObjectMeta: metav1.ObjectMeta{ 43 | Labels: map[string]string{ 44 | "app": "test", 45 | }, 46 | }, 47 | Spec: corev1.PodSpec{ 48 | Containers: []corev1.Container{ 49 | { 50 | Name: "nginx", 51 | Image: "nginx:1.19", 52 | Resources: corev1.ResourceRequirements{ 53 | Limits: corev1.ResourceList{ 54 | corev1.ResourceCPU: resource.MustParse("100m"), 55 | corev1.ResourceMemory: resource.MustParse("128Mi"), 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | } 64 | Expect(k8sClient.Create(ctx, testDeployment)).Should(Succeed()) 65 | 66 | By("Executing a SET query to update container resources") 67 | provider, err := apiserver.NewAPIServerProvider() 68 | Expect(err).NotTo(HaveOccurred()) 69 | 70 | executor, err := core.NewQueryExecutor(provider) 71 | Expect(err).NotTo(HaveOccurred()) 72 | 73 | ast, err := core.ParseQuery(` 74 | MATCH (d:Deployment {name: "test-deployment-5"}) 75 | SET d.spec.template.spec.containers[0].resources.limits.cpu = "200m", 76 | d.spec.template.spec.containers[0].resources.limits.memory = "256Mi" 77 | RETURN d 78 | `) 79 | Expect(err).NotTo(HaveOccurred()) 80 | 81 | _, err = executor.Execute(ast, testNamespace) 82 | Expect(err).NotTo(HaveOccurred()) 83 | 84 | By("Verifying the resource updates in the cluster") 85 | var updatedDeployment appsv1.Deployment 86 | 87 | Eventually(func() string { 88 | err := k8sClient.Get(ctx, client.ObjectKey{ 89 | Namespace: testNamespace, 90 | Name: "test-deployment-5", 91 | }, &updatedDeployment) 92 | if err != nil { 93 | return "" 94 | } 95 | return updatedDeployment.Spec.Template.Spec.Containers[0].Resources.Limits.Cpu().String() 96 | }, timeout*4, interval).Should(Equal("200m")) 97 | 98 | Eventually(func() string { 99 | err := k8sClient.Get(ctx, client.ObjectKey{ 100 | Namespace: testNamespace, 101 | Name: "test-deployment-5", 102 | }, &updatedDeployment) 103 | if err != nil { 104 | return "" 105 | } 106 | return updatedDeployment.Spec.Template.Spec.Containers[0].Resources.Limits.Memory().String() 107 | }, timeout*4, interval).Should(Equal("256Mi")) 108 | 109 | By("Cleaning up") 110 | Expect(k8sClient.Delete(ctx, testDeployment)).Should(Succeed()) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /pkg/core/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | corev1 "k8s.io/api/core/v1" 11 | apierrors "k8s.io/apimachinery/pkg/api/errors" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/client-go/kubernetes/scheme" 14 | "k8s.io/client-go/rest" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | "sigs.k8s.io/controller-runtime/pkg/envtest" 17 | logf "sigs.k8s.io/controller-runtime/pkg/log" 18 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 19 | ) 20 | 21 | var k8sClient client.Client 22 | var testEnv *envtest.Environment 23 | var cfg *rest.Config 24 | var testNamespace string 25 | 26 | const ( 27 | timeout = time.Second * 10 28 | interval = time.Millisecond * 250 29 | ) 30 | 31 | func TestE2E(t *testing.T) { 32 | RegisterFailHandler(Fail) 33 | RunSpecs(t, "Cyphernetes E2E Suite") 34 | } 35 | 36 | var _ = BeforeSuite(func() { 37 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 38 | 39 | By("bootstrapping test environment") 40 | testNamespace = "cyphernetes-test" 41 | 42 | useExistingCluster := true 43 | testEnv = &envtest.Environment{ 44 | UseExistingCluster: &useExistingCluster, 45 | } 46 | 47 | var err error 48 | cfg, err = testEnv.Start() 49 | Expect(err).NotTo(HaveOccurred()) 50 | Expect(cfg).NotTo(BeNil()) 51 | 52 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 53 | Expect(err).NotTo(HaveOccurred()) 54 | Expect(k8sClient).NotTo(BeNil()) 55 | 56 | // Create namespace after client is initialized 57 | By("Creating test namespace") 58 | ns := &corev1.Namespace{ 59 | ObjectMeta: metav1.ObjectMeta{ 60 | Name: testNamespace, 61 | }, 62 | } 63 | err = k8sClient.Create(context.Background(), ns) 64 | if err != nil && !apierrors.IsAlreadyExists(err) { 65 | Expect(err).NotTo(HaveOccurred()) 66 | } 67 | }) 68 | 69 | var _ = AfterSuite(func() { 70 | By("tearing down the test environment") 71 | 72 | // Delete namespace before stopping the environment 73 | By("Deleting test namespace") 74 | ns := &corev1.Namespace{ 75 | ObjectMeta: metav1.ObjectMeta{ 76 | Name: testNamespace, 77 | }, 78 | } 79 | err := k8sClient.Delete(context.Background(), ns) 80 | if err != nil && !apierrors.IsNotFound(err) { 81 | Expect(err).NotTo(HaveOccurred()) 82 | } 83 | 84 | // Wait for namespace deletion 85 | Eventually(func() bool { 86 | err := k8sClient.Get(context.Background(), client.ObjectKey{Name: testNamespace}, &corev1.Namespace{}) 87 | return apierrors.IsNotFound(err) 88 | }, timeout*6, interval).Should(BeTrue()) 89 | 90 | err = testEnv.Stop() 91 | Expect(err).NotTo(HaveOccurred()) 92 | }) 93 | -------------------------------------------------------------------------------- /pkg/core/e2e/multi_container_update_operations_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | appsv1 "k8s.io/api/apps/v1" 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | 13 | "github.com/avitaltamir/cyphernetes/pkg/core" 14 | "github.com/avitaltamir/cyphernetes/pkg/provider/apiserver" 15 | ) 16 | 17 | var _ = Describe("Multi-Container Update Operations", func() { 18 | var ctx context.Context 19 | 20 | BeforeEach(func() { 21 | ctx = context.Background() 22 | }) 23 | 24 | It("Should update the image of a specific container correctly", func() { 25 | By("Creating test resources") 26 | testDeployment := &appsv1.Deployment{ 27 | ObjectMeta: metav1.ObjectMeta{ 28 | Name: "test-deployment-6", 29 | Namespace: testNamespace, 30 | Labels: map[string]string{ 31 | "app": "test", 32 | }, 33 | }, 34 | Spec: appsv1.DeploymentSpec{ 35 | Selector: &metav1.LabelSelector{ 36 | MatchLabels: map[string]string{ 37 | "app": "test", 38 | }, 39 | }, 40 | Template: corev1.PodTemplateSpec{ 41 | ObjectMeta: metav1.ObjectMeta{ 42 | Labels: map[string]string{ 43 | "app": "test", 44 | }, 45 | }, 46 | Spec: corev1.PodSpec{ 47 | Containers: []corev1.Container{ 48 | { 49 | Name: "nginx", 50 | Image: "nginx:1.19", 51 | }, 52 | { 53 | Name: "busybox", 54 | Image: "busybox:1.32", 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | } 61 | Expect(k8sClient.Create(ctx, testDeployment)).Should(Succeed()) 62 | 63 | By("Executing a SET query to update the image of the busybox container") 64 | provider, err := apiserver.NewAPIServerProvider() 65 | Expect(err).NotTo(HaveOccurred()) 66 | 67 | executor, err := core.NewQueryExecutor(provider) 68 | Expect(err).NotTo(HaveOccurred()) 69 | 70 | ast, err := core.ParseQuery(` 71 | MATCH (d:Deployment {name: "test-deployment-6"}) 72 | SET d.spec.template.spec.containers[1].image = "busybox:1.33" 73 | RETURN d 74 | `) 75 | Expect(err).NotTo(HaveOccurred()) 76 | 77 | _, err = executor.Execute(ast, testNamespace) 78 | Expect(err).NotTo(HaveOccurred()) 79 | 80 | By("Verifying the image update in the cluster") 81 | var updatedDeployment appsv1.Deployment 82 | 83 | Eventually(func() string { 84 | err := k8sClient.Get(ctx, client.ObjectKey{ 85 | Namespace: testNamespace, 86 | Name: "test-deployment-6", 87 | }, &updatedDeployment) 88 | if err != nil { 89 | return "" 90 | } 91 | return updatedDeployment.Spec.Template.Spec.Containers[1].Image 92 | }, timeout*4, interval).Should(Equal("busybox:1.33")) 93 | 94 | By("Cleaning up") 95 | Expect(k8sClient.Delete(ctx, testDeployment)).Should(Succeed()) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /pkg/core/e2e/multiple_field_update_operations_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | appsv1 "k8s.io/api/apps/v1" 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/utils/ptr" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | 14 | "github.com/avitaltamir/cyphernetes/pkg/core" 15 | "github.com/avitaltamir/cyphernetes/pkg/provider/apiserver" 16 | ) 17 | 18 | var _ = Describe("Multiple Field Update Operations", func() { 19 | var ctx context.Context 20 | 21 | BeforeEach(func() { 22 | ctx = context.Background() 23 | }) 24 | 25 | It("Should update multiple fields in a deployment correctly", func() { 26 | By("Creating test resources") 27 | testDeployment := &appsv1.Deployment{ 28 | ObjectMeta: metav1.ObjectMeta{ 29 | Name: "test-deployment-4", 30 | Namespace: testNamespace, 31 | Labels: map[string]string{ 32 | "app": "test", 33 | }, 34 | }, 35 | Spec: appsv1.DeploymentSpec{ 36 | Replicas: ptr.To(int32(1)), 37 | Selector: &metav1.LabelSelector{ 38 | MatchLabels: map[string]string{ 39 | "app": "test", 40 | }, 41 | }, 42 | Template: corev1.PodTemplateSpec{ 43 | ObjectMeta: metav1.ObjectMeta{ 44 | Labels: map[string]string{ 45 | "app": "test", 46 | }, 47 | }, 48 | Spec: corev1.PodSpec{ 49 | Containers: []corev1.Container{ 50 | { 51 | Name: "nginx", 52 | Image: "nginx:1.19", 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | } 59 | Expect(k8sClient.Create(ctx, testDeployment)).Should(Succeed()) 60 | 61 | By("Executing a SET query to update multiple fields") 62 | provider, err := apiserver.NewAPIServerProvider() 63 | Expect(err).NotTo(HaveOccurred()) 64 | 65 | executor, err := core.NewQueryExecutor(provider) 66 | Expect(err).NotTo(HaveOccurred()) 67 | 68 | ast, err := core.ParseQuery(` 69 | MATCH (d:Deployment {name: "test-deployment-4"}) 70 | SET d.metadata.labels.environment = "production", 71 | d.spec.replicas = 3 72 | RETURN d 73 | `) 74 | Expect(err).NotTo(HaveOccurred()) 75 | 76 | _, err = executor.Execute(ast, testNamespace) 77 | Expect(err).NotTo(HaveOccurred()) 78 | 79 | By("Verifying the updates in the cluster") 80 | var updatedDeployment appsv1.Deployment 81 | 82 | Eventually(func() int32 { 83 | err := k8sClient.Get(ctx, client.ObjectKey{ 84 | Namespace: testNamespace, 85 | Name: "test-deployment-4", 86 | }, &updatedDeployment) 87 | if err != nil { 88 | return 0 89 | } 90 | return *updatedDeployment.Spec.Replicas 91 | }, timeout*4, interval).Should(Equal(int32(3))) 92 | 93 | Eventually(func() string { 94 | err := k8sClient.Get(ctx, client.ObjectKey{ 95 | Namespace: testNamespace, 96 | Name: "test-deployment-4", 97 | }, &updatedDeployment) 98 | if err != nil { 99 | return "" 100 | } 101 | return updatedDeployment.Labels["environment"] 102 | }, timeout*4, interval).Should(Equal("production")) 103 | 104 | By("Cleaning up") 105 | Expect(k8sClient.Delete(ctx, testDeployment)).Should(Succeed()) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /pkg/core/e2e/resource_creation_operations_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | appsv1 "k8s.io/api/apps/v1" 10 | corev1 "k8s.io/api/core/v1" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | 13 | "github.com/avitaltamir/cyphernetes/pkg/core" 14 | "github.com/avitaltamir/cyphernetes/pkg/provider/apiserver" 15 | ) 16 | 17 | var _ = Describe("Resource Creation Operations", func() { 18 | var ctx context.Context 19 | 20 | BeforeEach(func() { 21 | ctx = context.Background() 22 | }) 23 | 24 | It("Should create resources with complex JSON", func() { 25 | By("Creating a deployment with complex JSON") 26 | provider, err := apiserver.NewAPIServerProvider() 27 | Expect(err).NotTo(HaveOccurred()) 28 | 29 | executor, err := core.NewQueryExecutor(provider) 30 | Expect(err).NotTo(HaveOccurred()) 31 | 32 | query := `CREATE (d:Deployment { 33 | "metadata": { 34 | "name": "test-deployment-json", 35 | "namespace": "` + testNamespace + `", 36 | "labels": { 37 | "app": "test-json" 38 | } 39 | }, 40 | "spec": { 41 | "selector": { 42 | "matchLabels": { 43 | "app": "test-json" 44 | } 45 | }, 46 | "template": { 47 | "metadata": { 48 | "labels": { 49 | "app": "test-json" 50 | } 51 | }, 52 | "spec": { 53 | "containers": [ 54 | { 55 | "name": "nginx", 56 | "image": "nginx:latest" 57 | } 58 | ] 59 | } 60 | } 61 | } 62 | })` 63 | 64 | ast, err := core.ParseQuery(query) 65 | Expect(err).NotTo(HaveOccurred()) 66 | 67 | _, err = executor.Execute(ast, testNamespace) 68 | Expect(err).NotTo(HaveOccurred()) 69 | 70 | By("Verifying the deployment was created correctly") 71 | var deployment appsv1.Deployment 72 | Eventually(func() error { 73 | return k8sClient.Get(ctx, client.ObjectKey{ 74 | Namespace: testNamespace, 75 | Name: "test-deployment-json", 76 | }, &deployment) 77 | }, timeout, interval).Should(Succeed()) 78 | 79 | // Verify deployment properties 80 | Expect(deployment.ObjectMeta.Labels["app"]).To(Equal("test-json")) 81 | Expect(deployment.Spec.Template.Labels["app"]).To(Equal("test-json")) 82 | Expect(deployment.Spec.Selector.MatchLabels["app"]).To(Equal("test-json")) 83 | 84 | containers := deployment.Spec.Template.Spec.Containers 85 | Expect(containers).To(HaveLen(1)) 86 | Expect(containers[0].Name).To(Equal("nginx")) 87 | Expect(containers[0].Image).To(Equal("nginx:latest")) 88 | 89 | By("Cleaning up") 90 | Expect(k8sClient.Delete(ctx, &deployment)).Should(Succeed()) 91 | }) 92 | 93 | It("Should create ConfigMap with complex JSON", func() { 94 | By("Creating a ConfigMap with complex JSON") 95 | provider, err := apiserver.NewAPIServerProvider() 96 | Expect(err).NotTo(HaveOccurred()) 97 | 98 | executor, err := core.NewQueryExecutor(provider) 99 | Expect(err).NotTo(HaveOccurred()) 100 | 101 | query := `CREATE (c:ConfigMap { 102 | "metadata": { 103 | "name": "test-configmap-json", 104 | "namespace": "` + testNamespace + `", 105 | "labels": { 106 | "app": "test-json", 107 | "type": "config" 108 | } 109 | }, 110 | "data": { 111 | "config.json": "{\"database\":{\"host\":\"localhost\",\"port\":5432}}", 112 | "settings.yaml": "server:\n port: 8080\n host: 0.0.0.0", 113 | "feature-flags": "ENABLE_CACHE=true\nDEBUG_MODE=false" 114 | } 115 | })` 116 | 117 | ast, err := core.ParseQuery(query) 118 | Expect(err).NotTo(HaveOccurred()) 119 | 120 | _, err = executor.Execute(ast, testNamespace) 121 | Expect(err).NotTo(HaveOccurred()) 122 | 123 | By("Verifying the ConfigMap was created correctly") 124 | var configMap corev1.ConfigMap 125 | Eventually(func() error { 126 | return k8sClient.Get(ctx, client.ObjectKey{ 127 | Namespace: testNamespace, 128 | Name: "test-configmap-json", 129 | }, &configMap) 130 | }, timeout, interval).Should(Succeed()) 131 | 132 | // Verify ConfigMap properties 133 | Expect(configMap.ObjectMeta.Labels["app"]).To(Equal("test-json")) 134 | Expect(configMap.ObjectMeta.Labels["type"]).To(Equal("config")) 135 | 136 | // Verify data fields 137 | Expect(configMap.Data).To(HaveKey("config.json")) 138 | Expect(configMap.Data).To(HaveKey("settings.yaml")) 139 | Expect(configMap.Data).To(HaveKey("feature-flags")) 140 | 141 | // Verify specific data content 142 | var configJSON map[string]interface{} 143 | err = json.Unmarshal([]byte(configMap.Data["config.json"]), &configJSON) 144 | Expect(err).NotTo(HaveOccurred()) 145 | Expect(configJSON).To(HaveKey("database")) 146 | 147 | By("Cleaning up") 148 | Expect(k8sClient.Delete(ctx, &configMap)).Should(Succeed()) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /pkg/core/graph.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "fmt" 4 | 5 | func (q *QueryExecutor) buildGraph(result *QueryResult) { 6 | debugLog(fmt.Sprintln("Building graph")) 7 | debugLog(fmt.Sprintf("Initial nodes: %+v\n", result.Graph.Nodes)) 8 | 9 | // Process nodes from result data 10 | for key, resources := range result.Data { 11 | resourcesSlice, ok := resources.([]interface{}) 12 | if !ok || len(resourcesSlice) == 0 { 13 | continue 14 | } 15 | for _, resource := range resourcesSlice { 16 | resourceMap, ok := resource.(map[string]interface{}) 17 | if !ok { 18 | continue 19 | } 20 | metadata, ok := resourceMap["metadata"].(map[string]interface{}) 21 | if !ok { 22 | continue 23 | } 24 | name, ok := metadata["name"].(string) 25 | if !ok { 26 | continue 27 | } 28 | kind, ok := resourceMap["kind"].(string) 29 | if !ok { 30 | continue 31 | } 32 | node := Node{ 33 | Id: key, 34 | Kind: kind, 35 | Name: name, 36 | } 37 | if node.Kind != "Namespace" { 38 | node.Namespace = getNamespaceName(metadata) 39 | } 40 | debugLog(fmt.Sprintf("Adding node from result data: %+v\n", node)) 41 | result.Graph.Nodes = append(result.Graph.Nodes, node) 42 | } 43 | } 44 | 45 | // Process edges 46 | var edges []Edge 47 | edgeMap := make(map[string]bool) 48 | for _, edge := range result.Graph.Edges { 49 | edgeKey := fmt.Sprintf("%s-%s-%s", edge.From, edge.To, edge.Type) 50 | reverseEdgeKey := fmt.Sprintf("%s-%s-%s", edge.To, edge.From, edge.Type) 51 | 52 | if !edgeMap[edgeKey] && !edgeMap[reverseEdgeKey] { 53 | edges = append(edges, edge) 54 | edgeMap[edgeKey] = true 55 | edgeMap[reverseEdgeKey] = true 56 | } 57 | } 58 | result.Graph.Edges = edges 59 | } 60 | 61 | func getNamespaceName(metadata map[string]interface{}) string { 62 | namespace, ok := metadata["namespace"].(string) 63 | if !ok { 64 | namespace = "default" 65 | } 66 | return namespace 67 | } 68 | 69 | func getTargetK8sResourceName(resourceTemplate map[string]interface{}, resourceName string, foreignName string) string { 70 | // We'll use these in order of preference: 71 | // 1. The .name or .metadata.name specified in the resource template 72 | // 2. The name of the kubernetes resource represented by the foreign node 73 | // 3. The name of the node 74 | name := "" 75 | if foreignName != "" { 76 | name = foreignName 77 | } else if resourceTemplate["name"] != nil { 78 | name = resourceTemplate["name"].(string) 79 | } else if resourceTemplate["metadata"] != nil && resourceTemplate["metadata"].(map[string]interface{})["name"] != nil { 80 | name = resourceTemplate["metadata"].(map[string]interface{})["name"].(string) 81 | } else { 82 | name = resourceName 83 | } 84 | return name 85 | } 86 | -------------------------------------------------------------------------------- /pkg/core/relationship_match.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func matchByCriterion(resourceA, resourceB interface{}, criterion MatchCriterion) bool { 9 | switch criterion.ComparisonType { 10 | case ContainsAll: 11 | l, err := JsonPathCompileAndLookup(resourceA, strings.ReplaceAll(criterion.FieldA, "[]", "")) 12 | if err != nil { 13 | return false 14 | } 15 | labels, ok := l.(map[string]interface{}) 16 | if !ok { 17 | return false 18 | } 19 | 20 | s, err := JsonPathCompileAndLookup(resourceB, strings.ReplaceAll(criterion.FieldB, "[]", "")) 21 | if err != nil { 22 | return false 23 | } 24 | selector, ok := s.(map[string]interface{}) 25 | if !ok { 26 | return false 27 | } 28 | 29 | return matchContainsAll(labels, selector) 30 | 31 | case ExactMatch: 32 | // Extract the fields 33 | fieldsA, err := JsonPathCompileAndLookup(resourceA, strings.ReplaceAll(criterion.FieldA, "[]", "")) 34 | if err != nil { 35 | return false 36 | } 37 | fieldsB, err := JsonPathCompileAndLookup(resourceB, strings.ReplaceAll(criterion.FieldB, "[]", "")) 38 | if err != nil { 39 | return false 40 | } 41 | return matchFields(fieldsA, fieldsB) 42 | 43 | case StringContains: 44 | // Extract the fields 45 | fieldA, err := JsonPathCompileAndLookup(resourceA, strings.ReplaceAll(criterion.FieldA, "[]", "")) 46 | if err != nil { 47 | return false 48 | } 49 | fieldB, err := JsonPathCompileAndLookup(resourceB, strings.ReplaceAll(criterion.FieldB, "[]", "")) 50 | if err != nil { 51 | return false 52 | } 53 | 54 | // Convert both fields to strings 55 | strA := fmt.Sprintf("%v", fieldA) 56 | strB := fmt.Sprintf("%v", fieldB) 57 | 58 | // Check if fieldA contains fieldB 59 | return strings.Contains(strA, strB) 60 | } 61 | return false 62 | } 63 | 64 | func matchFields(fieldA, fieldB interface{}) bool { 65 | // if fieldA is a string, compare it to fieldB 66 | fieldAString, ok := fieldA.(string) 67 | if ok { 68 | fieldBString, ok := fieldB.(string) 69 | if ok { 70 | return fieldAString == fieldBString 71 | } 72 | return false 73 | } 74 | 75 | // if fieldA is a slice, flatten it and compare each element to fieldB 76 | fieldASlice, ok := fieldA.([]interface{}) 77 | if ok { 78 | // Flatten nested slices 79 | var flatSlice []interface{} 80 | for _, element := range fieldASlice { 81 | if nestedSlice, isSlice := element.([]interface{}); isSlice { 82 | flatSlice = append(flatSlice, nestedSlice...) 83 | } else { 84 | flatSlice = append(flatSlice, element) 85 | } 86 | } 87 | 88 | // Compare each element in the flattened slice 89 | for _, element := range flatSlice { 90 | if matchFields(element, fieldB) { 91 | return true 92 | } 93 | } 94 | return false 95 | } 96 | 97 | // if fieldA is a map, traverse it recursively 98 | fieldAMap, ok := fieldA.(map[string]interface{}) 99 | if ok { 100 | for _, value := range fieldAMap { 101 | if matchFields(value, fieldB) { 102 | return true 103 | } 104 | } 105 | return false 106 | } 107 | 108 | // if fieldA is nil, compare to fieldB nil 109 | if fieldA == nil { 110 | return fieldB == nil 111 | } 112 | 113 | // Direct comparison as last resort 114 | return fieldA == fieldB 115 | } 116 | 117 | func matchContainsAll(labels, selector map[string]interface{}) bool { 118 | if len(selector) == 0 || len(labels) == 0 { 119 | return false 120 | } 121 | // validate all labels in the selector exist on the labels and match 122 | for key, value := range selector { 123 | if labels[key] != value { 124 | return false 125 | } 126 | } 127 | return true 128 | } 129 | -------------------------------------------------------------------------------- /pkg/core/token.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | const ( 4 | ILLEGAL TokenType = iota 5 | EOF 6 | IDENT 7 | INT 8 | STRING 9 | NUMBER 10 | BOOLEAN 11 | NULL 12 | JSONDATA 13 | 14 | // Temporal functions and operators 15 | DATETIME 16 | DURATION 17 | PLUS 18 | MINUS 19 | 20 | // Keywords 21 | MATCH 22 | CREATE 23 | WHERE 24 | SET 25 | DELETE 26 | RETURN 27 | IN 28 | AS 29 | COUNT 30 | SUM 31 | AND 32 | NOT 33 | 34 | // Operators 35 | EQUALS 36 | NOT_EQUALS 37 | GREATER_THAN 38 | LESS_THAN 39 | GREATER_THAN_EQUALS 40 | LESS_THAN_EQUALS 41 | REGEX_COMPARE 42 | CONTAINS 43 | 44 | // Delimiters 45 | LPAREN 46 | RPAREN 47 | LBRACE 48 | RBRACE 49 | LBRACKET 50 | RBRACKET 51 | COLON 52 | COMMA 53 | DOT 54 | 55 | // Relationship tokens 56 | REL_NOPROPS_RIGHT 57 | REL_NOPROPS_LEFT 58 | REL_NOPROPS_BOTH 59 | REL_NOPROPS_NONE 60 | REL_BEGINPROPS_LEFT 61 | REL_BEGINPROPS_NONE 62 | REL_ENDPROPS_RIGHT 63 | REL_ENDPROPS_NONE 64 | ) 65 | -------------------------------------------------------------------------------- /pkg/core/types.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // Direction represents the direction of a relationship 4 | type Direction string 5 | 6 | const ( 7 | Left Direction = "left" 8 | Right Direction = "right" 9 | Both Direction = "both" 10 | None Direction = "none" 11 | ) 12 | 13 | // Token represents a lexical token 14 | type Token struct { 15 | Type TokenType 16 | Literal string 17 | } 18 | 19 | // TokenType represents the type of a lexical token 20 | type TokenType int 21 | 22 | // Expression represents a complete Cyphernetes query 23 | type Expression struct { 24 | Contexts []string 25 | Clauses []Clause 26 | } 27 | 28 | // Clause is an interface implemented by all clause types 29 | type Clause interface { 30 | isClause() 31 | } 32 | 33 | // MatchClause represents a MATCH clause 34 | type MatchClause struct { 35 | Nodes []*NodePattern 36 | Relationships []*Relationship 37 | ExtraFilters []*Filter 38 | } 39 | 40 | // Filter represents a filter condition in a WHERE clause 41 | type Filter struct { 42 | Type string // "KeyValuePair" or "SubMatch" 43 | KeyValuePair *KeyValuePair // Used when Type is "KeyValuePair" 44 | SubMatch *SubMatch // Used when Type is "SubMatch" 45 | } 46 | 47 | // SubMatch represents a pattern match within a WHERE clause 48 | type SubMatch struct { 49 | IsNegated bool 50 | Nodes []*NodePattern 51 | Relationships []*Relationship 52 | ReferenceNodeName string 53 | } 54 | 55 | // CreateClause represents a CREATE clause 56 | type CreateClause struct { 57 | Nodes []*NodePattern 58 | Relationships []*Relationship 59 | } 60 | 61 | // SetClause represents a SET clause 62 | type SetClause struct { 63 | KeyValuePairs []*KeyValuePair 64 | } 65 | 66 | // DeleteClause represents a DELETE clause 67 | type DeleteClause struct { 68 | NodeIds []string 69 | } 70 | 71 | // ReturnClause represents a RETURN clause 72 | type ReturnClause struct { 73 | Items []*ReturnItem 74 | } 75 | 76 | // ReturnItem represents an item in a RETURN clause 77 | type ReturnItem struct { 78 | JsonPath string 79 | Alias string 80 | Aggregate string 81 | } 82 | 83 | // NodePattern represents a node pattern in a query 84 | type NodePattern struct { 85 | ResourceProperties *ResourceProperties 86 | IsAnonymous bool // Indicates if this is an anonymous node (no variable name) 87 | } 88 | 89 | // ResourceProperties represents the properties of a resource 90 | type ResourceProperties struct { 91 | Name string 92 | Kind string 93 | Properties *Properties 94 | JsonData string 95 | } 96 | 97 | // Properties represents a collection of properties 98 | type Properties struct { 99 | PropertyList []*Property 100 | } 101 | 102 | // Property represents a key-value property 103 | type Property struct { 104 | Key string 105 | Value interface{} 106 | } 107 | 108 | // KeyValuePair represents a key-value pair with an operator 109 | type KeyValuePair struct { 110 | Key string 111 | Value interface{} 112 | Operator string 113 | IsNegated bool 114 | } 115 | 116 | // TemporalExpression represents a datetime operation (e.g., datetime() - duration("PT1H")) 117 | type TemporalExpression struct { 118 | Function string // "datetime" or "duration" 119 | Argument string // For duration function, the ISO 8601 duration string 120 | Operation string // "+" or "-" if this is part of an operation 121 | RightExpr *TemporalExpression // Right side of the operation, if any 122 | } 123 | 124 | // Relationship represents a relationship between nodes 125 | type Relationship struct { 126 | ResourceProperties *ResourceProperties 127 | Direction Direction 128 | LeftNode *NodePattern 129 | RightNode *NodePattern 130 | } 131 | 132 | // NodeRelationshipList represents a list of nodes and relationships 133 | type NodeRelationshipList struct { 134 | Nodes []*NodePattern 135 | Relationships []*Relationship 136 | } 137 | 138 | // Implement isClause for all clause types 139 | func (*MatchClause) isClause() {} 140 | func (*CreateClause) isClause() {} 141 | func (*SetClause) isClause() {} 142 | func (*DeleteClause) isClause() {} 143 | func (*ReturnClause) isClause() {} 144 | -------------------------------------------------------------------------------- /pkg/core/utils.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // Map manipulates a slice and transforms it to a slice of another type. 4 | func Map[T any, R any](collection []T, iteratee func(item T, index int) R) []R { 5 | result := make([]R, len(collection)) 6 | 7 | for i := range collection { 8 | result[i] = iteratee(collection[i], i) 9 | } 10 | 11 | return result 12 | } 13 | 14 | // Find search an element in a slice based on a predicate. It returns element and true if element was found. 15 | func Find[T any](collection []T, predicate func(item T) bool) (T, bool) { 16 | for i := range collection { 17 | if predicate(collection[i]) { 18 | return collection[i], true 19 | } 20 | } 21 | 22 | var result T 23 | return result, false 24 | } 25 | -------------------------------------------------------------------------------- /pkg/provider/interface.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/runtime/schema" 5 | ) 6 | 7 | // Provider defines the interface for different backend implementations 8 | type Provider interface { 9 | // Resource Operations 10 | // All operations support dry-run if the provider implementation supports it. 11 | // Dry-run can be enabled through provider-specific configuration options. 12 | GetK8sResources(kind, fieldSelector, labelSelector, namespace string) (interface{}, error) 13 | DeleteK8sResources(kind, name, namespace string) error 14 | CreateK8sResource(kind, name, namespace string, body interface{}) error 15 | PatchK8sResource(kind, name, namespace string, patchJSON []byte) error 16 | 17 | // Schema Operations 18 | FindGVR(kind string) (schema.GroupVersionResource, error) 19 | GetOpenAPIResourceSpecs() (map[string][]string, error) 20 | CreateProviderForContext(context string) (Provider, error) 21 | 22 | // Configuration 23 | ToggleDryRun() 24 | } 25 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cyphernetes Web Interface 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cyphernetes-web", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "test": "vitest run", 11 | "test:watch": "vitest" 12 | }, 13 | "dependencies": { 14 | "@testing-library/react": "^16.0.1", 15 | "@types/js-yaml": "^4.0.9", 16 | "@types/react-syntax-highlighter": "^15.5.13", 17 | "@vitest/ui": "^2.1.2", 18 | "js-yaml": "^4.1.0", 19 | "jsdom": "^25.0.1", 20 | "path": "^0.12.7", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "react-force-graph-2d": "^1.25.6", 24 | "react-syntax-highlighter": "^15.5.0", 25 | "vitest": "^2.1.9" 26 | }, 27 | "devDependencies": { 28 | "@types/react": "^18.0.28", 29 | "@types/react-dom": "^18.0.11", 30 | "@vitejs/plugin-react": "^3.1.0", 31 | "typescript": "^4.9.3", 32 | "vite": "^4.5.14", 33 | "@testing-library/jest-dom": "^5.16.5", 34 | "@testing-library/react": "^13.4.0", 35 | "@types/testing-library__jest-dom": "^5.14.5", 36 | "vitest-canvas-mock": "^0.3.1" 37 | }, 38 | "packageManager": "pnpm@9.11.0" 39 | } -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AvitalTamir/cyphernetes/d07fbac2979a5b02513fb663dfdbd0669fa9eaac/web/public/favicon.ico -------------------------------------------------------------------------------- /web/src/api/queryApi.ts: -------------------------------------------------------------------------------- 1 | // This is a mock API for now. In a real application, this would make HTTP requests to your backend. 2 | 3 | export interface QueryResponse { 4 | result: string; 5 | graph: string; 6 | error?: string; 7 | } 8 | 9 | export async function executeQuery(query: string): Promise { 10 | const response = await fetch('/api/query', { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | body: JSON.stringify({ query }), 16 | }); 17 | 18 | if (!response.ok) { 19 | const data = await response.json(); 20 | throw new Error(data.error); 21 | } 22 | 23 | const data = await response.json(); 24 | 25 | return data; 26 | } 27 | 28 | // Add this new function to convert resource names 29 | export async function convertResourceName(name: string): Promise { 30 | const response = await fetch(`/api/convert-resource-name?name=${encodeURIComponent(name)}`, { 31 | method: 'GET', 32 | headers: { 33 | 'Content-Type': 'application/json', 34 | }, 35 | }); 36 | 37 | if (!response.ok) { 38 | throw new Error('Failed to convert resource name'); 39 | } 40 | 41 | const data = await response.json(); 42 | return data.singular; 43 | } 44 | 45 | // Update the fetchAutocompleteSuggestions function 46 | export async function fetchAutocompleteSuggestions(query: string, position: number): Promise { 47 | // Convert resource names in the query 48 | const convertedQuery = await convertQueryResourceNames(query); 49 | 50 | const response = await fetch(`/api/autocomplete?query=${encodeURIComponent(convertedQuery)}&position=${position}`, { 51 | method: 'GET', 52 | headers: { 53 | 'Content-Type': 'application/json', 54 | }, 55 | }); 56 | 57 | if (!response.ok) { 58 | throw new Error('Failed to fetch autocomplete suggestions'); 59 | } 60 | 61 | const data = await response.json(); 62 | return data.suggestions; 63 | } 64 | 65 | // Helper function to convert resource names in the query 66 | async function convertQueryResourceNames(query: string): Promise { 67 | const regex = /\((\w+):(\w+)\)/g; 68 | const matches = query.match(regex); 69 | 70 | if (!matches) return query; 71 | 72 | for (const match of matches) { 73 | const [, , resourceName] = match.match(/\((\w+):(\w+)\)/) || []; 74 | if (resourceName) { 75 | const singularName = await convertResourceName(resourceName); 76 | query = query.replace(match, match.replace(resourceName, singularName)); 77 | } 78 | } 79 | 80 | return query; 81 | } -------------------------------------------------------------------------------- /web/src/components/GraphVisualization.css: -------------------------------------------------------------------------------- 1 | .graph-visualization-container { 2 | width: 100%; 3 | height: calc(100% - 2rem); 4 | display: flex; 5 | flex-direction: column; 6 | background-color: #1e1e1e; 7 | box-sizing: border-box; 8 | border-radius: 8px; 9 | padding: 1rem; 10 | } 11 | 12 | .left-sidebar-closed .graph-visualization-container { 13 | border-radius: 0; 14 | padding: 0; 15 | } 16 | 17 | .graph-visualization { 18 | position: relative; 19 | width: 100%; 20 | height: 100%; 21 | overflow: hidden; 22 | border-radius: 8px; 23 | box-shadow: 0 0 15px rgba(140, 82, 255, 0.2); 24 | } 25 | 26 | .left-sidebar-closed .graph-visualization { 27 | border-radius: 0; 28 | } 29 | 30 | .graph-visualization > div { 31 | width: 100% !important; 32 | height: 100% !important; 33 | } 34 | 35 | .graph-legend { 36 | padding: 12px; 37 | display: flex; 38 | flex-wrap: wrap; 39 | justify-content: center; 40 | background-color: rgba(255, 255, 255, 0.05); 41 | margin-top: 1rem; 42 | border-radius: 8px; 43 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); 44 | } 45 | 46 | .legend-item { 47 | display: flex; 48 | align-items: center; 49 | margin: 5px 10px; 50 | transition: all 0.2s ease; 51 | } 52 | 53 | .legend-item:hover { 54 | transform: translateY(-2px); 55 | } 56 | 57 | .legend-color { 58 | width: 20px; 59 | height: 20px; 60 | border-radius: 50%; 61 | margin-right: 8px; 62 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); 63 | } 64 | 65 | .legend-label { 66 | color: #e0e0e0; 67 | font-size: 14px; 68 | font-family: 'Arial', sans-serif; 69 | } 70 | 71 | /* Theme Toggle Button */ 72 | .theme-toggle-button { 73 | position: absolute; 74 | bottom: 30px; 75 | right: 30px; 76 | padding: 6px; 77 | background-color: rgba(255, 255, 255, 0.1); 78 | color: #e0e0e0; 79 | border: 1px solid rgba(255, 255, 255, 0.2); 80 | border-radius: 50%; 81 | cursor: pointer; 82 | font-size: 16px; 83 | line-height: 1; 84 | width: 30px; 85 | height: 30px; 86 | display: flex; 87 | align-items: center; 88 | justify-content: center; 89 | z-index: 10; 90 | transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease; 91 | filter: grayscale(100%); 92 | } 93 | 94 | .theme-toggle-button:hover { 95 | background-color: rgba(255, 255, 255, 0.2); 96 | } 97 | 98 | /* Light Theme Adjustments */ 99 | .light-theme.graph-visualization-container { 100 | background-color: #f0f0f0; /* Light grey container background */ 101 | } 102 | 103 | .light-theme .graph-legend { 104 | background-color: rgba(0, 0, 0, 0.05); 105 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 106 | } 107 | 108 | .light-theme .legend-label { 109 | color: #333; /* Darker text for light background */ 110 | } 111 | 112 | .light-theme .theme-toggle-button { 113 | background-color: rgba(0, 0, 0, 0.6); 114 | color: #333; 115 | border: 1px solid rgba(0, 0, 0, 0.2); 116 | } 117 | 118 | .light-theme .theme-toggle-button:hover { 119 | background-color: rgba(0, 0, 0, 0.7); 120 | } 121 | -------------------------------------------------------------------------------- /web/src/components/HistoryModal.css: -------------------------------------------------------------------------------- 1 | .history-modal-overlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background-color: rgba(0, 0, 0, 0.5); 8 | backdrop-filter: blur(5px); 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | z-index: 10001; /* Increased z-index to be above other elements */ 13 | } 14 | 15 | .history-modal { 16 | background-color: #2a2a2a; 17 | border-radius: 8px; 18 | padding: 20px; 19 | width: 80%; 20 | max-width: 600px; 21 | max-height: 80vh; 22 | display: flex; 23 | flex-direction: column; 24 | } 25 | 26 | .history-modal h2 { 27 | margin-top: 0; 28 | margin-bottom: 15px; 29 | color: #e0e0e0; 30 | font-family: Arial, sans-serif; 31 | } 32 | 33 | .history-search-input { 34 | width: calc(100% - 16px); 35 | padding: 8px; 36 | margin-bottom: 10px; 37 | background-color: #3a3a3a; 38 | border: 1px solid #4a4a4a; 39 | color: #e0e0e0; 40 | border-radius: 4px; 41 | } 42 | 43 | .history-list { 44 | list-style-type: none; 45 | padding: 0; 46 | margin: 0; 47 | overflow-y: auto; 48 | flex-grow: 1; 49 | } 50 | 51 | .history-list li { 52 | padding: 0; 53 | cursor: pointer; 54 | color: #e0e0e0; 55 | transition: background-color 0.2s; 56 | } 57 | 58 | .history-list li:hover, 59 | .history-list li.selected { 60 | background-color: #3a3a3a; 61 | } 62 | 63 | .history-list li pre { 64 | margin: 0 !important; 65 | } -------------------------------------------------------------------------------- /web/src/components/HistoryModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 3 | import { dracula } from 'react-syntax-highlighter/dist/cjs/styles/prism'; 4 | import './HistoryModal.css'; 5 | 6 | interface HistoryModalProps { 7 | isOpen: boolean; 8 | onClose: () => void; 9 | history: string[]; 10 | onSelectQuery: (query: string) => void; 11 | } 12 | 13 | const HistoryModal: React.FC = ({ isOpen, onClose, history, onSelectQuery }) => { 14 | const [searchTerm, setSearchTerm] = useState(''); 15 | const [filteredHistory, setFilteredHistory] = useState([]); 16 | const [selectedIndex, setSelectedIndex] = useState(0); 17 | const searchInputRef = useRef(null); 18 | const listRef = useRef(null); 19 | 20 | useEffect(() => { 21 | if (isOpen) { 22 | setSearchTerm(''); 23 | setSelectedIndex(0); 24 | searchInputRef.current?.focus(); 25 | } 26 | }, [isOpen]); 27 | 28 | useEffect(() => { 29 | const filtered = history.filter(query => 30 | query.toLowerCase().includes(searchTerm.toLowerCase()) 31 | ); 32 | setFilteredHistory(filtered); 33 | setSelectedIndex(0); 34 | }, [searchTerm, history]); 35 | 36 | const handleKeyDown = (e: React.KeyboardEvent) => { 37 | if (e.key === 'ArrowDown') { 38 | e.preventDefault(); 39 | setSelectedIndex(prev => (prev < filteredHistory.length - 1 ? prev + 1 : prev)); 40 | } else if (e.key === 'ArrowUp') { 41 | e.preventDefault(); 42 | setSelectedIndex(prev => (prev > 0 ? prev - 1 : 0)); 43 | } else if (e.key === 'Enter') { 44 | e.preventDefault(); 45 | if (filteredHistory[selectedIndex]) { 46 | onSelectQuery(filteredHistory[selectedIndex]); 47 | onClose(); 48 | } 49 | } else if (e.key === 'Escape') { 50 | onClose(); 51 | } 52 | }; 53 | 54 | useEffect(() => { 55 | if (listRef.current) { 56 | const selectedElement = listRef.current.children[selectedIndex] as HTMLElement; 57 | if (selectedElement) { 58 | selectedElement.scrollIntoView({ block: 'nearest' }); 59 | } 60 | } 61 | }, [selectedIndex]); 62 | 63 | if (!isOpen) return null; 64 | 65 | return ( 66 |
67 |
e.stopPropagation()}> 68 |

Query History

69 | setSearchTerm(e.target.value)} 75 | onKeyDown={handleKeyDown} 76 | className="history-search-input" 77 | /> 78 |
    79 | {filteredHistory.map((query, index) => ( 80 |
  • { 84 | onSelectQuery(query); 85 | onClose(); 86 | }} 87 | > 88 | 100 | {query} 101 | 102 |
  • 103 | ))} 104 |
105 |
106 |
107 | ); 108 | }; 109 | 110 | export default HistoryModal; -------------------------------------------------------------------------------- /web/src/components/__tests__/GraphVisualization.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { expect, test, describe, beforeEach, vi } from 'vitest'; 4 | import GraphVisualization from '../GraphVisualization'; 5 | 6 | // Mock react-force-graph-2d 7 | vi.mock('react-force-graph-2d', () => ({ 8 | __esModule: true, 9 | default: React.forwardRef((props: any, ref: any) =>
Mock ForceGraph2D
), 10 | })); 11 | 12 | describe('GraphVisualization Component', () => { 13 | beforeEach(() => { 14 | vi.resetAllMocks(); 15 | }); 16 | 17 | test('renders GraphVisualization component', () => { 18 | const { getByTestId } = render( 19 | {}} /> 20 | ); 21 | expect(getByTestId('graph-container')).toBeDefined(); 22 | }); 23 | }); -------------------------------------------------------------------------------- /web/src/components/__tests__/QueryInput.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent, screen, waitFor, act } from '@testing-library/react'; 3 | import { expect, test, describe, vi, beforeEach } from 'vitest'; 4 | import QueryInput from '../QueryInput'; 5 | 6 | // Mock fetch globally 7 | const mockFetch = vi.fn(); 8 | global.fetch = mockFetch; 9 | 10 | describe('QueryInput Component', () => { 11 | beforeEach(() => { 12 | // Reset all mocks before each test 13 | vi.clearAllMocks(); 14 | 15 | // Setup default mock for context API 16 | mockFetch.mockResolvedValue({ 17 | ok: true, 18 | json: () => Promise.resolve({ context: 'test-context', namespace: 'test-namespace' }) 19 | }); 20 | }); 21 | 22 | test('submits query when button is clicked', () => { 23 | const mockOnSubmit = vi.fn(); 24 | render( 25 | {}} 31 | isPanelOpen={true} 32 | /> 33 | ); 34 | 35 | const textarea = screen.getByPlaceholderText('Your Cyphernetes query here...'); 36 | fireEvent.change(textarea, { target: { value: 'MATCH (n) RETURN n' } }); 37 | 38 | const submitButton = screen.getByText('Execute Query'); 39 | fireEvent.click(submitButton); 40 | 41 | expect(mockOnSubmit).toHaveBeenCalledWith('MATCH (n) RETURN n', null); 42 | }); 43 | 44 | test('disables submit button when loading', () => { 45 | render( 46 | {}} 48 | isLoading={true} 49 | queryStatus={null} 50 | isHistoryModalOpen={false} 51 | setIsHistoryModalOpen={() => {}} 52 | isPanelOpen={true} 53 | /> 54 | ); 55 | 56 | const submitButton = screen.getByText('Executing...'); 57 | expect(submitButton).toBeDisabled(); 58 | }); 59 | 60 | test('displays context information when loaded', async () => { 61 | await act(async () => { 62 | render( 63 | {}} 65 | isLoading={false} 66 | queryStatus={null} 67 | isHistoryModalOpen={false} 68 | setIsHistoryModalOpen={() => {}} 69 | isPanelOpen={true} 70 | /> 71 | ); 72 | }); 73 | 74 | // Wait for the context info to be displayed 75 | await waitFor(() => { 76 | expect(screen.getByText('test-context')).toBeInTheDocument(); 77 | expect(screen.getByText('test-namespace')).toBeInTheDocument(); 78 | }); 79 | 80 | // Verify the fetch was called correctly 81 | expect(mockFetch).toHaveBeenCalledWith('/api/context'); 82 | }); 83 | 84 | test('handles context API error gracefully', async () => { 85 | // Mock a failed API response 86 | mockFetch.mockResolvedValueOnce({ 87 | ok: false, 88 | status: 500, 89 | statusText: 'Internal Server Error' 90 | }); 91 | 92 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); 93 | 94 | await act(async () => { 95 | render( 96 | {}} 98 | isLoading={false} 99 | queryStatus={null} 100 | isHistoryModalOpen={false} 101 | setIsHistoryModalOpen={() => {}} 102 | isPanelOpen={true} 103 | /> 104 | ); 105 | }); 106 | 107 | // Wait for the error to be logged 108 | await waitFor(() => { 109 | expect(consoleSpy).toHaveBeenCalled(); 110 | }); 111 | 112 | // Context indicator should not be rendered 113 | expect(screen.queryByText('test-context')).not.toBeInTheDocument(); 114 | expect(screen.queryByText('test-namespace')).not.toBeInTheDocument(); 115 | 116 | consoleSpy.mockRestore(); 117 | }); 118 | }); -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | 5 | console.log(` 6 | ______ _____ / / ___ _______ ___ / /____ ___ 7 | / __/ // / _ \\/ _ \\/ -_) __/ _ \\/ -_) __/ -_|_-< 8 | \\__/\\_, / .__/_//_/\\__/_/ /_//_/\\__/\\__/\\__/___/ 9 | /___/_/ Web Interface 10 | 11 | Wanna help build this thing? 12 | Visit https://github.com/AvitalTamir/cyphernetes`) 13 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 14 | 15 | 16 | , 17 | ) -------------------------------------------------------------------------------- /web/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import 'vitest-canvas-mock'; 3 | 4 | import { vi } from 'vitest'; 5 | 6 | // Extend expect with custom matchers 7 | import { expect } from 'vitest'; 8 | import * as matchers from '@testing-library/jest-dom/matchers'; 9 | expect.extend(matchers); 10 | 11 | // Reset all mocks before each test 12 | beforeEach(() => { 13 | vi.resetAllMocks(); 14 | }); -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "types": ["vitest/globals"] 19 | }, 20 | "include": ["src"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | //@ts-ignore 9 | root: path.resolve(__dirname, '.'), 10 | build: { 11 | outDir: 'dist', 12 | emptyOutDir: true, 13 | chunkSizeWarningLimit: 2000, 14 | }, 15 | }) -------------------------------------------------------------------------------- /web/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'jsdom', 7 | setupFiles: './src/setupTests.ts', 8 | mockReset: true, // Add this line 9 | }, 10 | }); --------------------------------------------------------------------------------