├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── docker-images-security-scan.yml │ ├── go.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── Dockerfile.x86_64 ├── LICENSE ├── README.md ├── assets └── defaults │ ├── appConfig.yaml │ ├── blueprints.json │ ├── pages.json │ └── scorecards.json ├── go.mod ├── go.sum ├── main.go ├── pkg ├── config │ ├── config.go │ ├── models.go │ └── utils.go ├── crd │ ├── crd.go │ ├── crd_test.go │ ├── utils.go │ └── utils_test.go ├── defaults │ ├── defaults.go │ ├── defaults_test.go │ └── init.go ├── event_handler │ ├── consumer │ │ ├── consumer.go │ │ ├── consumer_test.go │ │ └── event_listener.go │ ├── event_handler.go │ ├── event_handler_test.go │ ├── event_listener_factory.go │ └── polling │ │ ├── event_listener.go │ │ ├── polling.go │ │ └── polling_test.go ├── goutils │ ├── env.go │ ├── map.go │ └── slices.go ├── handlers │ ├── controllers.go │ └── controllers_test.go ├── jq │ ├── parser.go │ └── parser_test.go ├── k8s │ ├── client.go │ ├── config.go │ ├── controller.go │ ├── controller_test.go │ └── resource.go ├── parsers │ └── sensitive.go ├── port │ ├── blueprint │ │ └── blueprint.go │ ├── cli │ │ ├── action.go │ │ ├── blueprint.go │ │ ├── client.go │ │ ├── entity.go │ │ ├── integration.go │ │ ├── kafka_crednetials.go │ │ ├── org_details.go │ │ ├── page.go │ │ └── scorecards.go │ ├── entity │ │ └── entity.go │ ├── integration │ │ └── integration.go │ ├── kafka_credentials │ │ └── credentials.go │ ├── models.go │ ├── org_details │ │ └── org_details.go │ ├── page │ │ └── page.go │ └── scorecards │ │ └── scorecards.go └── signal │ └── signal.go └── test_utils ├── cleanup.go ├── immutable.go └── testing_init.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @port-labs/ecosystem-team 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | What - 4 | Why - 5 | How - 6 | 7 | ## Type of change 8 | 9 | Please leave one option from the following and delete the rest: 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] Non-breaking change (fix of existing functionality that will not change current behavior) 15 | - [ ] Documentation (added/updated documentation) 16 | 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "gomod" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | open-pull-requests-limit: 5 14 | -------------------------------------------------------------------------------- /.github/workflows/docker-images-security-scan.yml: -------------------------------------------------------------------------------- 1 | name: Scan docker images 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' # Every day at midnight 6 | workflow_dispatch: 7 | 8 | env: 9 | REGISTRY: "ghcr.io" 10 | IMAGE_NAME: "port-labs/port-k8s-exporter" 11 | 12 | jobs: 13 | build-and-scan: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v3 26 | 27 | - name: Log in to the Container registry 28 | uses: docker/login-action@v3 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - name: Import GPG key 35 | id: import_gpg 36 | uses: crazy-max/ghaction-import-gpg@v6 37 | with: 38 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 39 | passphrase: ${{ secrets.PASSPHRASE }} 40 | 41 | - name: Set up Go 42 | uses: actions/setup-go@v5 43 | with: 44 | go-version: 1.23 45 | cache: true 46 | 47 | # https://stackoverflow.com/questions/51475992/cgo-cross-compiling-from-amd64linux-to-arm64linux/75368290#75368290 48 | - name: musl-cross for CGO Support 49 | run: | 50 | mkdir ../../musl-cross 51 | wget -P ~ https://musl.cc/aarch64-linux-musl-cross.tgz 52 | tar -xvf ~/aarch64-linux-musl-cross.tgz -C ../../musl-cross 53 | 54 | - name: Run GoReleaser 55 | uses: goreleaser/goreleaser-action@v6 56 | with: 57 | version: latest 58 | args: release --clean --skip publish --snapshot 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 62 | REGISTRY: ${{ env.REGISTRY }} 63 | IMAGE: ${{ env.IMAGE_NAME }} 64 | 65 | - name: Run Trivy vulnerability scanner 66 | uses: aquasecurity/trivy-action@0.29.0 67 | with: 68 | image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 69 | ignore-unfixed: true 70 | vuln-type: 'os,library' 71 | severity: 'CRITICAL,HIGH' 72 | output: trivy-vulnerability.txt 73 | 74 | - name: Publish Trivy Output to Summary 75 | run: | 76 | if [[ -s trivy-vulnerability.txt ]]; then 77 | { 78 | echo "### Security Output" 79 | echo "
Click to expand" 80 | echo "" 81 | echo '```terraform' 82 | cat trivy-vulnerability.txt 83 | echo '```' 84 | echo "
" 85 | } >> $GITHUB_STEP_SUMMARY 86 | fi 87 | 88 | - name: Set output for trivy results 89 | run: | 90 | cat trivy-vulnerability.txt 91 | cat trivy-vulnerability.txt | grep -i "total:" | awk '{print $2}' 92 | echo "VULNERABILITIES_COUNT=$(cat trivy-vulnerability.txt | grep -i "total:" | awk '{print $2}')" >> $GITHUB_ENV 93 | echo ${{ env.VULNERABILITIES_COUNT }} 94 | 95 | - name: Send slack alert if vulnerabilities found 96 | if: ${{ env.VULNERABILITIES_COUNT != '0' }} 97 | uses: slackapi/slack-github-action@v2.0.0 98 | with: 99 | webhook-type: incoming-webhook 100 | payload: | 101 | { 102 | "text": "Vulnerabilities found in `${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest` image", 103 | "attachments": [ 104 | { 105 | "text": "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest image has vulnerabilities", 106 | "fields": [ 107 | { 108 | "title": "Image", 109 | "value": "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest", 110 | "short": true 111 | }, 112 | { 113 | "title": "Vulnerabilities", 114 | "value": "Count: ${{ env.VULNERABILITIES_COUNT }}", 115 | "short": true 116 | }, 117 | { 118 | "title": "link", 119 | "value": "https://github.com/port-labs/port-agent/actions/runs/${{ github.run_id }}", 120 | "short": true 121 | } 122 | ], 123 | 124 | "color": "#FF0000" 125 | } 126 | ] 127 | } 128 | env: 129 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_RND_ECOSYSTEM_DEPENDABOT_ALERTS_WEBHOOK_URL }} -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.23 20 | cache: true 21 | 22 | - name: Build 23 | run: go build -v ./... 24 | 25 | - name: Clean Test Cache 26 | run: go clean -testcache 27 | 28 | - name: Test 29 | run: go run gotest.tools/gotestsum@latest -f github-actions --junitfile ./test-results/junit.xml --format-hide-empty-pkg --junitfile-hide-empty-pkg -- -test.coverprofile=cover.out -p=1 ./... 30 | env: 31 | PORT_CLIENT_ID: ${{ secrets.PORT_CLIENT_ID }} 32 | PORT_CLIENT_SECRET: ${{ secrets.PORT_CLIENT_SECRET }} 33 | PORT_BASE_URL: ${{ secrets.PORT_BASE_URL }} 34 | 35 | - name: Merge coverage reports 36 | run: | 37 | go tool cover -func=cover.out > coverage.txt 38 | go tool cover -html=cover.out -o coverage.html 39 | - name: Upload coverage report 40 | id: upload-coverage 41 | uses: actions/upload-artifact@v4 42 | with: 43 | path: 'coverage.html' 44 | name: 'coverage-html' 45 | - name: Get PR_NUMBER 46 | id: pr-number 47 | run: | 48 | if [ ! -z ${{ inputs.PR_NUMBER }} ]; then 49 | echo "PR_NUMBER=${{ inputs.PR_NUMBER }}" >> $GITHUB_OUTPUT 50 | elif [ ! -z ${{ github.event.pull_request.number }} ]; then 51 | echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT 52 | else 53 | echo "PR_NUMBER=0" >> $GITHUB_OUTPUT 54 | fi 55 | - name: Set repo code coverage percentage by the percentage of statements covered in the tests 56 | id: set-stmts-coverage 57 | run: | 58 | stmts=$(tail -n1 coverage.txt | awk '{print $3}' | sed 's/%//') 59 | if [ -z "$stmts" ]; then 60 | echo "STMTS_COVERAGE=0" >> $GITHUB_OUTPUT 61 | else 62 | echo "STMTS_COVERAGE=$stmts" >> $GITHUB_OUTPUT 63 | fi 64 | - name: Comment PR with code coverage summary 65 | if: ${{ (steps.pr-number.outputs.PR_NUMBER != 0) }} 66 | uses: actions/github-script@v7 67 | env: 68 | CODE_COVERAGE_ARTIFACT_URL: ${{ steps.upload-coverage.outputs.artifact-url }} 69 | PR_NUMBER: ${{ steps.pr-number.outputs.PR_NUMBER }} 70 | with: 71 | github-token: ${{ secrets.GITHUB_TOKEN }} 72 | script: | 73 | const output = `#### Code Coverage Artifact 📈: ${{ env.CODE_COVERAGE_ARTIFACT_URL }} 74 | #### Code Coverage Total Percentage: \`${{ steps.set-stmts-coverage.outputs.STMTS_COVERAGE }}%\``; 75 | github.rest.issues.createComment({ 76 | issue_number: ${{ env.PR_NUMBER }}, 77 | owner: context.repo.owner, 78 | repo: context.repo.repo, 79 | body: output 80 | }) 81 | - name: Get current repo coverage percentage from Port 82 | uses: port-labs/port-github-action@v1 83 | id: get-current-coverage 84 | with: 85 | clientId: ${{ secrets.PORT_MAIN_CLIENT_ID }} 86 | clientSecret: ${{ secrets.PORT_MAIN_CLIENT_SECRET }} 87 | baseUrl: https://api.getport.io 88 | operation: GET 89 | identifier: port-k8s-exporter 90 | blueprint: repository 91 | - name: Set current code coverage 92 | id: set-current-coverage 93 | run: echo "CURRENT_COVERAGE=${{ fromJson(steps.get-current-coverage.outputs.entity).properties.coverage_percent }}" >> $GITHUB_OUTPUT 94 | - name: Comment if Coverage Regression 95 | if: ${{ (fromJson(steps.set-stmts-coverage.outputs.STMTS_COVERAGE) < fromJson(steps.set-current-coverage.outputs.CURRENT_COVERAGE)) && (steps.pr-number.outputs.PR_NUMBER != 0) }} 96 | uses: actions/github-script@v7 97 | env: 98 | PR_NUMBER: ${{ steps.pr-number.outputs.PR_NUMBER }} 99 | CURRENT_COVERAGE: ${{ steps.set-current-coverage.outputs.CURRENT_COVERAGE }} 100 | NEW_COVERAGE: ${{ steps.set-stmts-coverage.outputs.STMTS_COVERAGE }} 101 | with: 102 | github-token: ${{ secrets.GITHUB_TOKEN }} 103 | script: | 104 | const output = `🚨 The new code coverage percentage is lower than the current one. Current coverage: \`${{ env.CURRENT_COVERAGE }}\`\n While the new one is: \`${{ env.NEW_COVERAGE }}\``; 105 | github.rest.issues.createComment({ 106 | issue_number: ${{ env.PR_NUMBER }}, 107 | owner: context.repo.owner, 108 | repo: context.repo.repo, 109 | body: output 110 | }) 111 | - name: Calculate minimum required coverage with tolerance 112 | run: | 113 | STMT_COVERAGE=${{ steps.set-stmts-coverage.outputs.STMTS_COVERAGE }} 114 | THRESHOLD_DELTA=${{ vars.COVERAGE_THRESHOLD_DELTA }} 115 | MIN_REQUIRED=$(echo "$STMT_COVERAGE + $THRESHOLD_DELTA" | bc) 116 | echo "MIN_REQUIRED_COVERAGE=$MIN_REQUIRED" >> $GITHUB_ENV 117 | - name: Fail PR if current code coverage percentage is higher than the new one 118 | if: ${{ (fromJson(env.MIN_REQUIRED_COVERAGE) < fromJson(steps.set-current-coverage.outputs.CURRENT_COVERAGE)) && (vars.CODE_COVERAGE_ENFORCEMENT == 'true') }} 119 | run: exit 1 120 | - name: Update service code coverage percentage in Port 121 | if: ${{ (github.event_name == 'push') }} 122 | uses: port-labs/port-github-action@v1 123 | with: 124 | clientId: ${{ secrets.PORT_MAIN_CLIENT_ID }} 125 | clientSecret: ${{ secrets.PORT_MAIN_CLIENT_SECRET }} 126 | baseUrl: https://api.getport.io 127 | operation: UPSERT 128 | identifier: port-k8s-exporter 129 | blueprint: repository 130 | properties: |- 131 | { 132 | "coverage_percent": "${{ steps.set-stmts-coverage.outputs.STMTS_COVERAGE }}" 133 | } 134 | 135 | - name: Publish Test Report 136 | uses: mikepenz/action-junit-report@v4 137 | if: ${{ always() }} 138 | with: 139 | report_paths: './test-results/junit.xml' 140 | include_passed: true 141 | require_tests: true 142 | fail_on_failure: true 143 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | env: 10 | REGISTRY: "ghcr.io" 11 | IMAGE_NAME: "port-labs/port-k8s-exporter" 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | packages: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Log in to the Container registry 33 | uses: docker/login-action@v3 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Import GPG key 40 | id: import_gpg 41 | uses: crazy-max/ghaction-import-gpg@v6 42 | with: 43 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 44 | passphrase: ${{ secrets.PASSPHRASE }} 45 | 46 | - name: Set up Go 47 | uses: actions/setup-go@v5 48 | with: 49 | go-version: 1.23 50 | cache: true 51 | 52 | # https://stackoverflow.com/questions/51475992/cgo-cross-compiling-from-amd64linux-to-arm64linux/75368290#75368290 53 | - name: musl-cross for CGO Support 54 | run: | 55 | mkdir ../../musl-cross 56 | wget -P ~ https://musl.cc/aarch64-linux-musl-cross.tgz 57 | tar -xvf ~/aarch64-linux-musl-cross.tgz -C ../../musl-cross 58 | 59 | - name: Run GoReleaser 60 | uses: goreleaser/goreleaser-action@v6 61 | with: 62 | version: latest 63 | args: release --clean 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 67 | REGISTRY: ${{ env.REGISTRY }} 68 | IMAGE: ${{ env.IMAGE_NAME }} 69 | -------------------------------------------------------------------------------- /.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 | .idea 18 | 19 | .vscode 20 | 21 | __debug_bin 22 | 23 | config.yaml 24 | 25 | deployments/k8s 26 | 27 | .env 28 | 29 | .vscode 30 | 31 | # debug file 32 | __debug_bin* -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=1 7 | binary: "{{ .ProjectName }}" 8 | goos: 9 | - linux 10 | goarch: 11 | - amd64 12 | - id: arm 13 | flags: 14 | - -tags=musl 15 | ldflags: 16 | - -extldflags=-static 17 | env: 18 | - CGO_ENABLED=1 19 | - CC=/home/runner/work/musl-cross/aarch64-linux-musl-cross/bin/aarch64-linux-musl-gcc 20 | binary: "{{ .ProjectName }}" 21 | goos: 22 | - linux 23 | goarch: 24 | - arm64 25 | dockers: 26 | - image_templates: 27 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Version }}-amd64" 28 | use: buildx 29 | dockerfile: Dockerfile.x86_64 30 | goarch: amd64 31 | build_flag_templates: 32 | - "--label=org.opencontainers.image.created={{.Date}}" 33 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 34 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 35 | - "--label=org.opencontainers.image.version={{.Version}}" 36 | - "--platform=linux/amd64" 37 | extra_files: 38 | - assets/ 39 | - image_templates: 40 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Version }}-arm64v8" 41 | use: buildx 42 | goarch: arm64 43 | dockerfile: Dockerfile 44 | build_flag_templates: 45 | - "--label=org.opencontainers.image.created={{.Date}}" 46 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 47 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 48 | - "--label=org.opencontainers.image.version={{.Version}}" 49 | - "--platform=linux/arm64/v8" 50 | extra_files: 51 | - assets/ 52 | docker_manifests: 53 | - name_template: "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Version }}" 54 | image_templates: 55 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Version }}-amd64" 56 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Version }}-arm64v8" 57 | - name_template: "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:latest" 58 | image_templates: 59 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Version }}-amd64" 60 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Version }}-arm64v8" 61 | archives: 62 | - format: zip 63 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' 64 | checksum: 65 | name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' 66 | algorithm: sha256 67 | signs: 68 | - artifacts: checksum 69 | args: 70 | - "--batch" 71 | - "--local-user" 72 | - "{{ .Env.GPG_FINGERPRINT }}" 73 | - "--output" 74 | - "${signature}" 75 | - "--detach-sign" 76 | - "${artifact}" 77 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21.3 2 | 3 | RUN apk upgrade libssl3 libcrypto3 4 | 5 | COPY assets/ /assets 6 | 7 | ENTRYPOINT ["/usr/bin/port-k8s-exporter"] 8 | 9 | COPY port-k8s-exporter /usr/bin/port-k8s-exporter 10 | -------------------------------------------------------------------------------- /Dockerfile.x86_64: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21.3 2 | 3 | RUN apk upgrade libssl3 libcrypto3 4 | 5 | COPY assets/ /assets 6 | 7 | RUN apk add gcompat 8 | 9 | ENTRYPOINT ["/usr/bin/port-k8s-exporter"] 10 | 11 | COPY port-k8s-exporter /usr/bin/port-k8s-exporter 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Port K8s Exporter 4 | 5 | [![Slack](https://img.shields.io/badge/Slack-4A154B?style=for-the-badge&logo=slack&logoColor=white)](https://join.slack.com/t/devex-community/shared_invite/zt-1bmf5621e-GGfuJdMPK2D8UN58qL4E_g) 6 | 7 | Port is the Developer Platform meant to supercharge your DevOps and Developers, and allow you to regain control of your environment. 8 | 9 | ### Docs 10 | 11 | - [Port Docs](https://docs.getport.io) 12 | 13 | ## Usage 14 | 15 | See [Helm Chart](https://github.com/port-labs/helm-charts/tree/main/charts/port-k8s-exporter) to deploy `Port K8s Exporter` in your K8s Cluster. 16 | -------------------------------------------------------------------------------- /assets/defaults/appConfig.yaml: -------------------------------------------------------------------------------- 1 | createMissingRelatedEntities: true 2 | resources: 3 | - kind: v1/namespaces 4 | port: 5 | entity: 6 | mappings: 7 | - blueprint: '"namespace"' 8 | identifier: .metadata.name + "-" + env.CLUSTER_NAME 9 | properties: 10 | creationTimestamp: .metadata.creationTimestamp 11 | labels: .metadata.labels 12 | relations: 13 | Cluster: env.CLUSTER_NAME 14 | title: .metadata.name 15 | selector: 16 | query: .metadata.name | startswith("kube") | not 17 | - kind: v1/namespaces 18 | port: 19 | entity: 20 | mappings: 21 | - blueprint: '"cluster"' 22 | identifier: env.CLUSTER_NAME 23 | title: env.CLUSTER_NAME 24 | selector: 25 | query: .metadata.name | contains("kube-system") 26 | - kind: apps/v1/deployments 27 | port: 28 | entity: 29 | mappings: 30 | - blueprint: '"workload"' 31 | icon: '"Deployment"' 32 | identifier: .metadata.name + "-Deployment-" + .metadata.namespace + "-" + env.CLUSTER_NAME 33 | properties: 34 | images: '(.spec.template.spec.containers | map({name, image, resources})) | map("\(.name): \(.image)")' 35 | availableReplicas: .status.availableReplicas 36 | containers: (.spec.template.spec.containers | map({name, image, resources})) 37 | creationTimestamp: .metadata.creationTimestamp 38 | hasLatest: .spec.template.spec.containers[].image | contains(":latest") 39 | hasLimits: .spec.template.spec.containers | all(has("resources") and (.resources.limits.memory 40 | and .resources.limits.cpu)) 41 | hasPrivileged: .spec.template.spec.containers | [.[].securityContext.privileged] 42 | | any 43 | isHealthy: if .spec.replicas == .status.availableReplicas then "Healthy" 44 | else "Unhealthy" end 45 | kind: '"Deployment"' 46 | labels: .metadata.labels 47 | replicas: .spec.replicas 48 | strategy: .spec.strategy.type 49 | strategyConfig: .spec.strategy // {} 50 | relations: 51 | Namespace: .metadata.namespace + "-" + env.CLUSTER_NAME 52 | title: .metadata.name 53 | selector: 54 | query: .metadata.namespace | startswith("kube") | not 55 | - kind: apps/v1/daemonsets 56 | port: 57 | entity: 58 | mappings: 59 | - blueprint: '"workload"' 60 | identifier: .metadata.name + "-DaemonSet-" + .metadata.namespace + "-" + env.CLUSTER_NAME 61 | properties: 62 | availableReplicas: .status.availableReplicas 63 | containers: (.spec.template.spec.containers | map({name, image, resources})) 64 | creationTimestamp: .metadata.creationTimestamp 65 | hasLatest: .spec.template.spec.containers[].image | contains(":latest") 66 | hasLimits: .spec.template.spec.containers | all(has("resources") and (.resources.limits.memory 67 | and .resources.limits.cpu)) 68 | hasPrivileged: .spec.template.spec.containers | [.[].securityContext.privileged] 69 | | any 70 | isHealthy: if .spec.replicas == .status.availableReplicas then "Healthy" 71 | else "Unhealthy" end 72 | kind: '"DaemonSet"' 73 | labels: .metadata.labels 74 | replicas: .spec.replicas 75 | strategyConfig: .spec.strategy // {} 76 | relations: 77 | Namespace: .metadata.namespace + "-" + env.CLUSTER_NAME 78 | title: .metadata.name 79 | selector: 80 | query: .metadata.namespace | startswith("kube") | not 81 | - kind: apps/v1/statefulsets 82 | port: 83 | entity: 84 | mappings: 85 | - blueprint: '"workload"' 86 | identifier: .metadata.name + "-StatefulSet-" + .metadata.namespace + "-" + env.CLUSTER_NAME 87 | properties: 88 | availableReplicas: .status.availableReplicas 89 | containers: (.spec.template.spec.containers | map({name, image, resources})) 90 | creationTimestamp: .metadata.creationTimestamp 91 | hasLatest: .spec.template.spec.containers[].image | contains(":latest") 92 | hasLimits: .spec.template.spec.containers | all(has("resources") and (.resources.limits.memory 93 | and .resources.limits.cpu)) 94 | hasPrivileged: .spec.template.spec.containers | [.[].securityContext.privileged] 95 | | any 96 | isHealthy: if .spec.replicas == .status.availableReplicas then "Healthy" 97 | else "Unhealthy" end 98 | kind: '"StatefulSet"' 99 | labels: .metadata.labels 100 | replicas: .spec.replicas 101 | strategyConfig: .spec.strategy // {} 102 | relations: 103 | Namespace: .metadata.namespace + "-" + env.CLUSTER_NAME 104 | title: .metadata.name 105 | selector: 106 | query: .metadata.namespace | startswith("kube") | not -------------------------------------------------------------------------------- /assets/defaults/blueprints.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "identifier": "cluster", 4 | "description": "This blueprint represents a Kubernetes Cluster", 5 | "title": "Cluster", 6 | "icon": "Cluster", 7 | "schema": { 8 | "properties": {}, 9 | "required": [] 10 | }, 11 | "mirrorProperties": {}, 12 | "calculationProperties": {}, 13 | "relations": {} 14 | }, 15 | { 16 | "identifier": "namespace", 17 | "description": "This blueprint represents a k8s Namespace", 18 | "title": "Namespace", 19 | "icon": "Environment", 20 | "schema": { 21 | "properties": { 22 | "creationTimestamp": { 23 | "type": "string", 24 | "title": "Created", 25 | "format": "date-time", 26 | "description": "When the Namespace was created" 27 | }, 28 | "labels": { 29 | "type": "object", 30 | "title": "Labels", 31 | "description": "Labels of the Namespace" 32 | } 33 | }, 34 | "required": [] 35 | }, 36 | "mirrorProperties": {}, 37 | "calculationProperties": {}, 38 | "relations": { 39 | "Cluster": { 40 | "title": "Cluster", 41 | "description": "The namespace's Kubernetes cluster", 42 | "target": "cluster", 43 | "required": false, 44 | "many": false 45 | } 46 | } 47 | }, 48 | { 49 | "identifier": "workload", 50 | "description": "This blueprint represents a k8s Workload. This includes all k8s objects which can create pods (deployments[replicasets], daemonsets, statefulsets...)", 51 | "title": "Workload", 52 | "icon": "Deployment", 53 | "schema": { 54 | "properties": { 55 | "availableReplicas": { 56 | "type": "number", 57 | "title": "Running Replicas", 58 | "description": "Current running replica count" 59 | }, 60 | "containers": { 61 | "type": "array", 62 | "title": "Containers", 63 | "default": [], 64 | "description": "The containers for each pod instance of the Workload" 65 | }, 66 | "images": { 67 | "items": { 68 | "type": "string" 69 | }, 70 | "title": "Images", 71 | "type": "array" 72 | }, 73 | "creationTimestamp": { 74 | "type": "string", 75 | "title": "Created", 76 | "format": "date-time", 77 | "description": "When the Workload was created" 78 | }, 79 | "labels": { 80 | "type": "object", 81 | "title": "Labels", 82 | "description": "Labels of the Workload" 83 | }, 84 | "replicas": { 85 | "type": "number", 86 | "title": "Wanted Replicas", 87 | "description": "Wanted replica count" 88 | }, 89 | "strategy": { 90 | "type": "string", 91 | "title": "Strategy", 92 | "description": "Rollout Strategy" 93 | }, 94 | "hasPrivileged": { 95 | "type": "boolean", 96 | "title": "Has Privileged Container" 97 | }, 98 | "hasLatest": { 99 | "type": "boolean", 100 | "title": "Has 'latest' tag", 101 | "description": "Has Container with 'latest' as image tag" 102 | }, 103 | "hasLimits": { 104 | "type": "boolean", 105 | "title": "All containers have limits" 106 | }, 107 | "isHealthy": { 108 | "type": "string", 109 | "enum": [ 110 | "Healthy", 111 | "Unhealthy" 112 | ], 113 | "enumColors": { 114 | "Healthy": "green", 115 | "Unhealthy": "red" 116 | }, 117 | "title": "Workload Health" 118 | }, 119 | "kind": { 120 | "title": "Workload Kind", 121 | "description": "The kind of Workload", 122 | "type": "string", 123 | "enum": [ 124 | "StatefulSet", 125 | "DaemonSet", 126 | "Deployment", 127 | "Rollout" 128 | ] 129 | }, 130 | "strategyConfig": { 131 | "type": "object", 132 | "title": "Strategy Config", 133 | "description": "The workloads rollout strategy" 134 | } 135 | }, 136 | "required": [] 137 | }, 138 | "mirrorProperties": { 139 | "Cluster": { 140 | "title": "Cluster", 141 | "path": "Namespace.Cluster.$title" 142 | }, 143 | "namespace": { 144 | "title": "Namespace", 145 | "path": "Namespace.$title" 146 | } 147 | }, 148 | "calculationProperties": {}, 149 | "relations": { 150 | "Namespace": { 151 | "title": "Namespace", 152 | "target": "namespace", 153 | "required": false, 154 | "many": false 155 | } 156 | } 157 | } 158 | ] 159 | -------------------------------------------------------------------------------- /assets/defaults/pages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "identifier": "workload_overview_dashboard", 4 | "icon": "Apps", 5 | "title": "Workload Overview Dashboard", 6 | "widgets": [ 7 | { 8 | "type": "dashboard-widget", 9 | "layout": [ 10 | { 11 | "height": 433, 12 | "columns": [ 13 | { 14 | "id": "explanation", 15 | "size": 6 16 | }, 17 | { 18 | "id": "workload-health", 19 | "size": 6 20 | } 21 | ] 22 | }, 23 | { 24 | "height": 400, 25 | "columns": [ 26 | { 27 | "id": "one-replica", 28 | "size": 6 29 | }, 30 | { 31 | "id": "workloads-namespaces", 32 | "size": 6 33 | } 34 | ] 35 | } 36 | ], 37 | "widgets": [ 38 | { 39 | "id": "explanation", 40 | "title": "Port Dashboards", 41 | "icon": "port", 42 | "markdown": "[Dashboards](https://docs.getport.io/customize-pages-dashboards-and-plugins/page/dashboard-page) enable you to visualize the data from your catalog. Dashboards contain various widgets that display the data in different ways, such as pie charts, number charts, iframes, and more. You can create pages and dashboards for specific processes and expose them to the relevant people using [page permissions](https://docs.getport.io/customize-pages-dashboards-and-plugins/page/page-permissions).\n\nThis dashboard contains visualizations based on your \"Workload\" catalog. You can edit this dashboard and create new ones. Here are some additional ideas for dashboards\n- [SVP engineering dashboard](https://demo.getport.io/dashboard_svp_engineering)\n- [Security dashboard](https://demo.getport.io/dashboard_security)\n- [SRE dashboard](https://demo.getport.io/dashboard_sre)", 43 | "type": "markdown" 44 | }, 45 | { 46 | "id": "workload-health", 47 | "icon": "PieChart", 48 | "type": "entities-pie-chart", 49 | "description": "According to the \"Workload Health\" indication", 50 | "title": "Deployment status", 51 | "dataset": { 52 | "combinator": "and", 53 | "rules": [ 54 | { 55 | "operator": "=", 56 | "value": "workload", 57 | "property": "$blueprint" 58 | } 59 | ] 60 | }, 61 | "property": "property#isHealthy" 62 | }, 63 | { 64 | "id": "one-replica", 65 | "type": "table-entities-explorer", 66 | "displayMode": "widget", 67 | "title": "Workloads with one replica", 68 | "description": "", 69 | "icon": "Table", 70 | "dataset": { 71 | "combinator": "and", 72 | "rules": [ 73 | { 74 | "operator": "=", 75 | "value": "workload", 76 | "property": "$blueprint" 77 | } 78 | ] 79 | }, 80 | "blueprintConfig": { 81 | "workload": { 82 | "groupSettings": { 83 | "groupBy": [] 84 | }, 85 | "sortSettings": { 86 | "sortBy": [] 87 | }, 88 | "filterSettings": { 89 | "filterBy": { 90 | "combinator": "and", 91 | "rules": [] 92 | } 93 | }, 94 | "propertiesSettings": { 95 | "hidden": [ 96 | "$identifier", 97 | "containers", 98 | "creationTimestamp", 99 | "hasLatest", 100 | "hasLimits", 101 | "hasPrivileged", 102 | "isHealthy", 103 | "kind", 104 | "labels", 105 | "replicas", 106 | "strategy", 107 | "strategyConfig", 108 | "Cluster", 109 | "namespace", 110 | "highAvailability", 111 | "configuration", 112 | "highAvalabilityB", 113 | "highAvalabilityS", 114 | "highAvalabilityG", 115 | "notPrivileged", 116 | "notDefaultNamespace", 117 | "rolloutStrategy", 118 | "imageTag", 119 | "Namespace" 120 | ], 121 | "order": [] 122 | } 123 | } 124 | } 125 | }, 126 | { 127 | "id": "workloads-namespaces", 128 | "icon": "PieChart", 129 | "type": "entities-pie-chart", 130 | "description": "", 131 | "title": "Workloads per namespace", 132 | "dataset": { 133 | "combinator": "and", 134 | "rules": [ 135 | { 136 | "operator": "=", 137 | "value": "workload", 138 | "property": "$blueprint" 139 | } 140 | ] 141 | }, 142 | "property": "mirror-property#namespace" 143 | } 144 | ] 145 | } 146 | ], 147 | "section": "software_catalog", 148 | "type": "dashboard" 149 | }, 150 | { 151 | "identifier": "availability_scorecard_dashboard", 152 | "icon": "Scorecards", 153 | "title": "Availability Scorecard Dashboard", 154 | "widgets": [ 155 | { 156 | "type": "dashboard-widget", 157 | "layout": [ 158 | { 159 | "height": 405, 160 | "columns": [ 161 | { 162 | "id": "explanation", 163 | "size": 6 164 | }, 165 | { 166 | "id": "levels-summary", 167 | "size": 6 168 | } 169 | ] 170 | }, 171 | { 172 | "height": 600, 173 | "columns": [ 174 | { 175 | "id": "workloads-by-level", 176 | "size": 12 177 | } 178 | ] 179 | }, 180 | { 181 | "height": 422, 182 | "columns": [ 183 | { 184 | "id": "one-replica-pie-chart", 185 | "size": 4 186 | }, 187 | { 188 | "id": "two-replicas-pie-chart", 189 | "size": 4 190 | }, 191 | { 192 | "id": "three-replicas-pie-chart", 193 | "size": 4 194 | } 195 | ] 196 | } 197 | ], 198 | "widgets": [ 199 | { 200 | "id": "levels-summary", 201 | "type": "entities-pie-chart", 202 | "property": "scorecard#highAvailability", 203 | "title": "Levels summary", 204 | "dataset": { 205 | "combinator": "and", 206 | "rules": [ 207 | { 208 | "property": "$blueprint", 209 | "operator": "=", 210 | "value": "workload" 211 | } 212 | ] 213 | } 214 | }, 215 | { 216 | "id": "workloads-by-level", 217 | "type": "table-entities-explorer", 218 | "title": "Workloads by level", 219 | "icon": "Users", 220 | "blueprintConfig": { 221 | "workload": { 222 | "groupSettings": { 223 | "groupBy": [ 224 | "highAvailability" 225 | ] 226 | }, 227 | "sortSettings": { 228 | "sortBy": [] 229 | }, 230 | "filterSettings": { 231 | "filterBy": { 232 | "combinator": "and", 233 | "rules": [] 234 | } 235 | }, 236 | "propertiesSettings": { 237 | "hidden": [ 238 | "$identifier", 239 | "$updatedAt", 240 | "$createdAt", 241 | "availableReplicas", 242 | "containers", 243 | "creationTimestamp", 244 | "hasLatest", 245 | "hasLimits", 246 | "hasPrivileged", 247 | "isHealthy", 248 | "kind", 249 | "labels", 250 | "replicas", 251 | "strategy", 252 | "strategyConfig", 253 | "Cluster", 254 | "namespace", 255 | "configuration", 256 | "Namespace", 257 | "notPrivileged", 258 | "notDefaultNamespace", 259 | "rolloutStrategy", 260 | "imageTag" 261 | ], 262 | "order": [] 263 | } 264 | } 265 | }, 266 | "displayMode": "widget", 267 | "dataset": { 268 | "combinator": "and", 269 | "rules": [ 270 | { 271 | "property": "$blueprint", 272 | "operator": "=", 273 | "value": "workload" 274 | } 275 | ] 276 | } 277 | }, 278 | { 279 | "id": "one-replica-pie-chart", 280 | "type": "entities-pie-chart", 281 | "property": "scorecard-rule#highAvailability#highAvalabilityB", 282 | "title": "\"Wanted Replicas\" >= 1", 283 | "icon": "Star", 284 | "dataset": { 285 | "combinator": "and", 286 | "rules": [ 287 | { 288 | "operator": "=", 289 | "value": "workload", 290 | "property": "$blueprint" 291 | } 292 | ] 293 | } 294 | }, 295 | { 296 | "id": "two-replicas-pie-chart", 297 | "type": "entities-pie-chart", 298 | "property": "scorecard-rule#highAvailability#highAvalabilityS", 299 | "title": "\"Wanted Replicas\" >= 2", 300 | "icon": "Star", 301 | "dataset": { 302 | "combinator": "and", 303 | "rules": [ 304 | { 305 | "operator": "=", 306 | "value": "workload", 307 | "property": "$blueprint" 308 | } 309 | ] 310 | }, 311 | "description": "Rule description" 312 | }, 313 | { 314 | "id": "three-replicas-pie-chart", 315 | "type": "entities-pie-chart", 316 | "property": "scorecard-rule#highAvailability#highAvalabilityG", 317 | "title": "\"Wanted Replicas\" >= 3", 318 | "icon": "Star", 319 | "dataset": { 320 | "combinator": "and", 321 | "rules": [ 322 | { 323 | "operator": "=", 324 | "value": "workload", 325 | "property": "$blueprint" 326 | } 327 | ] 328 | }, 329 | "description": "Rule description" 330 | }, 331 | { 332 | "id": "explanation", 333 | "title": "Scorecard dashboard", 334 | "description": "", 335 | "icon": "port", 336 | "markdown": "[Scorecards](https://docs.getport.io/promote-scorecards/) are a way for you and your team to define and measure standards in different categories, such as service maturity, production readiness, quality, productivity, and more. Scorecards contain [rules](https://docs.getport.io/promote-scorecards/#rule-elements) that determine its overall score (such as bronze, silver, and gold).\n\nThis dashboard is based on the \"High Availability\" scorecard we automatically created for your workloads. It contains the following rules:\n- Wanted Replicas >=1 (Bronze rule) \n- Wanted Replicas >=2 (Silver rule)\n- Wanted Replicas >=3 (Gold rule)", 337 | "type": "markdown" 338 | } 339 | ] 340 | } 341 | ], 342 | "section": "software_catalog", 343 | "type": "dashboard" 344 | } 345 | ] -------------------------------------------------------------------------------- /assets/defaults/scorecards.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "blueprint": "workload", 4 | "data": [ 5 | { 6 | "identifier": "configuration", 7 | "title": "Configuration Checks", 8 | "rules": [ 9 | { 10 | "identifier": "notPrivileged", 11 | "title": "No privilged containers", 12 | "level": "Bronze", 13 | "query": { 14 | "combinator": "and", 15 | "conditions": [ 16 | { 17 | "property": "hasPrivileged", 18 | "operator": "!=", 19 | "value": true 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "identifier": "hasLimits", 26 | "title": "All containers have CPU and Memory limits", 27 | "level": "Bronze", 28 | "query": { 29 | "combinator": "and", 30 | "conditions": [ 31 | { 32 | "property": "hasLimits", 33 | "operator": "=", 34 | "value": true 35 | } 36 | ] 37 | } 38 | }, 39 | { 40 | "identifier": "notDefaultNamespace", 41 | "title": "Not in 'default' namespace", 42 | "level": "Bronze", 43 | "query": { 44 | "combinator": "and", 45 | "conditions": [ 46 | { 47 | "property": "namespace", 48 | "operator": "!=", 49 | "value": "default" 50 | } 51 | ] 52 | } 53 | }, 54 | { 55 | "identifier": "rolloutStrategy", 56 | "title": "Using Rolling update strategy", 57 | "level": "Silver", 58 | "query": { 59 | "combinator": "and", 60 | "conditions": [ 61 | { 62 | "property": "strategy", 63 | "operator": "=", 64 | "value": "RollingUpdate" 65 | } 66 | ] 67 | } 68 | }, 69 | { 70 | "identifier": "imageTag", 71 | "title": "Doesn't have a container with image tag 'latest'", 72 | "level": "Gold", 73 | "query": { 74 | "combinator": "and", 75 | "conditions": [ 76 | { 77 | "property": "hasLatest", 78 | "operator": "!=", 79 | "value": "false" 80 | } 81 | ] 82 | } 83 | } 84 | ] 85 | }, 86 | { 87 | "identifier": "highAvailability", 88 | "title": "High Availability", 89 | "rules": [ 90 | { 91 | "identifier": "highAvalabilityB", 92 | "title": "\"Wanted Replicas\" >= 1", 93 | "level": "Bronze", 94 | "query": { 95 | "combinator": "and", 96 | "conditions": [ 97 | { 98 | "property": "replicas", 99 | "operator": ">=", 100 | "value": 1 101 | } 102 | ] 103 | } 104 | }, 105 | { 106 | "identifier": "highAvalabilityS", 107 | "title": "\"Wanted Replicas\" >= 2", 108 | "level": "Silver", 109 | "query": { 110 | "combinator": "and", 111 | "conditions": [ 112 | { 113 | "property": "replicas", 114 | "operator": ">=", 115 | "value": 2 116 | } 117 | ] 118 | } 119 | }, 120 | { 121 | "identifier": "highAvalabilityG", 122 | "title": "\"Wanted Replicas\" >= 3", 123 | "level": "Gold", 124 | "query": { 125 | "combinator": "and", 126 | "conditions": [ 127 | { 128 | "property": "replicas", 129 | "operator": ">=", 130 | "value": 3 131 | } 132 | ] 133 | } 134 | } 135 | ] 136 | } 137 | ] 138 | } 139 | ] -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/port-labs/port-k8s-exporter 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/confluentinc/confluent-kafka-go/v2 v2.2.0 9 | github.com/go-resty/resty/v2 v2.7.0 10 | github.com/google/uuid v1.6.0 11 | github.com/itchyny/gojq v0.12.17 12 | github.com/joho/godotenv v1.5.1 13 | github.com/stretchr/testify v1.9.0 14 | golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 15 | gopkg.in/yaml.v3 v3.0.1 16 | k8s.io/api v0.30.10 17 | k8s.io/apiextensions-apiserver v0.30.10 18 | k8s.io/apimachinery v0.30.10 19 | k8s.io/client-go v0.30.10 20 | k8s.io/klog/v2 v2.130.1 21 | k8s.io/utils v0.0.0-20241210054802-24370beab758 22 | ) 23 | 24 | require ( 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/emicklei/go-restful/v3 v3.11.3 // indirect 27 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 28 | github.com/go-logr/logr v1.4.2 // indirect 29 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 30 | github.com/go-openapi/jsonreference v0.20.5 // indirect 31 | github.com/go-openapi/swag v0.23.0 // indirect 32 | github.com/gogo/protobuf v1.3.2 // indirect 33 | github.com/golang/protobuf v1.5.4 // indirect 34 | github.com/google/gnostic-models v0.6.9 // indirect 35 | github.com/google/go-cmp v0.6.0 // indirect 36 | github.com/google/gofuzz v1.2.0 // indirect 37 | github.com/imdario/mergo v0.3.16 // indirect 38 | github.com/itchyny/timefmt-go v0.1.6 // indirect 39 | github.com/josharian/intern v1.0.0 // indirect 40 | github.com/json-iterator/go v1.1.12 // indirect 41 | github.com/mailru/easyjson v0.7.7 // indirect 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 43 | github.com/modern-go/reflect2 v1.0.2 // indirect 44 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 45 | github.com/pkg/errors v0.9.1 // indirect 46 | github.com/pmezard/go-difflib v1.0.0 // indirect 47 | github.com/sirupsen/logrus v1.9.3 // indirect 48 | github.com/spf13/pflag v1.0.6 // indirect 49 | golang.org/x/net v0.38.0 // indirect 50 | golang.org/x/oauth2 v0.27.0 // indirect 51 | golang.org/x/sys v0.31.0 // indirect 52 | golang.org/x/term v0.30.0 // indirect 53 | golang.org/x/text v0.23.0 // indirect 54 | golang.org/x/time v0.3.0 // indirect 55 | google.golang.org/protobuf v1.35.2 // indirect 56 | gopkg.in/inf.v0 v0.9.1 // indirect 57 | k8s.io/kube-openapi v0.0.0-20250304201544-e5f78fe3ede9 // indirect 58 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 59 | sigs.k8s.io/randfill v1.0.0 // indirect 60 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 61 | sigs.k8s.io/yaml v1.4.0 // indirect 62 | ) 63 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/port-labs/port-k8s-exporter/pkg/config" 8 | "github.com/port-labs/port-k8s-exporter/pkg/defaults" 9 | "github.com/port-labs/port-k8s-exporter/pkg/event_handler" 10 | "github.com/port-labs/port-k8s-exporter/pkg/handlers" 11 | "github.com/port-labs/port-k8s-exporter/pkg/k8s" 12 | "github.com/port-labs/port-k8s-exporter/pkg/port" 13 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 14 | "github.com/port-labs/port-k8s-exporter/pkg/port/integration" 15 | "k8s.io/klog/v2" 16 | ) 17 | 18 | func initiateHandler(exporterConfig *port.Config, k8sClient *k8s.Client, portClient *cli.PortClient) (*handlers.ControllersHandler, error) { 19 | i, err := integration.GetIntegration(portClient, exporterConfig.StateKey) 20 | if err != nil { 21 | return nil, fmt.Errorf("error getting Port integration: %v", err) 22 | } 23 | if i.Config == nil { 24 | return nil, errors.New("integration config is nil") 25 | 26 | } 27 | 28 | newHandler := handlers.NewControllersHandler(exporterConfig, i.Config, k8sClient, portClient) 29 | newHandler.Handle() 30 | 31 | return newHandler, nil 32 | } 33 | 34 | func main() { 35 | k8sConfig := k8s.NewKubeConfig() 36 | applicationConfig, err := config.NewConfiguration() 37 | if err != nil { 38 | klog.Fatalf("Error getting application config: %s", err.Error()) 39 | } 40 | 41 | clientConfig, err := k8sConfig.ClientConfig() 42 | if err != nil { 43 | klog.Fatalf("Error getting K8s client config: %s", err.Error()) 44 | } 45 | 46 | k8sClient, err := k8s.NewClient(clientConfig) 47 | if err != nil { 48 | klog.Fatalf("Error building K8s client: %s", err.Error()) 49 | } 50 | portClient := cli.New(config.ApplicationConfig) 51 | 52 | if err := defaults.InitIntegration(portClient, applicationConfig, false); err != nil { 53 | klog.Fatalf("Error initializing Port integration: %s", err.Error()) 54 | } 55 | 56 | eventListener, err := event_handler.CreateEventListener(applicationConfig.StateKey, applicationConfig.EventListenerType, portClient) 57 | if err != nil { 58 | klog.Fatalf("Error creating event listener: %s", err.Error()) 59 | } 60 | 61 | klog.Info("Starting controllers handler") 62 | err = event_handler.Start(eventListener, func() (event_handler.IStoppableRsync, error) { 63 | return initiateHandler(applicationConfig, k8sClient, portClient) 64 | }) 65 | 66 | if err != nil { 67 | klog.Fatalf("Error starting event listener: %s", err.Error()) 68 | } 69 | } 70 | 71 | func init() { 72 | klog.InitFlags(nil) 73 | config.Init() 74 | } 75 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/joho/godotenv" 10 | "github.com/port-labs/port-k8s-exporter/pkg/port" 11 | "gopkg.in/yaml.v3" 12 | "k8s.io/klog/v2" 13 | ) 14 | 15 | var KafkaConfig = &KafkaConfiguration{} 16 | var PollingListenerRate uint 17 | 18 | var ApplicationConfig = &ApplicationConfiguration{} 19 | 20 | func Init() { 21 | _ = godotenv.Load() 22 | 23 | NewString(&ApplicationConfig.EventListenerType, "event-listener-type", "POLLING", "Event listener type, can be either POLLING or KAFKA. Optional.") 24 | 25 | // Kafka listener Configuration 26 | NewString(&KafkaConfig.Brokers, "event-listener-brokers", "localhost:9092", "Kafka event listener brokers") 27 | NewString(&KafkaConfig.SecurityProtocol, "event-listener-security-protocol", "plaintext", "Kafka event listener security protocol") 28 | NewString(&KafkaConfig.AuthenticationMechanism, "event-listener-authentication-mechanism", "none", "Kafka event listener authentication mechanism") 29 | 30 | // Polling listener Configuration 31 | NewUInt(&PollingListenerRate, "event-listener-polling-rate", 60, "Polling event listener polling rate") 32 | 33 | // Application Configuration 34 | NewString(&ApplicationConfig.ConfigFilePath, "config", "config.yaml", "Path to Port K8s Exporter config file. Required.") 35 | NewString(&ApplicationConfig.StateKey, "state-key", "my-k8s-exporter", "Port K8s Exporter state key id. Required.") 36 | NewUInt(&ApplicationConfig.ResyncInterval, "resync-interval", 0, "The re-sync interval in minutes. Optional.") 37 | NewString(&ApplicationConfig.PortBaseURL, "port-base-url", "https://api.getport.io", "Port base URL. Optional.") 38 | NewString(&ApplicationConfig.PortClientId, "port-client-id", "", "Port client id. Required.") 39 | NewString(&ApplicationConfig.PortClientSecret, "port-client-secret", "", "Port client secret. Required.") 40 | NewBool(&ApplicationConfig.CreateDefaultResources, "create-default-resources", true, "Create default resources on installation. Optional.") 41 | NewCreatePortResourcesOrigin(&ApplicationConfig.CreatePortResourcesOrigin, "create-default-resources-origin", "Port", "Create default resources origin on installation. Optional.") 42 | 43 | NewBool(&ApplicationConfig.OverwriteConfigurationOnRestart, "overwrite-configuration-on-restart", false, "Overwrite the configuration in port on restarting the exporter. Optional.") 44 | 45 | // Deprecated 46 | NewBool(&ApplicationConfig.DeleteDependents, "delete-dependents", false, "Delete dependents. Optional.") 47 | NewBool(&ApplicationConfig.CreateMissingRelatedEntities, "create-missing-related-entities", false, "Create missing related entities. Optional.") 48 | 49 | flag.Parse() 50 | } 51 | 52 | func NewConfiguration() (*port.Config, error) { 53 | config := &port.Config{ 54 | StateKey: ApplicationConfig.StateKey, 55 | EventListenerType: ApplicationConfig.EventListenerType, 56 | CreateDefaultResources: ApplicationConfig.CreateDefaultResources, 57 | CreatePortResourcesOrigin: ApplicationConfig.CreatePortResourcesOrigin, 58 | ResyncInterval: ApplicationConfig.ResyncInterval, 59 | OverwriteConfigurationOnRestart: ApplicationConfig.OverwriteConfigurationOnRestart, 60 | CreateMissingRelatedEntities: ApplicationConfig.CreateMissingRelatedEntities, 61 | DeleteDependents: ApplicationConfig.DeleteDependents, 62 | } 63 | 64 | v, err := os.ReadFile(ApplicationConfig.ConfigFilePath) 65 | if err != nil { 66 | v = []byte("{}") 67 | klog.Infof("Config file not found, using defaults") 68 | return config, nil 69 | } 70 | klog.Infof("Config file found") 71 | err = yaml.Unmarshal(v, &config) 72 | if err != nil { 73 | return nil, fmt.Errorf("failed loading configuration: %w", err) 74 | } 75 | 76 | config.StateKey = strings.ToLower(config.StateKey) 77 | 78 | return config, nil 79 | } 80 | -------------------------------------------------------------------------------- /pkg/config/models.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/port-labs/port-k8s-exporter/pkg/port" 4 | 5 | type KafkaConfiguration struct { 6 | Brokers string 7 | SecurityProtocol string 8 | GroupID string 9 | AuthenticationMechanism string 10 | Username string 11 | Password string 12 | KafkaSecurityEnabled bool 13 | } 14 | 15 | type ApplicationConfiguration struct { 16 | ConfigFilePath string 17 | StateKey string 18 | ResyncInterval uint 19 | PortBaseURL string 20 | PortClientId string 21 | PortClientSecret string 22 | EventListenerType string 23 | CreateDefaultResources bool 24 | CreatePortResourcesOrigin port.CreatePortResourcesOrigin 25 | OverwriteConfigurationOnRestart bool 26 | // These Configurations are used only for setting up the Integration on installation or when using OverwriteConfigurationOnRestart flag. 27 | Resources []port.Resource 28 | DeleteDependents bool `json:"deleteDependents,omitempty"` 29 | CreateMissingRelatedEntities bool `json:"createMissingRelatedEntities,omitempty"` 30 | UpdateEntityOnlyOnDiff bool `json:"updateEntityOnlyOnDiff,omitempty"` 31 | } 32 | -------------------------------------------------------------------------------- /pkg/config/utils.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port" 7 | "strings" 8 | 9 | "github.com/port-labs/port-k8s-exporter/pkg/goutils" 10 | "k8s.io/utils/strings/slices" 11 | ) 12 | 13 | var keys []string 14 | 15 | func prepareEnvKey(key string) string { 16 | newKey := strings.ToUpper(strings.ReplaceAll(key, "-", "_")) 17 | 18 | if slices.Contains(keys, newKey) { 19 | panic("Application Error : Found duplicate config key: " + newKey) 20 | } 21 | 22 | keys = append(keys, newKey) 23 | return newKey 24 | } 25 | 26 | func NewString(v *string, key string, defaultValue string, description string) { 27 | value := goutils.GetStringEnvOrDefault(prepareEnvKey(key), defaultValue) 28 | flag.StringVar(v, key, value, description) 29 | } 30 | 31 | func NewUInt(v *uint, key string, defaultValue uint, description string) { 32 | value := uint(goutils.GetUintEnvOrDefault(prepareEnvKey(key), uint64(defaultValue))) 33 | flag.UintVar(v, key, value, description) 34 | } 35 | 36 | func NewBool(v *bool, key string, defaultValue bool, description string) { 37 | value := goutils.GetBoolEnvOrDefault(prepareEnvKey(key), defaultValue) 38 | flag.BoolVar(v, key, value, description) 39 | } 40 | 41 | func NewCreatePortResourcesOrigin(target *port.CreatePortResourcesOrigin, key, defaultValue, description string) { 42 | var value string 43 | flag.StringVar(&value, key, defaultValue, description) 44 | 45 | *target = port.CreatePortResourcesOrigin(value) 46 | if *target != port.CreatePortResourcesOriginPort && *target != port.CreatePortResourcesOriginK8S { 47 | panic(fmt.Sprintf("Invalid value for %s: %s. Must be one of [Port, K8S]", key, value)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/crd/crd.go: -------------------------------------------------------------------------------- 1 | package crd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/port-labs/port-k8s-exporter/pkg/goutils" 10 | "github.com/port-labs/port-k8s-exporter/pkg/jq" 11 | "github.com/port-labs/port-k8s-exporter/pkg/port" 12 | "github.com/port-labs/port-k8s-exporter/pkg/port/blueprint" 13 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 14 | "golang.org/x/exp/slices" 15 | v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 16 | apiextensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/klog/v2" 19 | ) 20 | 21 | const ( 22 | KindCRD = "CustomResourceDefinition" 23 | K8SIcon = "Cluster" 24 | CrossplaneIcon = "Crossplane" 25 | NestedSchemaSeparator = "__" 26 | ) 27 | 28 | func createKindConfigFromCRD(crd v1.CustomResourceDefinition) port.Resource { 29 | resource := crd.Spec.Names.Kind 30 | group := crd.Spec.Group 31 | version := crd.Spec.Versions[0].Name 32 | kindConfig := port.Resource{ 33 | Kind: group + "/" + version + "/" + resource, 34 | Selector: port.Selector{ 35 | Query: "true", 36 | }, 37 | Port: port.Port{ 38 | Entity: port.EntityMappings{ 39 | Mappings: []port.EntityMapping{ 40 | { 41 | Identifier: ".metadata.name", 42 | Blueprint: "\"" + crd.Spec.Names.Singular + "\"", // Blueprint is a JQ query, so that way we hardcoded it 43 | Title: ".metadata.name", 44 | Properties: map[string]string{ 45 | "namespace": ".metadata.namespace", 46 | "*": ".spec", 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | } 53 | return kindConfig 54 | } 55 | 56 | func isCRDNamespacedScoped(crd v1.CustomResourceDefinition) bool { 57 | return crd.Spec.Scope == v1.NamespaceScoped 58 | } 59 | 60 | func getDescriptionFromCRD(crd v1.CustomResourceDefinition) string { 61 | return fmt.Sprintf("This action automatically generated from a Custom Resource Definition (CRD) in the cluster. Allows you to create, update, and delete %s resources. To complete the setup of this action, follow [this guide](https://docs.getport.io/guides-and-tutorials/manage-resources-using-k8s-crds)", crd.Spec.Names.Singular) 62 | } 63 | func getIconFromCRD(crd v1.CustomResourceDefinition) string { 64 | if len(crd.ObjectMeta.OwnerReferences) > 0 && crd.ObjectMeta.OwnerReferences[0].Kind == "CompositeResourceDefinition" { 65 | return CrossplaneIcon 66 | } 67 | return K8SIcon 68 | } 69 | 70 | func buildCreateAction(crd v1.CustomResourceDefinition, as *port.ActionUserInputs, nameProperty port.ActionProperty, namespaceProperty port.ActionProperty, invocation port.InvocationMethod) port.Action { 71 | createActionProperties := goutils.MergeMaps( 72 | as.Properties, 73 | map[string]port.ActionProperty{"name": nameProperty}, 74 | ) 75 | 76 | crtAct := port.Action{ 77 | Identifier: "create_" + crd.Spec.Names.Singular, 78 | Title: "Create " + strings.Title(crd.Spec.Names.Singular), 79 | Icon: getIconFromCRD(crd), 80 | Trigger: &port.Trigger{ 81 | Type: "self-service", 82 | Operation: "CREATE", 83 | BlueprintIdentifier: crd.Spec.Names.Singular, 84 | UserInputs: &port.ActionUserInputs{ 85 | Properties: createActionProperties, 86 | Required: append(as.Required, "name"), 87 | }, 88 | }, 89 | Description: getDescriptionFromCRD(crd), 90 | InvocationMethod: &invocation, 91 | } 92 | 93 | if isCRDNamespacedScoped(crd) { 94 | crtAct.Trigger.UserInputs.Properties["namespace"] = namespaceProperty 95 | crtAct.Trigger.UserInputs.Required = append(crtAct.Trigger.UserInputs.Required, "namespace") 96 | } 97 | 98 | return crtAct 99 | } 100 | 101 | func buildUpdateAction(crd v1.CustomResourceDefinition, as *port.ActionUserInputs, invocation port.InvocationMethod) port.Action { 102 | for k, v := range as.Properties { 103 | updatedStruct := v 104 | 105 | defaultMap := make(map[string]string) 106 | // Blueprint schema differs from the action schema, as it not shallow - this JQ pattern assign the defaults from the entity nested schema to the action shallow one 107 | defaultMap["jqQuery"] = ".entity.properties." + strings.Replace(k, NestedSchemaSeparator, ".", -1) 108 | updatedStruct.Default = defaultMap 109 | 110 | as.Properties[k] = updatedStruct 111 | } 112 | 113 | updtAct := port.Action{ 114 | Identifier: "update_" + crd.Spec.Names.Singular, 115 | Title: "Update " + strings.Title(crd.Spec.Names.Singular), 116 | Icon: getIconFromCRD(crd), 117 | Description: getDescriptionFromCRD(crd), 118 | Trigger: &port.Trigger{ 119 | Type: "self-service", 120 | Operation: "DAY-2", 121 | BlueprintIdentifier: crd.Spec.Names.Singular, 122 | UserInputs: &port.ActionUserInputs{ 123 | Properties: as.Properties, 124 | Required: as.Required, 125 | }, 126 | }, 127 | InvocationMethod: &invocation, 128 | } 129 | 130 | return updtAct 131 | } 132 | 133 | func buildDeleteAction(crd v1.CustomResourceDefinition, invocation port.InvocationMethod) port.Action { 134 | dltAct := port.Action{ 135 | Identifier: "delete_" + crd.Spec.Names.Singular, 136 | Title: "Delete " + strings.Title(crd.Spec.Names.Singular), 137 | Icon: getIconFromCRD(crd), 138 | Description: getDescriptionFromCRD(crd), 139 | Trigger: &port.Trigger{ 140 | Type: "self-service", 141 | BlueprintIdentifier: crd.Spec.Names.Singular, 142 | UserInputs: &port.ActionUserInputs{ 143 | Properties: map[string]port.ActionProperty{}, 144 | }, 145 | Operation: "DELETE", 146 | }, 147 | 148 | InvocationMethod: &invocation, 149 | } 150 | 151 | return dltAct 152 | } 153 | 154 | func adjustSchemaToPortSchemaCompatibilityLevel(spec *v1.JSONSchemaProps) { 155 | for i, v := range spec.Properties { 156 | switch v.Type { 157 | case "object": 158 | adjustSchemaToPortSchemaCompatibilityLevel(&v) 159 | spec.Properties[i] = v 160 | case "integer": 161 | v.Type = "number" 162 | v.Format = "" 163 | spec.Properties[i] = v 164 | case "": 165 | if v.AnyOf != nil && len(v.AnyOf) > 0 { 166 | possibleTypes := make([]string, 0) 167 | for _, anyOf := range v.AnyOf { 168 | possibleTypes = append(possibleTypes, anyOf.Type) 169 | } 170 | 171 | // Prefer string over other types 172 | if slices.Contains(possibleTypes, "string") { 173 | v.Type = "string" 174 | } else { 175 | v.Type = possibleTypes[0] 176 | } 177 | } 178 | spec.Properties[i] = v 179 | } 180 | } 181 | } 182 | 183 | func convertToPortSchemas(crd v1.CustomResourceDefinition) ([]port.Action, *port.Blueprint, error) { 184 | latestCRDVersion := crd.Spec.Versions[0] 185 | bs := &port.BlueprintSchema{} 186 | as := &port.ActionUserInputs{} 187 | notVisible := new(bool) // Using a pointer to bool to avoid the omitempty of false values 188 | *notVisible = false 189 | 190 | var spec v1.JSONSchemaProps 191 | 192 | // If the CRD has a spec field, use that as the schema - as it's a best practice but not required by k8s 193 | if _, ok := latestCRDVersion.Schema.OpenAPIV3Schema.Properties["spec"]; ok { 194 | spec = latestCRDVersion.Schema.OpenAPIV3Schema.Properties["spec"] 195 | } else { 196 | spec = *latestCRDVersion.Schema.OpenAPIV3Schema 197 | } 198 | 199 | // Adjust schema to be compatible with Port schema 200 | // Port's schema complexity is not rich as k8s, so we need to adjust some types and formats so we can bridge this gap 201 | adjustSchemaToPortSchemaCompatibilityLevel(&spec) 202 | 203 | bytes, err := json.Marshal(&spec) 204 | if err != nil { 205 | return nil, nil, fmt.Errorf("error marshaling schema: %v", err) 206 | } 207 | 208 | err = json.Unmarshal(bytes, &bs) 209 | if err != nil { 210 | return nil, nil, fmt.Errorf("error unmarshaling schema into blueprint schema: %v", err) 211 | } 212 | 213 | // Make nested schemas shallow with `NestedSchemaSeparator`(__) separator 214 | shallowedSchema := ShallowJsonSchema(&spec, NestedSchemaSeparator) 215 | bytesNested, err := json.Marshal(&shallowedSchema) 216 | if err != nil { 217 | return nil, nil, fmt.Errorf("error marshaling schema: %v", err) 218 | } 219 | 220 | err = json.Unmarshal(bytesNested, &as) 221 | 222 | if err != nil { 223 | return nil, nil, fmt.Errorf("error unmarshaling schema into action schema: %v", err) 224 | } 225 | 226 | for k, v := range as.Properties { 227 | if !slices.Contains(as.Required, k) { 228 | v.Visible = new(bool) 229 | // Not required fields should not be visible, and also shouldn't be applying default values in Port's side, instead we should let k8s apply the defaults 230 | *v.Visible = false 231 | v.Default = nil 232 | as.Properties[k] = v 233 | } 234 | 235 | as.Properties[k] = v 236 | } 237 | 238 | if isCRDNamespacedScoped(crd) { 239 | bs.Properties["namespace"] = port.Property{ 240 | Type: "string", 241 | Title: "Namespace", 242 | } 243 | } 244 | 245 | bp := port.Blueprint{ 246 | Identifier: crd.Spec.Names.Singular, 247 | Title: strings.Title(crd.Spec.Names.Singular), 248 | Icon: getIconFromCRD(crd), 249 | Schema: *bs, 250 | } 251 | 252 | nameProperty := port.ActionProperty{ 253 | Type: "string", 254 | Title: crd.Spec.Names.Singular + " Name", 255 | Pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", 256 | } 257 | 258 | namespaceProperty := port.ActionProperty{ 259 | Type: "string", 260 | Title: "Namespace", 261 | Default: "default", 262 | } 263 | 264 | invocation := port.InvocationMethod{ 265 | Type: "GITHUB", 266 | Organization: "", 267 | Repository: "", 268 | Workflow: "sync-control-plane-direct.yml", 269 | ReportWorkflowStatus: true, 270 | WorkflowInputs: map[string]interface{}{ 271 | "operation": "{{.trigger.operation}}", 272 | "triggeringUser": "{{ .trigger.by.user.email }}", 273 | "runId": "{{ .run.id }}", 274 | "manifest": map[string]interface{}{ 275 | "apiVersion": crd.Spec.Group + "/" + crd.Spec.Versions[0].Name, 276 | "kind": crd.Spec.Names.Kind, 277 | "metadata": map[string]interface{}{ 278 | "{{if (.entity | has(\"identifier\")) then \"name\" else null end}}": "{{.entity.\"identifier\"}}", 279 | "{{if (.inputs | has(\"name\")) then \"name\" else null end}}": "{{.inputs.\"name\"}}", 280 | "{{if (.entity.properties | has(\"namespace\")) then \"namespace\" else null end}}": "{{.entity.properties.\"namespace\"}}", 281 | "{{if (.inputs | has(\"namespace\")) then \"namespace\" else null end}}": "{{.inputs.\"namespace\"}}", 282 | }, 283 | "spec": "{{ .inputs | to_entries | map(if .key | contains(\"__\") then .key |= split(\"__\") else . end) | reduce .[] as $item ({}; if $item.key | type == \"array\" then setpath($item.key;$item.value) else setpath([$item.key];$item.value) end) | del(.name) | del (.namespace) }}", 284 | }, 285 | }, 286 | } 287 | 288 | actions := []port.Action{ 289 | buildCreateAction(crd, as, nameProperty, namespaceProperty, invocation), 290 | buildUpdateAction(crd, as, invocation), 291 | buildDeleteAction(crd, invocation), 292 | } 293 | 294 | return actions, &bp, nil 295 | } 296 | 297 | func findMatchingCRDs(crds []v1.CustomResourceDefinition, pattern string) []v1.CustomResourceDefinition { 298 | matchedCRDs := make([]v1.CustomResourceDefinition, 0) 299 | 300 | for _, crd := range crds { 301 | mapCrd, err := goutils.StructToMap(crd) 302 | 303 | if err != nil { 304 | klog.Errorf("Error converting CRD to map: %s", err.Error()) 305 | continue 306 | } 307 | 308 | match, err := jq.ParseBool(pattern, mapCrd) 309 | 310 | if err != nil { 311 | klog.Errorf("Error running jq on crd CRD: %s", err.Error()) 312 | continue 313 | } 314 | if match { 315 | matchedCRDs = append(matchedCRDs, crd) 316 | } 317 | } 318 | 319 | return matchedCRDs 320 | } 321 | 322 | func handleCRD(crds []v1.CustomResourceDefinition, portConfig *port.IntegrationAppConfig, portClient *cli.PortClient) { 323 | matchedCRDs := findMatchingCRDs(crds, portConfig.CRDSToDiscover) 324 | 325 | for _, crd := range matchedCRDs { 326 | portConfig.Resources = append(portConfig.Resources, createKindConfigFromCRD(crd)) 327 | actions, bp, err := convertToPortSchemas(crd) 328 | if err != nil { 329 | klog.Errorf("Error converting CRD to Port schemas: %s", err.Error()) 330 | continue 331 | } 332 | 333 | _, err = blueprint.NewBlueprint(portClient, *bp) 334 | 335 | if err != nil && strings.Contains(err.Error(), "taken") { 336 | klog.Infof("Blueprint already exists, patching blueprint properties") 337 | _, err = blueprint.PatchBlueprint(portClient, port.Blueprint{Schema: bp.Schema, Identifier: bp.Identifier}) 338 | if err != nil { 339 | klog.Errorf("Error patching blueprint: %s", err.Error()) 340 | } 341 | } 342 | 343 | if err != nil { 344 | klog.Errorf("Error creating blueprint: %s", err.Error()) 345 | } 346 | 347 | for _, act := range actions { 348 | _, err = cli.CreateAction(portClient, act) 349 | if err != nil { 350 | if strings.Contains(err.Error(), "taken") { 351 | if portConfig.OverwriteCRDsActions { 352 | _, err = cli.UpdateAction(portClient, act) 353 | if err != nil { 354 | klog.Errorf("Error updating blueprint action: %s", err.Error()) 355 | } 356 | } else { 357 | klog.Infof("Action already exists, if you wish to overwrite it, delete it first or provide the configuration overwriteCrdsActions: true, in the exporter configuration and resync") 358 | } 359 | } else { 360 | klog.Errorf("Error creating blueprint action: %s", err.Error()) 361 | } 362 | } 363 | } 364 | } 365 | } 366 | 367 | func AutodiscoverCRDsToActions(portConfig *port.IntegrationAppConfig, apiExtensionsClient apiextensions.ApiextensionsV1Interface, portClient *cli.PortClient) { 368 | if portConfig.CRDSToDiscover == "" { 369 | klog.Info("Discovering CRDs is disabled") 370 | return 371 | } 372 | 373 | klog.Infof("Discovering CRDs/XRDs with pattern: %s", portConfig.CRDSToDiscover) 374 | crds, err := apiExtensionsClient.CustomResourceDefinitions().List(context.Background(), metav1.ListOptions{}) 375 | 376 | if err != nil { 377 | klog.Errorf("Error listing CRDs: %s", err.Error()) 378 | return 379 | } 380 | 381 | handleCRD(crds.Items, portConfig, portClient) 382 | } 383 | -------------------------------------------------------------------------------- /pkg/crd/crd_test.go: -------------------------------------------------------------------------------- 1 | package crd 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "testing" 7 | 8 | guuid "github.com/google/uuid" 9 | "github.com/port-labs/port-k8s-exporter/pkg/config" 10 | "github.com/port-labs/port-k8s-exporter/pkg/port" 11 | "github.com/port-labs/port-k8s-exporter/pkg/port/blueprint" 12 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 13 | testUtils "github.com/port-labs/port-k8s-exporter/test_utils" 14 | v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 15 | fakeapiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1/fake" 16 | "k8s.io/apimachinery/pkg/runtime" 17 | clienttesting "k8s.io/client-go/testing" 18 | ) 19 | 20 | type Fixture struct { 21 | t *testing.T 22 | apiextensionClient *fakeapiextensionsv1.FakeApiextensionsV1 23 | portClient *cli.PortClient 24 | portConfig *port.IntegrationAppConfig 25 | stateKey string 26 | } 27 | 28 | var ( 29 | blueprintPrefix = "k8s-crd-test" 30 | ) 31 | 32 | func getBlueprintId(stateKey string) string { 33 | return testUtils.GetBlueprintIdFromPrefixAndStateKey(blueprintPrefix, stateKey) 34 | } 35 | 36 | func deleteDefaultResources(stateKey string, portClient *cli.PortClient) { 37 | blueprintId := getBlueprintId(stateKey) 38 | _ = blueprint.DeleteBlueprintEntities(portClient, blueprintId) 39 | _ = blueprint.DeleteBlueprint(portClient, blueprintId) 40 | } 41 | 42 | func newFixture(t *testing.T, userAgent string, namespaced bool, crdsDiscoveryPattern string) *Fixture { 43 | 44 | stateKey := guuid.NewString() 45 | blueprintId := getBlueprintId(stateKey) 46 | apiExtensionsFakeClient := fakeapiextensionsv1.FakeApiextensionsV1{Fake: &clienttesting.Fake{}} 47 | 48 | apiExtensionsFakeClient.AddReactor("list", "customresourcedefinitions", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { 49 | fakeCrd := &v1.CustomResourceDefinitionList{ 50 | Items: []v1.CustomResourceDefinition{ 51 | { 52 | Spec: v1.CustomResourceDefinitionSpec{ 53 | Group: "testgroup", 54 | Names: v1.CustomResourceDefinitionNames{ 55 | Kind: "TestKind", 56 | Singular: blueprintId, 57 | Plural: "testkinds", 58 | }, 59 | Versions: []v1.CustomResourceDefinitionVersion{ 60 | { 61 | Name: "v1", 62 | Schema: &v1.CustomResourceValidation{ 63 | OpenAPIV3Schema: &v1.JSONSchemaProps{ 64 | Type: "object", 65 | Properties: map[string]v1.JSONSchemaProps{ 66 | "spec": { 67 | Type: "object", 68 | Properties: map[string]v1.JSONSchemaProps{ 69 | "stringProperty": { 70 | Type: "string", 71 | }, 72 | "intProperty": { 73 | Type: "integer", 74 | }, 75 | "boolProperty": { 76 | Type: "boolean", 77 | }, 78 | "nestedProperty": { 79 | Type: "object", 80 | Properties: map[string]v1.JSONSchemaProps{ 81 | "nestedStringProperty": { 82 | Type: "string", 83 | }, 84 | }, 85 | Required: []string{"nestedStringProperty"}, 86 | }, 87 | "anyOfProperty": { 88 | AnyOf: []v1.JSONSchemaProps{ 89 | { 90 | Type: "string", 91 | }, 92 | { 93 | Type: "integer", 94 | }, 95 | }, 96 | }, 97 | }, 98 | Required: []string{"stringProperty", "nestedProperty"}, 99 | }, 100 | }, 101 | }, 102 | }, 103 | }, 104 | }, 105 | }, 106 | }, 107 | }, 108 | } 109 | 110 | if namespaced { 111 | fakeCrd.Items[0].Spec.Scope = v1.NamespaceScoped 112 | } else { 113 | fakeCrd.Items[0].Spec.Scope = v1.ClusterScoped 114 | } 115 | 116 | return true, fakeCrd, nil 117 | }) 118 | 119 | newConfig := &config.ApplicationConfiguration{ 120 | ConfigFilePath: config.ApplicationConfig.ConfigFilePath, 121 | ResyncInterval: config.ApplicationConfig.ResyncInterval, 122 | PortBaseURL: config.ApplicationConfig.PortBaseURL, 123 | EventListenerType: config.ApplicationConfig.EventListenerType, 124 | CreateDefaultResources: config.ApplicationConfig.CreateDefaultResources, 125 | OverwriteConfigurationOnRestart: config.ApplicationConfig.OverwriteConfigurationOnRestart, 126 | Resources: config.ApplicationConfig.Resources, 127 | DeleteDependents: config.ApplicationConfig.DeleteDependents, 128 | CreateMissingRelatedEntities: config.ApplicationConfig.CreateMissingRelatedEntities, 129 | UpdateEntityOnlyOnDiff: config.ApplicationConfig.UpdateEntityOnlyOnDiff, 130 | PortClientId: config.ApplicationConfig.PortClientId, 131 | PortClientSecret: config.ApplicationConfig.PortClientSecret, 132 | StateKey: stateKey, 133 | } 134 | 135 | if userAgent == "" { 136 | userAgent = fmt.Sprintf("%s/0.1", stateKey) 137 | } 138 | 139 | portClient := cli.New(newConfig) 140 | deleteDefaultResources(stateKey, portClient) 141 | 142 | return &Fixture{ 143 | t: t, 144 | portClient: portClient, 145 | apiextensionClient: &apiExtensionsFakeClient, 146 | portConfig: &port.IntegrationAppConfig{ 147 | CRDSToDiscover: crdsDiscoveryPattern, 148 | }, 149 | stateKey: stateKey, 150 | } 151 | } 152 | 153 | func checkBlueprintAndActionsProperties(t *testing.T, f *Fixture, namespaced bool) { 154 | 155 | blueprintId := getBlueprintId(f.stateKey) 156 | bp, err := blueprint.GetBlueprint(f.portClient, blueprintId) 157 | if err != nil { 158 | t.Errorf("Error getting blueprint: %s", err.Error()) 159 | } 160 | t.Run("Check blueprint", func(t *testing.T) { 161 | if bp == nil { 162 | t.Errorf("Blueprint not found") 163 | } 164 | if bp.Schema.Properties["stringProperty"].Type != "string" { 165 | t.Errorf("stringProperty type is not string") 166 | } 167 | if bp.Schema.Properties["intProperty"].Type != "number" { 168 | t.Errorf("intProperty type is not number") 169 | } 170 | if bp.Schema.Properties["boolProperty"].Type != "boolean" { 171 | t.Errorf("boolProperty type is not boolean") 172 | } 173 | if bp.Schema.Properties["anyOfProperty"].Type != "string" { 174 | t.Errorf("anyOfProperty type is not string") 175 | } 176 | if bp.Schema.Properties["nestedProperty"].Type != "object" { 177 | t.Errorf("nestedProperty type is not object") 178 | } 179 | if namespaced { 180 | if bp.Schema.Properties["namespace"].Type != "string" { 181 | t.Errorf("namespace type is not string") 182 | } 183 | } else { 184 | if _, ok := bp.Schema.Properties["namespace"]; ok { 185 | t.Errorf("namespace should not be present") 186 | } 187 | } 188 | }) 189 | 190 | createAction, err := cli.GetAction(f.portClient, fmt.Sprintf("create_%s", blueprintId)) 191 | if err != nil { 192 | t.Errorf("Error getting create action: %s", err.Error()) 193 | } 194 | t.Run("Check create action", func(t *testing.T) { 195 | if createAction == nil { 196 | t.Errorf("Create action not found") 197 | } 198 | if createAction.Trigger.UserInputs.Properties["stringProperty"].Type != "string" { 199 | t.Errorf("stringProperty type is not string") 200 | } 201 | if createAction.Trigger.UserInputs.Properties["intProperty"].Type != "number" { 202 | t.Errorf("intProperty type is not number") 203 | } 204 | if createAction.Trigger.UserInputs.Properties["boolProperty"].Type != "boolean" { 205 | t.Errorf("boolProperty type is not boolean") 206 | } 207 | if createAction.Trigger.UserInputs.Properties["anyOfProperty"].Type != "string" { 208 | t.Errorf("anyOfProperty type is not string") 209 | } 210 | if _, ok := createAction.Trigger.UserInputs.Properties["nestedProperty"]; ok { 211 | t.Errorf("nestedProperty should not be present") 212 | } 213 | if createAction.Trigger.UserInputs.Properties["nestedProperty__nestedStringProperty"].Type != "string" { 214 | t.Errorf("nestedProperty__nestedStringProperty type is not string") 215 | } 216 | if namespaced { 217 | if createAction.Trigger.UserInputs.Properties["namespace"].Type != "string" { 218 | t.Errorf("namespace type is not string") 219 | } 220 | } else { 221 | if _, ok := createAction.Trigger.UserInputs.Properties["namespace"]; ok { 222 | t.Errorf("namespace should not be present") 223 | } 224 | } 225 | if slices.Contains(createAction.Trigger.UserInputs.Required, "stringProperty") == false { 226 | t.Errorf("stringProperty should be required") 227 | } 228 | if slices.Contains(createAction.Trigger.UserInputs.Required, "nestedProperty__nestedStringProperty") == false { 229 | t.Errorf("nestedProperty__nestedStringProperty should be required") 230 | } 231 | if slices.Contains(createAction.Trigger.UserInputs.Required, "nestedProperty") == true { 232 | t.Errorf("nestedProperty should not be required") 233 | } 234 | }) 235 | 236 | updateAction, err := cli.GetAction(f.portClient, fmt.Sprintf("update_%s", blueprintId)) 237 | if err != nil { 238 | t.Errorf("Error getting update action: %s", err.Error()) 239 | } 240 | t.Run("Check update action", func(t *testing.T) { 241 | if updateAction == nil { 242 | t.Errorf("Update action not found") 243 | } 244 | if updateAction.Trigger.UserInputs.Properties["stringProperty"].Type != "string" { 245 | t.Errorf("stringProperty type is not string") 246 | } 247 | if updateAction.Trigger.UserInputs.Properties["intProperty"].Type != "number" { 248 | t.Errorf("intProperty type is not number") 249 | } 250 | if updateAction.Trigger.UserInputs.Properties["boolProperty"].Type != "boolean" { 251 | t.Errorf("boolProperty type is not boolean") 252 | } 253 | if updateAction.Trigger.UserInputs.Properties["anyOfProperty"].Type != "string" { 254 | t.Errorf("anyOfProperty type is not string") 255 | } 256 | if _, ok := createAction.Trigger.UserInputs.Properties["nestedProperty"]; ok { 257 | t.Errorf("nestedProperty should not be present") 258 | } 259 | if createAction.Trigger.UserInputs.Properties["nestedProperty__nestedStringProperty"].Type != "string" { 260 | t.Errorf("nestedProperty__nestedStringProperty type is not string") 261 | } 262 | if _, ok := updateAction.Trigger.UserInputs.Properties["namespace"]; ok { 263 | t.Errorf("namespace should not be present") 264 | } 265 | if slices.Contains(createAction.Trigger.UserInputs.Required, "stringProperty") == false { 266 | t.Errorf("stringProperty should be required") 267 | } 268 | if slices.Contains(createAction.Trigger.UserInputs.Required, "nestedProperty__nestedStringProperty") == false { 269 | t.Errorf("nestedProperty__nestedStringProperty should be required") 270 | } 271 | if slices.Contains(createAction.Trigger.UserInputs.Required, "nestedProperty") == true { 272 | t.Errorf("nestedProperty should not be required") 273 | } 274 | }) 275 | 276 | deleteAction, err := cli.GetAction(f.portClient, fmt.Sprintf("delete_%s", blueprintId)) 277 | if err != nil { 278 | t.Errorf("Error getting delete action: %s", err.Error()) 279 | } 280 | t.Run("Check delete action", func(t *testing.T) { 281 | if deleteAction == nil { 282 | t.Errorf("Delete action not found") 283 | } 284 | // Delete action takes the namespace using control the payload feature 285 | if namespaced { 286 | if _, ok := deleteAction.Trigger.UserInputs.Properties["namespace"]; ok { 287 | t.Errorf("namespace should not be present") 288 | } 289 | } else { 290 | if _, ok := deleteAction.Trigger.UserInputs.Properties["namespace"]; ok { 291 | t.Errorf("namespace should not be present") 292 | } 293 | } 294 | }) 295 | } 296 | 297 | func TestCRD_crd_autoDiscoverCRDsToActionsClusterScoped(t *testing.T) { 298 | f := newFixture(t, "", false, "true") 299 | 300 | blueprintId := getBlueprintId(f.stateKey) 301 | 302 | AutodiscoverCRDsToActions(f.portConfig, f.apiextensionClient, f.portClient) 303 | 304 | checkBlueprintAndActionsProperties(t, f, false) 305 | 306 | testUtils.CheckResourcesExistence( 307 | true, true, f.portClient, t, 308 | []string{blueprintId}, []string{}, 309 | []string{ 310 | fmt.Sprintf("create_%s", blueprintId), 311 | fmt.Sprintf("update_%s", blueprintId), 312 | fmt.Sprintf("delete_%s", blueprintId), 313 | }, 314 | ) 315 | } 316 | 317 | func TestCRD_crd_autoDiscoverCRDsToActionsNamespaced(t *testing.T) { 318 | f := newFixture(t, "", true, "true") 319 | blueprintId := getBlueprintId(f.stateKey) 320 | 321 | AutodiscoverCRDsToActions(f.portConfig, f.apiextensionClient, f.portClient) 322 | 323 | checkBlueprintAndActionsProperties(t, f, true) 324 | 325 | testUtils.CheckResourcesExistence( 326 | true, true, f.portClient, t, 327 | []string{blueprintId}, []string{}, 328 | []string{ 329 | fmt.Sprintf("create_%s", blueprintId), 330 | fmt.Sprintf("update_%s", blueprintId), 331 | fmt.Sprintf("delete_%s", blueprintId), 332 | }, 333 | ) 334 | } 335 | 336 | func TestCRD_crd_autoDiscoverCRDsToActionsNoCRDs(t *testing.T) { 337 | f := newFixture(t, "", false, "false") 338 | blueprintId := getBlueprintId(f.stateKey) 339 | 340 | AutodiscoverCRDsToActions(f.portConfig, f.apiextensionClient, f.portClient) 341 | 342 | testUtils.CheckResourcesExistence( 343 | false, false, f.portClient, t, 344 | []string{blueprintId}, []string{}, 345 | []string{ 346 | fmt.Sprintf("create_%s", blueprintId), 347 | fmt.Sprintf("update_%s", blueprintId), 348 | fmt.Sprintf("delete_%s", blueprintId), 349 | }, 350 | ) 351 | } 352 | -------------------------------------------------------------------------------- /pkg/crd/utils.go: -------------------------------------------------------------------------------- 1 | package crd 2 | 3 | import ( 4 | v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 5 | ) 6 | 7 | func ShallowJsonSchema(schema *v1.JSONSchemaProps, separator string) *v1.JSONSchemaProps { 8 | clonedSchema := schema.DeepCopy() 9 | shallowProperties(clonedSchema, "", separator, clonedSchema) 10 | 11 | if schema.Type == "object" { 12 | return &v1.JSONSchemaProps{ 13 | Type: "object", 14 | Properties: clonedSchema.Properties, 15 | Required: shallowRequired(schema, "", separator), 16 | } 17 | } 18 | 19 | return schema 20 | } 21 | 22 | func shallowProperties(schema *v1.JSONSchemaProps, parent string, seperator string, originalSchema *v1.JSONSchemaProps) { 23 | for k, v := range schema.Properties { 24 | shallowedKey := k 25 | 26 | if parent != "" { 27 | shallowedKey = parent + seperator + k 28 | } 29 | 30 | if v.Type != "object" { 31 | originalSchema.Properties[shallowedKey] = v 32 | } else { 33 | shallowProperties(&v, shallowedKey, seperator, originalSchema) 34 | delete(originalSchema.Properties, k) 35 | } 36 | } 37 | } 38 | 39 | // shallowRequired recursively traverses the JSONSchemaProps and returns a list of required fields with nested field names concatenated by the provided separator. 40 | func shallowRequired(schema *v1.JSONSchemaProps, prefix, separator string) []string { 41 | var requiredFields []string 42 | 43 | for _, field := range schema.Required { 44 | if propSchema, ok := schema.Properties[field]; ok { 45 | fullFieldName := field 46 | if prefix != "" { 47 | fullFieldName = prefix + separator + field 48 | } 49 | 50 | if propSchema.Type == "object" { 51 | // Recursively process nested objects but don't add the object field itself 52 | nestedRequiredFields := shallowRequired(&propSchema, fullFieldName, separator) 53 | requiredFields = append(requiredFields, nestedRequiredFields...) 54 | } else { 55 | // Add non-object fields to the required list 56 | requiredFields = append(requiredFields, fullFieldName) 57 | } 58 | } 59 | } 60 | 61 | return requiredFields 62 | } 63 | -------------------------------------------------------------------------------- /pkg/crd/utils_test.go: -------------------------------------------------------------------------------- 1 | package crd 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 8 | ) 9 | 10 | func TestCRD_crd_shallowNestedSchema(t *testing.T) { 11 | originalSchema := &v1.JSONSchemaProps{ 12 | Type: "object", 13 | Properties: map[string]v1.JSONSchemaProps{ 14 | "spec": { 15 | Type: "object", 16 | Properties: map[string]v1.JSONSchemaProps{ 17 | "stringProperty": { 18 | Type: "string", 19 | }, 20 | "intProperty": { 21 | Type: "integer", 22 | }, 23 | "boolProperty": { 24 | Type: "boolean", 25 | }, 26 | "nestedProperty": { 27 | Type: "object", 28 | Properties: map[string]v1.JSONSchemaProps{ 29 | "nestedStringProperty": { 30 | Type: "string", 31 | }, 32 | "nestedIntProperty": { 33 | Type: "integer", 34 | }, 35 | }, 36 | Required: []string{"nestedStringProperty"}, 37 | }, 38 | "multiNestedProperty": { 39 | Type: "object", 40 | Properties: map[string]v1.JSONSchemaProps{ 41 | "nestedObjectProperty": { 42 | Type: "object", 43 | Properties: map[string]v1.JSONSchemaProps{ 44 | "nestedStringProperty": { 45 | Type: "string", 46 | }, 47 | }, 48 | Required: []string{"nestedStringProperty"}, 49 | }, 50 | }, 51 | Required: []string{}, 52 | }, 53 | }, 54 | Required: []string{"stringProperty", "nestedProperty", "multiNestedProperty"}, 55 | }, 56 | }, 57 | Required: []string{"spec"}, 58 | } 59 | 60 | shallowedSchema := ShallowJsonSchema(originalSchema, "__") 61 | 62 | expectedSchema := &v1.JSONSchemaProps{ 63 | Type: "object", 64 | Properties: map[string]v1.JSONSchemaProps{ 65 | "spec__stringProperty": { 66 | Type: "string", 67 | }, 68 | "spec__intProperty": { 69 | Type: "integer", 70 | }, 71 | "spec__boolProperty": { 72 | Type: "boolean", 73 | }, 74 | "spec__nestedProperty__nestedStringProperty": { 75 | Type: "string", 76 | }, 77 | "spec__nestedProperty__nestedIntProperty": { 78 | Type: "integer", 79 | }, 80 | "spec__multiNestedProperty__nestedObjectProperty__nestedStringProperty": { 81 | Type: "string", 82 | }, 83 | }, 84 | Required: []string{"spec__stringProperty", "spec__nestedProperty__nestedStringProperty"}, 85 | } 86 | 87 | if reflect.DeepEqual(shallowedSchema, expectedSchema) { 88 | t.Logf("Shallowed schema is as expected") 89 | } else { 90 | t.Errorf("Shallowed schema is not as expected") 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /pkg/defaults/defaults.go: -------------------------------------------------------------------------------- 1 | package defaults 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "sync" 7 | 8 | "github.com/port-labs/port-k8s-exporter/pkg/port" 9 | "github.com/port-labs/port-k8s-exporter/pkg/port/blueprint" 10 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 11 | "github.com/port-labs/port-k8s-exporter/pkg/port/page" 12 | "github.com/port-labs/port-k8s-exporter/pkg/port/scorecards" 13 | "gopkg.in/yaml.v3" 14 | "k8s.io/klog/v2" 15 | ) 16 | 17 | type ScorecardDefault struct { 18 | Blueprint string `json:"blueprint"` 19 | Scorecards []port.Scorecard `json:"data"` 20 | } 21 | 22 | type Defaults struct { 23 | Blueprints []port.Blueprint 24 | Scorecards []ScorecardDefault 25 | AppConfig *port.IntegrationAppConfig 26 | Pages []port.Page 27 | } 28 | 29 | var BlueprintsAsset = "assets/defaults/blueprints.json" 30 | var ScorecardsAsset = "assets/defaults/scorecards.json" 31 | var PagesAsset = "assets/defaults/pages.json" 32 | var AppConfigAsset = "assets/defaults/appConfig.yaml" 33 | 34 | func getDefaults() (*Defaults, error) { 35 | var bp []port.Blueprint 36 | file, err := os.ReadFile(BlueprintsAsset) 37 | if err != nil { 38 | klog.Infof("No default blueprints found. Skipping...") 39 | } else { 40 | err = json.Unmarshal(file, &bp) 41 | if err != nil { 42 | return nil, err 43 | } 44 | } 45 | 46 | var sc []ScorecardDefault 47 | file, err = os.ReadFile(ScorecardsAsset) 48 | if err != nil { 49 | klog.Infof("No default scorecards found. Skipping...") 50 | } else { 51 | err = json.Unmarshal(file, &sc) 52 | if err != nil { 53 | return nil, err 54 | } 55 | } 56 | 57 | var appConfig *port.IntegrationAppConfig 58 | file, err = os.ReadFile(AppConfigAsset) 59 | if err != nil { 60 | klog.Infof("No default appConfig found. Skipping...") 61 | } else { 62 | err = yaml.Unmarshal(file, &appConfig) 63 | if err != nil { 64 | return nil, err 65 | } 66 | } 67 | 68 | var pages []port.Page 69 | file, err = os.ReadFile(PagesAsset) 70 | if err != nil { 71 | klog.Infof("No default pages found. Skipping...") 72 | } else { 73 | err = yaml.Unmarshal(file, &pages) 74 | if err != nil { 75 | return nil, err 76 | } 77 | } 78 | 79 | return &Defaults{ 80 | Blueprints: bp, 81 | Scorecards: sc, 82 | AppConfig: appConfig, 83 | Pages: pages, 84 | }, nil 85 | } 86 | 87 | // deconstructBlueprintsToCreationSteps takes a list of blueprints and returns a list of blueprints with only the 88 | // required fields for creation, a list of blueprints with the required fields for creation and relations, and a list 89 | // of blueprints with all fields for creation, relations, and calculation properties. 90 | // This is done because there might be a case where a blueprint has a relation to another blueprint that 91 | // hasn't been created yet. 92 | func deconstructBlueprintsToCreationSteps(rawBlueprints []port.Blueprint) ([]port.Blueprint, [][]port.Blueprint) { 93 | var ( 94 | bareBlueprints []port.Blueprint 95 | withRelations []port.Blueprint 96 | fullBlueprints []port.Blueprint 97 | ) 98 | 99 | for _, bp := range rawBlueprints { 100 | bareBlueprint := port.Blueprint{ 101 | Identifier: bp.Identifier, 102 | Title: bp.Title, 103 | Icon: bp.Icon, 104 | Description: bp.Description, 105 | Schema: bp.Schema, 106 | } 107 | bareBlueprints = append(bareBlueprints, bareBlueprint) 108 | 109 | withRelation := bareBlueprint 110 | withRelation.Relations = bp.Relations 111 | withRelations = append(withRelations, withRelation) 112 | 113 | fullBlueprint := withRelation 114 | fullBlueprint.AggregationProperties = bp.AggregationProperties 115 | fullBlueprint.CalculationProperties = bp.CalculationProperties 116 | fullBlueprint.MirrorProperties = bp.MirrorProperties 117 | fullBlueprints = append(fullBlueprints, fullBlueprint) 118 | } 119 | 120 | return bareBlueprints, [][]port.Blueprint{withRelations, fullBlueprints} 121 | } 122 | 123 | type AbortDefaultCreationError struct { 124 | BlueprintsToRollback []string 125 | PagesToRollback []string 126 | Errors []error 127 | } 128 | 129 | func (e *AbortDefaultCreationError) Error() string { 130 | return "AbortDefaultCreationError" 131 | } 132 | 133 | func createResources(portClient *cli.PortClient, defaults *Defaults, shouldCreatePageForBlueprints bool) error { 134 | existingBlueprints := []string{} 135 | for _, bp := range defaults.Blueprints { 136 | if _, err := blueprint.GetBlueprint(portClient, bp.Identifier); err == nil { 137 | existingBlueprints = append(existingBlueprints, bp.Identifier) 138 | } 139 | } 140 | 141 | if len(existingBlueprints) > 0 { 142 | klog.Infof("Found existing blueprints: %v, skipping default resources creation", existingBlueprints) 143 | return nil 144 | } 145 | 146 | bareBlueprints, patchStages := deconstructBlueprintsToCreationSteps(defaults.Blueprints) 147 | waitGroup := sync.WaitGroup{} 148 | var resourceErrors []error 149 | mutex := sync.Mutex{} 150 | createdBlueprints := []string{} 151 | 152 | for _, bp := range bareBlueprints { 153 | waitGroup.Add(1) 154 | go func(bp port.Blueprint) { 155 | defer waitGroup.Done() 156 | var result *port.Blueprint 157 | var err error 158 | if shouldCreatePageForBlueprints { 159 | result, err = blueprint.NewBlueprint(portClient, bp) 160 | } else { 161 | result, err = blueprint.NewBlueprintWithoutPage(portClient, bp) 162 | } 163 | mutex.Lock() 164 | if err != nil { 165 | klog.Warningf("Failed to create blueprint %s: %v", bp.Identifier, err.Error()) 166 | resourceErrors = append(resourceErrors, err) 167 | } else { 168 | klog.Infof("Created blueprint %s", result.Identifier) 169 | createdBlueprints = append(createdBlueprints, bp.Identifier) 170 | } 171 | mutex.Unlock() 172 | }(bp) 173 | } 174 | waitGroup.Wait() 175 | 176 | if len(resourceErrors) > 0 { 177 | return &AbortDefaultCreationError{ 178 | BlueprintsToRollback: createdBlueprints, 179 | Errors: resourceErrors, 180 | } 181 | } 182 | 183 | for _, patchStage := range patchStages { 184 | for _, bp := range patchStage { 185 | waitGroup.Add(1) 186 | go func(bp port.Blueprint) { 187 | defer waitGroup.Done() 188 | if _, err := blueprint.PatchBlueprint(portClient, bp); err != nil { 189 | mutex.Lock() 190 | klog.Warningf("Failed to patch blueprint %s: %v", bp.Identifier, err.Error()) 191 | resourceErrors = append(resourceErrors, err) 192 | mutex.Unlock() 193 | } 194 | }(bp) 195 | } 196 | waitGroup.Wait() 197 | 198 | if len(resourceErrors) > 0 { 199 | return &AbortDefaultCreationError{ 200 | BlueprintsToRollback: createdBlueprints, 201 | Errors: resourceErrors, 202 | } 203 | } 204 | } 205 | 206 | for _, blueprintScorecards := range defaults.Scorecards { 207 | for _, scorecard := range blueprintScorecards.Scorecards { 208 | waitGroup.Add(1) 209 | go func(blueprintIdentifier string, scorecard port.Scorecard) { 210 | defer waitGroup.Done() 211 | if err := scorecards.CreateScorecard(portClient, blueprintIdentifier, scorecard); err != nil { 212 | klog.Warningf("Failed to create scorecard for blueprint %s: %v", blueprintIdentifier, err.Error()) 213 | } 214 | }(blueprintScorecards.Blueprint, scorecard) 215 | } 216 | } 217 | waitGroup.Wait() 218 | 219 | for _, pageToCreate := range defaults.Pages { 220 | waitGroup.Add(1) 221 | go func(p port.Page) { 222 | defer waitGroup.Done() 223 | if err := page.CreatePage(portClient, p); err != nil { 224 | klog.Warningf("Failed to create page %s: %v", p.Identifier, err.Error()) 225 | } else { 226 | klog.Infof("Created page %s", p.Identifier) 227 | } 228 | }(pageToCreate) 229 | } 230 | waitGroup.Wait() 231 | 232 | return nil 233 | } 234 | 235 | func initializeDefaults(portClient *cli.PortClient, defaults *Defaults, shouldCreatePageForBlueprints bool) error { 236 | if err := createResources(portClient, defaults, shouldCreatePageForBlueprints); err != nil { 237 | if abortErr, ok := err.(*AbortDefaultCreationError); ok { 238 | klog.Warningf("Rolling back blueprints due to creation error") 239 | for _, blueprintID := range abortErr.BlueprintsToRollback { 240 | if err := blueprint.DeleteBlueprint(portClient, blueprintID); err != nil { 241 | klog.Warningf("Failed to rollback blueprint %s: %v", blueprintID, err) 242 | } else { 243 | klog.Infof("Successfully rolled back blueprint %s", blueprintID) 244 | } 245 | } 246 | } 247 | klog.Warningf("Error creating default resources: %v", err) 248 | return err 249 | } 250 | 251 | return nil 252 | } 253 | 254 | type ExceptionGroup struct { 255 | Message string 256 | Errors []error 257 | } 258 | 259 | func (e *ExceptionGroup) Error() string { 260 | return e.Message 261 | } 262 | -------------------------------------------------------------------------------- /pkg/defaults/defaults_test.go: -------------------------------------------------------------------------------- 1 | package defaults 2 | 3 | import ( 4 | "testing" 5 | 6 | guuid "github.com/google/uuid" 7 | "github.com/port-labs/port-k8s-exporter/pkg/config" 8 | "github.com/port-labs/port-k8s-exporter/pkg/port" 9 | "github.com/port-labs/port-k8s-exporter/pkg/port/blueprint" 10 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 11 | "github.com/port-labs/port-k8s-exporter/pkg/port/integration" 12 | "github.com/port-labs/port-k8s-exporter/pkg/port/page" 13 | testUtils "github.com/port-labs/port-k8s-exporter/test_utils" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | type Fixture struct { 18 | t *testing.T 19 | portClient *cli.PortClient 20 | stateKey string 21 | } 22 | 23 | func tearDownFixture( 24 | t *testing.T, 25 | f *Fixture, 26 | ) { 27 | t.Logf("Deleting default resources for %s", f.stateKey) 28 | deleteDefaultResources(f.portClient, f.stateKey) 29 | } 30 | 31 | func NewFixture(t *testing.T) *Fixture { 32 | stateKey := guuid.NewString() 33 | portClient := cli.New(config.ApplicationConfig) 34 | 35 | deleteDefaultResources(portClient, stateKey) 36 | return &Fixture{ 37 | t: t, 38 | portClient: portClient, 39 | stateKey: stateKey, 40 | } 41 | } 42 | 43 | func (f *Fixture) CreateIntegration() { 44 | _, err := integration.CreateIntegration(f.portClient, f.stateKey, "", &port.IntegrationAppConfig{ 45 | Resources: []port.Resource{}, 46 | }, false) 47 | 48 | if err != nil { 49 | f.t.Errorf("Error creating Port integration: %s", err.Error()) 50 | } 51 | } 52 | 53 | func (f *Fixture) CleanIntegration() { 54 | _ = integration.DeleteIntegration(f.portClient, f.stateKey) 55 | } 56 | 57 | func deleteDefaultResources(portClient *cli.PortClient, stateKey string) { 58 | _ = integration.DeleteIntegration(portClient, stateKey) 59 | _ = blueprint.DeleteBlueprintEntities(portClient, "workload") 60 | _ = blueprint.DeleteBlueprint(portClient, "workload") 61 | _ = blueprint.DeleteBlueprintEntities(portClient, "namespace") 62 | _ = blueprint.DeleteBlueprint(portClient, "namespace") 63 | _ = blueprint.DeleteBlueprintEntities(portClient, "cluster") 64 | _ = blueprint.DeleteBlueprint(portClient, "cluster") 65 | _ = page.DeletePage(portClient, "workload_overview_dashboard") 66 | _ = page.DeletePage(portClient, "availability_scorecard_dashboard") 67 | } 68 | 69 | func Test_InitIntegration_InitDefaults(t *testing.T) { 70 | f := NewFixture(t) 71 | defer tearDownFixture(t, f) 72 | e := InitIntegration(f.portClient, &port.Config{ 73 | StateKey: f.stateKey, 74 | EventListenerType: "POLLING", 75 | CreateDefaultResources: true, 76 | CreatePortResourcesOrigin: port.CreatePortResourcesOriginK8S, 77 | }, true) 78 | assert.Nil(t, e) 79 | 80 | _, err := integration.GetIntegration(f.portClient, f.stateKey) 81 | assert.Nil(t, err) 82 | 83 | _, err = blueprint.GetBlueprint(f.portClient, "workload") 84 | assert.Nil(t, err) 85 | 86 | _, err = blueprint.GetBlueprint(f.portClient, "namespace") 87 | assert.Nil(t, err) 88 | 89 | _, err = blueprint.GetBlueprint(f.portClient, "cluster") 90 | assert.Nil(t, err) 91 | 92 | _, err = page.GetPage(f.portClient, "workload_overview_dashboard") 93 | assert.Nil(t, err) 94 | 95 | _, err = page.GetPage(f.portClient, "availability_scorecard_dashboard") 96 | assert.Nil(t, err) 97 | } 98 | 99 | func Test_InitIntegration_InitDefaults_CreateDefaultResources_False(t *testing.T) { 100 | f := NewFixture(t) 101 | defer tearDownFixture(t, f) 102 | e := InitIntegration(f.portClient, &port.Config{ 103 | StateKey: f.stateKey, 104 | EventListenerType: "POLLING", 105 | CreateDefaultResources: false, 106 | CreatePortResourcesOrigin: port.CreatePortResourcesOriginK8S, 107 | }, true) 108 | assert.Nil(t, e) 109 | 110 | _, err := integration.GetIntegration(f.portClient, f.stateKey) 111 | assert.Nil(t, err) 112 | 113 | testUtils.CheckResourcesExistence(false, false, f.portClient, f.t, []string{"workload", "namespace", "cluster"}, []string{"workload_overview_dashboard", "availability_scorecard_dashboard"}, []string{}) 114 | } 115 | 116 | func Test_InitIntegration_BlueprintExists(t *testing.T) { 117 | f := NewFixture(t) 118 | defer tearDownFixture(t, f) 119 | if _, err := blueprint.NewBlueprint(f.portClient, port.Blueprint{ 120 | Identifier: "workload", 121 | Title: "Workload", 122 | Schema: port.BlueprintSchema{ 123 | Properties: map[string]port.Property{}, 124 | }, 125 | }); err != nil { 126 | t.Errorf("Error creating Port blueprint: %s", err.Error()) 127 | } 128 | e := InitIntegration(f.portClient, &port.Config{ 129 | StateKey: f.stateKey, 130 | EventListenerType: "POLLING", 131 | CreateDefaultResources: true, 132 | CreatePortResourcesOrigin: port.CreatePortResourcesOriginK8S, 133 | }, true) 134 | assert.Nil(t, e) 135 | 136 | i, err := integration.GetIntegration(f.portClient, f.stateKey) 137 | assert.NotNil(t, i.Config.Resources) 138 | assert.Nil(t, err) 139 | 140 | _, err = blueprint.GetBlueprint(f.portClient, "workload") 141 | assert.Nil(t, err) 142 | 143 | testUtils.CheckResourcesExistence(false, false, f.portClient, f.t, []string{"namespace", "cluster"}, []string{"workload_overview_dashboard", "availability_scorecard_dashboard"}, []string{}) 144 | } 145 | 146 | func Test_InitIntegration_PageExists(t *testing.T) { 147 | f := NewFixture(t) 148 | defer tearDownFixture(t, f) 149 | if err := page.CreatePage(f.portClient, port.Page{ 150 | Identifier: "workload_overview_dashboard", 151 | Title: "Workload Overview Dashboard", 152 | }); err != nil { 153 | t.Errorf("Error creating Port page: %s", err.Error()) 154 | } 155 | e := InitIntegration(f.portClient, &port.Config{ 156 | StateKey: f.stateKey, 157 | EventListenerType: "POLLING", 158 | CreateDefaultResources: true, 159 | CreatePortResourcesOrigin: port.CreatePortResourcesOriginK8S, 160 | }, true) 161 | assert.Nil(t, e) 162 | 163 | i, err := integration.GetIntegration(f.portClient, f.stateKey) 164 | assert.NotNil(t, i.Config.Resources) 165 | assert.Nil(t, err) 166 | 167 | _, err = page.GetPage(f.portClient, "workload_overview_dashboard") 168 | assert.Nil(t, err) 169 | 170 | testUtils.CheckResourcesExistence(true, false, f.portClient, f.t, []string{"workload", "namespace", "cluster"}, []string{"availability_scorecard_dashboard"}, []string{}) 171 | } 172 | 173 | func Test_InitIntegration_ExistingIntegration(t *testing.T) { 174 | f := NewFixture(t) 175 | defer tearDownFixture(t, f) 176 | _, err := integration.CreateIntegration(f.portClient, f.stateKey, "", nil, false) 177 | if err != nil { 178 | t.Errorf("Error creating Port integration: %s", err.Error()) 179 | } 180 | e := InitIntegration(f.portClient, &port.Config{ 181 | StateKey: f.stateKey, 182 | EventListenerType: "POLLING", 183 | CreateDefaultResources: true, 184 | CreatePortResourcesOrigin: port.CreatePortResourcesOriginK8S, 185 | }, true) 186 | assert.Nil(t, e) 187 | 188 | _, err = integration.GetIntegration(f.portClient, f.stateKey) 189 | assert.Nil(t, err) 190 | 191 | testUtils.CheckResourcesExistence(true, false, f.portClient, f.t, []string{"workload", "namespace", "cluster"}, []string{"workload_overview_dashboard", "availability_scorecard_dashboard"}, []string{}) 192 | } 193 | 194 | func Test_InitIntegration_LocalResourcesConfiguration(t *testing.T) { 195 | f := NewFixture(t) 196 | defer tearDownFixture(t, f) 197 | _, err := integration.CreateIntegration(f.portClient, f.stateKey, "", nil, false) 198 | if err != nil { 199 | t.Errorf("Error creating Port integration: %s", err.Error()) 200 | } 201 | expectedResources := []port.Resource{ 202 | { 203 | Kind: "workload", 204 | Port: port.Port{ 205 | Entity: port.EntityMappings{ 206 | Mappings: []port.EntityMapping{ 207 | { 208 | Identifier: "\"workload\"", 209 | Title: "\"Workload\"", 210 | Blueprint: "\"workload\"", 211 | Icon: "\"Microservice\"", 212 | Properties: map[string]string{ 213 | "namespace": "\"default\"", 214 | }, 215 | }, 216 | }, 217 | }, 218 | }, 219 | }, 220 | } 221 | e := InitIntegration(f.portClient, &port.Config{ 222 | StateKey: f.stateKey, 223 | EventListenerType: "POLLING", 224 | Resources: expectedResources, 225 | CreateDefaultResources: true, 226 | CreatePortResourcesOrigin: port.CreatePortResourcesOriginK8S, 227 | }, true) 228 | assert.Nil(t, e) 229 | 230 | i, err := integration.GetIntegration(f.portClient, f.stateKey) 231 | assert.Equal(t, expectedResources, i.Config.Resources) 232 | assert.Nil(t, err) 233 | 234 | testUtils.CheckResourcesExistence(true, false, f.portClient, f.t, []string{"workload", "namespace", "cluster"}, []string{"workload_overview_dashboard", "availability_scorecard_dashboard"}, []string{}) 235 | } 236 | 237 | func Test_InitIntegration_LocalResourcesConfiguration_ExistingIntegration_EmptyConfiguration(t *testing.T) { 238 | f := NewFixture(t) 239 | defer tearDownFixture(t, f) 240 | _, err := integration.CreateIntegration(f.portClient, f.stateKey, "POLLING", nil, false) 241 | if err != nil { 242 | t.Errorf("Error creating Port integration: %s", err.Error()) 243 | } 244 | e := InitIntegration(f.portClient, &port.Config{ 245 | StateKey: f.stateKey, 246 | EventListenerType: "KAFKA", 247 | Resources: nil, 248 | CreateDefaultResources: true, 249 | CreatePortResourcesOrigin: port.CreatePortResourcesOriginK8S, 250 | }, true) 251 | assert.Nil(t, e) 252 | 253 | i, err := integration.GetIntegration(f.portClient, f.stateKey) 254 | assert.Nil(t, err) 255 | assert.Equal(t, "KAFKA", i.EventListener.Type) 256 | 257 | testUtils.CheckResourcesExistence(true, false, f.portClient, f.t, []string{"workload", "namespace", "cluster"}, []string{"workload_overview_dashboard", "availability_scorecard_dashboard"}, []string{}) 258 | } 259 | 260 | func Test_InitIntegration_LocalResourcesConfiguration_ExistingIntegration_WithConfiguration_WithOverwriteConfigurationOnRestartFlag(t *testing.T) { 261 | f := NewFixture(t) 262 | defer tearDownFixture(t, f) 263 | 264 | expectedConfig := &port.IntegrationAppConfig{ 265 | Resources: []port.Resource{ 266 | { 267 | Kind: "workload", 268 | Port: port.Port{ 269 | Entity: port.EntityMappings{ 270 | Mappings: []port.EntityMapping{ 271 | { 272 | Identifier: "\"workload\"", 273 | Title: "\"Workload\"", 274 | Blueprint: "\"workload\"", 275 | Icon: "\"Microservice\"", 276 | Properties: map[string]string{ 277 | "namespace": "\"default\"", 278 | }, 279 | }, 280 | }, 281 | }, 282 | }, 283 | }, 284 | }, 285 | } 286 | _, err := integration.CreateIntegration(f.portClient, f.stateKey, "POLLING", expectedConfig, false) 287 | if err != nil { 288 | t.Errorf("Error creating Port integration: %s", err.Error()) 289 | } 290 | 291 | expectedConfig.Resources[0].Kind = "namespace" 292 | e := InitIntegration(f.portClient, &port.Config{ 293 | StateKey: f.stateKey, 294 | EventListenerType: "KAFKA", 295 | Resources: expectedConfig.Resources, 296 | CreateDefaultResources: true, 297 | CreatePortResourcesOrigin: port.CreatePortResourcesOriginK8S, 298 | OverwriteConfigurationOnRestart: true, 299 | }, true) 300 | assert.Nil(t, e) 301 | 302 | i, err := integration.GetIntegration(f.portClient, f.stateKey) 303 | assert.Nil(t, err) 304 | assert.Equal(t, expectedConfig.Resources, i.Config.Resources) 305 | 306 | testUtils.CheckResourcesExistence(true, false, f.portClient, f.t, []string{"workload", "namespace", "cluster"}, []string{"workload_overview_dashboard", "availability_scorecard_dashboard"}, []string{}) 307 | } 308 | -------------------------------------------------------------------------------- /pkg/defaults/init.go: -------------------------------------------------------------------------------- 1 | package defaults 2 | 3 | import ( 4 | "github.com/port-labs/port-k8s-exporter/pkg/port" 5 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port/integration" 7 | "github.com/port-labs/port-k8s-exporter/pkg/port/org_details" 8 | "k8s.io/klog/v2" 9 | ) 10 | 11 | func getEventListenerConfig(eventListenerType string) *port.EventListenerSettings { 12 | if eventListenerType == "KAFKA" { 13 | return &port.EventListenerSettings{ 14 | Type: eventListenerType, 15 | } 16 | } 17 | return nil 18 | } 19 | 20 | func isPortProvisioningSupported(portClient *cli.PortClient) (bool, error) { 21 | klog.Info("Resources origin is set to be Port, verifying integration is supported") 22 | featureFlags, err := org_details.GetOrganizationFeatureFlags(portClient) 23 | if err != nil { 24 | return false, err 25 | } 26 | 27 | for _, flag := range featureFlags { 28 | if flag == port.OrgUseProvisionedDefaultsFeatureFlag { 29 | return true, nil 30 | } 31 | } 32 | 33 | klog.Info("Port origin for Integration is not supported, changing resources origin to use K8S") 34 | return false, nil 35 | } 36 | 37 | func InitIntegration(portClient *cli.PortClient, applicationConfig *port.Config, isTest bool) error { 38 | klog.Infof("Initializing Port integration") 39 | defaults, err := getDefaults() 40 | if err != nil { 41 | return err 42 | } 43 | 44 | // Verify Port origin is supported via feature flags 45 | if applicationConfig.CreatePortResourcesOrigin == port.CreatePortResourcesOriginPort { 46 | shouldProvisionResourcesUsingPort, err := isPortProvisioningSupported(portClient) 47 | if err != nil { 48 | return err 49 | } 50 | if !shouldProvisionResourcesUsingPort { 51 | applicationConfig.CreatePortResourcesOrigin = port.CreatePortResourcesOriginK8S 52 | } 53 | } 54 | 55 | existingIntegration, err := integration.GetIntegration(portClient, applicationConfig.StateKey) 56 | defaultIntegrationConfig := &port.IntegrationAppConfig{ 57 | Resources: applicationConfig.Resources, 58 | CRDSToDiscover: applicationConfig.CRDSToDiscover, 59 | OverwriteCRDsActions: applicationConfig.OverwriteCRDsActions, 60 | DeleteDependents: applicationConfig.DeleteDependents, 61 | CreateMissingRelatedEntities: applicationConfig.CreateMissingRelatedEntities, 62 | } 63 | 64 | if err != nil { 65 | if applicationConfig.CreateDefaultResources { 66 | if applicationConfig.CreatePortResourcesOrigin != port.CreatePortResourcesOriginPort { 67 | defaultIntegrationConfig = defaults.AppConfig 68 | } 69 | } 70 | 71 | klog.Warningf("Could not get integration with state key %s, error: %s", applicationConfig.StateKey, err.Error()) 72 | shouldCreateResourcesUsingPort := applicationConfig.CreatePortResourcesOrigin == port.CreatePortResourcesOriginPort 73 | _, err := integration.CreateIntegration(portClient, applicationConfig.StateKey, applicationConfig.EventListenerType, defaultIntegrationConfig, shouldCreateResourcesUsingPort) 74 | if err != nil { 75 | return err 76 | } 77 | } else { 78 | klog.Infof("Integration with state key %s already exists, patching it", applicationConfig.StateKey) 79 | integrationPatch := &port.Integration{ 80 | EventListener: getEventListenerConfig(applicationConfig.EventListenerType), 81 | } 82 | 83 | if (existingIntegration.Config == nil && !(applicationConfig.CreatePortResourcesOrigin == port.CreatePortResourcesOriginPort)) || applicationConfig.OverwriteConfigurationOnRestart { 84 | integrationPatch.Config = defaultIntegrationConfig 85 | } 86 | 87 | if err := integration.PatchIntegration(portClient, applicationConfig.StateKey, integrationPatch); err != nil { 88 | return err 89 | } 90 | } 91 | 92 | if applicationConfig.CreateDefaultResources && applicationConfig.CreatePortResourcesOrigin != port.CreatePortResourcesOriginPort { 93 | klog.Infof("Creating default resources (blueprints, pages, etc..)") 94 | if err := initializeDefaults(portClient, defaults, !isTest); err != nil { 95 | klog.Warningf("Error initializing defaults: %s", err.Error()) 96 | klog.Warningf("Some default resources may not have been created. The integration will continue running.") 97 | } 98 | } 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/event_handler/consumer/consumer.go: -------------------------------------------------------------------------------- 1 | package consumer 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/confluentinc/confluent-kafka-go/v2/kafka" 9 | "github.com/port-labs/port-k8s-exporter/pkg/config" 10 | "k8s.io/klog/v2" 11 | ) 12 | 13 | type IConsume interface { 14 | SubscribeTopics(topics []string, rebalanceCb kafka.RebalanceCb) (err error) 15 | Poll(timeoutMs int) (event kafka.Event) 16 | Commit() (offsets []kafka.TopicPartition, err error) 17 | Close() (err error) 18 | } 19 | 20 | type Consumer struct { 21 | client IConsume 22 | enabled bool 23 | } 24 | 25 | type JsonHandler func(value []byte) 26 | 27 | func NewConsumer(config *config.KafkaConfiguration, overrideKafkaConsumer IConsume) (*Consumer, error) { 28 | c := overrideKafkaConsumer 29 | var err error 30 | if overrideKafkaConsumer == nil { 31 | c, err = kafka.NewConsumer(&kafka.ConfigMap{ 32 | "bootstrap.servers": config.Brokers, 33 | "client.id": "port-k8s-exporter", 34 | "group.id": config.GroupID, 35 | "security.protocol": config.SecurityProtocol, 36 | "sasl.mechanism": config.AuthenticationMechanism, 37 | "sasl.username": config.Username, 38 | "sasl.password": config.Password, 39 | "auto.offset.reset": "latest", 40 | }) 41 | 42 | if err != nil { 43 | return nil, err 44 | } 45 | } 46 | 47 | return &Consumer{client: c, enabled: true}, nil 48 | } 49 | 50 | func (c *Consumer) Consume(topic string, handler JsonHandler, readyChan chan bool) { 51 | topics := []string{topic} 52 | ready := false 53 | 54 | rebalanceCallback := func(c *kafka.Consumer, event kafka.Event) error { 55 | switch ev := event.(type) { 56 | case kafka.AssignedPartitions: 57 | klog.Infof("partition(s) assigned: %v\n", ev.Partitions) 58 | if readyChan != nil && ready == false { 59 | close(readyChan) 60 | ready = true 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | 67 | if err := c.client.SubscribeTopics(topics, rebalanceCallback); err != nil { 68 | klog.Fatalf("Error subscribing to topic: %s", err.Error()) 69 | } 70 | sigChan := make(chan os.Signal, 1) 71 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 72 | 73 | klog.Infof("Waiting for assigned partitions...") 74 | 75 | go func() { 76 | sig := <-sigChan 77 | klog.Infof("Caught signal %v: terminating\n", sig) 78 | c.Close() 79 | }() 80 | 81 | for c.enabled { 82 | ev := c.client.Poll(100) 83 | if ev == nil { 84 | continue 85 | } 86 | 87 | switch e := ev.(type) { 88 | case *kafka.Message: 89 | klog.Infof("%% New event\n%s\n", 90 | string(e.Value)) 91 | 92 | handler(e.Value) 93 | if _, err := c.client.Commit(); err != nil { 94 | klog.Errorf("Error committing offset: %s", err.Error()) 95 | } 96 | case kafka.Error: 97 | klog.Infof("%% Error: %v\n", e) 98 | default: 99 | klog.Infof("Ignored %v\n", e) 100 | } 101 | } 102 | klog.Infof("Closed consumer\n") 103 | } 104 | 105 | func (c *Consumer) Close() { 106 | if err := c.client.Close(); err != nil { 107 | klog.Fatalf("Error closing consumer: %s", err.Error()) 108 | } 109 | c.enabled = false 110 | } 111 | -------------------------------------------------------------------------------- /pkg/event_handler/consumer/consumer_test.go: -------------------------------------------------------------------------------- 1 | package consumer 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/confluentinc/confluent-kafka-go/v2/kafka" 8 | 9 | "github.com/port-labs/port-k8s-exporter/pkg/config" 10 | ) 11 | 12 | type Fixture struct { 13 | t *testing.T 14 | mockKafkaConsumer *MockConsumer 15 | consumer *Consumer 16 | topic string 17 | } 18 | 19 | type MockConsumer struct { 20 | pollData kafka.Event 21 | close func() 22 | } 23 | 24 | func (m *MockConsumer) SubscribeTopics(topics []string, rebalanceCb kafka.RebalanceCb) (err error) { 25 | _ = rebalanceCb(nil, kafka.AssignedPartitions{ 26 | Partitions: []kafka.TopicPartition{ 27 | { 28 | Topic: &topics[0], 29 | Offset: 0, 30 | Partition: 0, 31 | Metadata: nil, 32 | Error: nil, 33 | }, 34 | }, 35 | }) 36 | return nil 37 | } 38 | 39 | func (m *MockConsumer) Poll(timeoutMs int) (event kafka.Event) { 40 | // The consumer will poll this in while true loop so we need to close it inorder not to spam the logs 41 | defer func() { 42 | m.close() 43 | }() 44 | return m.pollData 45 | } 46 | 47 | func (m *MockConsumer) Commit() (offsets []kafka.TopicPartition, err error) { 48 | return nil, nil 49 | } 50 | 51 | func (m *MockConsumer) Close() (err error) { 52 | return nil 53 | } 54 | 55 | func NewFixture(t *testing.T) *Fixture { 56 | mock := &MockConsumer{} 57 | consumer, err := NewConsumer(&config.KafkaConfiguration{}, mock) 58 | mock.close = consumer.Close 59 | if err != nil { 60 | t.Fatalf("Error creating consumer: %v", err) 61 | } 62 | 63 | return &Fixture{ 64 | t: t, 65 | mockKafkaConsumer: mock, 66 | consumer: consumer, 67 | topic: "test-topic", 68 | } 69 | } 70 | 71 | func (f *Fixture) Produce(t *testing.T, value []byte) { 72 | f.mockKafkaConsumer.pollData = &kafka.Message{ 73 | TopicPartition: kafka.TopicPartition{Topic: &f.topic, Partition: 0}, 74 | Value: value, 75 | } 76 | } 77 | 78 | func (f *Fixture) Consume(handler JsonHandler) { 79 | readyChan := make(chan bool) 80 | go f.consumer.Consume(f.topic, handler, readyChan) 81 | <-readyChan 82 | } 83 | 84 | type MockJsonHandler struct { 85 | CapturedValue []byte 86 | } 87 | 88 | func (m *MockJsonHandler) HandleJson(value []byte) { 89 | m.CapturedValue = value 90 | } 91 | 92 | func TestConsumer_HandleJson(t *testing.T) { 93 | f := NewFixture(t) 94 | mockHandler := &MockJsonHandler{} 95 | 96 | f.Consume(mockHandler.HandleJson) 97 | 98 | f.Produce(t, []byte("test-value")) 99 | time.Sleep(time.Second * 2) 100 | 101 | if len(mockHandler.CapturedValue) == 0 { 102 | t.Error("Handler was not called") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/event_handler/consumer/event_listener.go: -------------------------------------------------------------------------------- 1 | package consumer 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/port-labs/port-k8s-exporter/pkg/config" 7 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 8 | "github.com/port-labs/port-k8s-exporter/pkg/port/kafka_credentials" 9 | "github.com/port-labs/port-k8s-exporter/pkg/port/org_details" 10 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 11 | "k8s.io/klog/v2" 12 | ) 13 | 14 | type EventListener struct { 15 | stateKey string 16 | portClient *cli.PortClient 17 | topic string 18 | consumer *Consumer 19 | } 20 | 21 | type IncomingMessage struct { 22 | Diff *struct { 23 | After *struct { 24 | Identifier string `json:"installationId"` 25 | } `json:"after"` 26 | } `json:"diff"` 27 | } 28 | 29 | func NewEventListener(stateKey string, portClient *cli.PortClient) (*EventListener, error) { 30 | klog.Infof("Getting Consumer Information") 31 | credentials, err := kafka_credentials.GetKafkaCredentials(portClient) 32 | if err != nil { 33 | return nil, err 34 | } 35 | orgId, err := org_details.GetOrgId(portClient) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | c := &config.KafkaConfiguration{ 41 | Brokers: config.KafkaConfig.Brokers, 42 | SecurityProtocol: config.KafkaConfig.SecurityProtocol, 43 | AuthenticationMechanism: config.KafkaConfig.AuthenticationMechanism, 44 | Username: credentials.Username, 45 | Password: credentials.Password, 46 | GroupID: orgId + ".k8s." + stateKey, 47 | } 48 | 49 | topic := orgId + ".change.log" 50 | instance, err := NewConsumer(c, nil) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return &EventListener{ 56 | stateKey: stateKey, 57 | portClient: portClient, 58 | topic: topic, 59 | consumer: instance, 60 | }, nil 61 | } 62 | 63 | func shouldResync(stateKey string, message *IncomingMessage) bool { 64 | return message.Diff != nil && 65 | message.Diff.After != nil && 66 | message.Diff.After.Identifier != "" && 67 | message.Diff.After.Identifier == stateKey 68 | } 69 | 70 | func (l *EventListener) Run(resync func()) error { 71 | klog.Infof("Starting Kafka event listener") 72 | 73 | klog.Infof("Starting consumer for topic %s", l.topic) 74 | l.consumer.Consume(l.topic, func(value []byte) { 75 | incomingMessage := &IncomingMessage{} 76 | parsingError := json.Unmarshal(value, &incomingMessage) 77 | if parsingError != nil { 78 | utilruntime.HandleError(fmt.Errorf("error handling message: %s", parsingError.Error())) 79 | } else if shouldResync(l.stateKey, incomingMessage) { 80 | klog.Infof("Changes detected. Resyncing...") 81 | resync() 82 | } 83 | }, nil) 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/event_handler/event_handler.go: -------------------------------------------------------------------------------- 1 | package event_handler 2 | 3 | import ( 4 | "fmt" 5 | "github.com/port-labs/port-k8s-exporter/pkg/handlers" 6 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 7 | "k8s.io/klog/v2" 8 | ) 9 | 10 | type IListener interface { 11 | Run(resync func()) error 12 | } 13 | 14 | type IStoppableRsync interface { 15 | Stop() 16 | } 17 | 18 | func Start(eventListener IListener, initControllerHandler func() (IStoppableRsync, error)) error { 19 | controllerHandler, err := initControllerHandler() 20 | if err != nil { 21 | utilruntime.HandleError(fmt.Errorf("error resyncing: %s", err.Error())) 22 | } 23 | 24 | return eventListener.Run(func() { 25 | klog.Infof("Resync request received. Recreating controllers for the new port configuration") 26 | if controllerHandler != (*handlers.ControllersHandler)(nil) { 27 | controllerHandler.Stop() 28 | } 29 | 30 | newController, resyncErr := initControllerHandler() 31 | controllerHandler = newController 32 | 33 | if resyncErr != nil { 34 | utilruntime.HandleError(fmt.Errorf("error resyncing: %s", resyncErr.Error())) 35 | } 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/event_handler/event_handler_test.go: -------------------------------------------------------------------------------- 1 | package event_handler 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | type fixture struct { 9 | t *testing.T 10 | } 11 | 12 | type ControllerHandlerMock struct { 13 | stopped bool 14 | } 15 | 16 | func (c *ControllerHandlerMock) Stop() { 17 | c.stopped = true 18 | } 19 | 20 | type EventListenerMock struct { 21 | } 22 | 23 | func (e *EventListenerMock) Run(resync func()) error { 24 | resync() 25 | resync() 26 | return nil 27 | } 28 | 29 | func TestStartKafkaEventListener(t *testing.T) { 30 | // Test should get a new controller handler on each call to the passed function and stop the previous one 31 | // The flow for this test will be: create controller handler -> resync and stop the controller handler & create a 32 | // new controller handler X 2 which will result the last controller handler to not be stopped 33 | eventListenerMock := &EventListenerMock{} 34 | firstResponse := &ControllerHandlerMock{} 35 | secondResponse := &ControllerHandlerMock{} 36 | thirdResponse := &ControllerHandlerMock{} 37 | responses := []*ControllerHandlerMock{ 38 | firstResponse, 39 | secondResponse, 40 | thirdResponse, 41 | } 42 | 43 | err := Start(eventListenerMock, func() (IStoppableRsync, error) { 44 | r := responses[0] 45 | responses = responses[1:] 46 | 47 | return r, nil 48 | }) 49 | 50 | if err != nil { 51 | t.Errorf("Expected no error, got %s", err.Error()) 52 | } 53 | 54 | assert.True(t, firstResponse.stopped) 55 | assert.True(t, secondResponse.stopped) 56 | assert.False(t, thirdResponse.stopped) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/event_handler/event_listener_factory.go: -------------------------------------------------------------------------------- 1 | package event_handler 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/port-labs/port-k8s-exporter/pkg/event_handler/consumer" 7 | "github.com/port-labs/port-k8s-exporter/pkg/event_handler/polling" 8 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 9 | "k8s.io/klog/v2" 10 | ) 11 | 12 | func CreateEventListener(stateKey string, eventListenerType string, portClient *cli.PortClient) (IListener, error) { 13 | klog.Infof("Received event listener type: %s", eventListenerType) 14 | switch eventListenerType { 15 | case "KAFKA": 16 | return consumer.NewEventListener(stateKey, portClient) 17 | case "POLLING": 18 | return polling.NewEventListener(stateKey, portClient), nil 19 | default: 20 | return nil, fmt.Errorf("unknown event listener type: %s", eventListenerType) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/event_handler/polling/event_listener.go: -------------------------------------------------------------------------------- 1 | package polling 2 | 3 | import ( 4 | "github.com/port-labs/port-k8s-exporter/pkg/config" 5 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 6 | "k8s.io/klog/v2" 7 | ) 8 | 9 | type EventListener struct { 10 | stateKey string 11 | portClient *cli.PortClient 12 | handler *Handler 13 | } 14 | 15 | func NewEventListener(stateKey string, portClient *cli.PortClient) *EventListener { 16 | return &EventListener{ 17 | stateKey: stateKey, 18 | portClient: portClient, 19 | handler: NewPollingHandler(config.PollingListenerRate, stateKey, portClient, nil), 20 | } 21 | } 22 | 23 | func (l *EventListener) Run(resync func()) error { 24 | klog.Infof("Starting polling event listener") 25 | klog.Infof("Polling rate set to %d seconds", config.PollingListenerRate) 26 | l.handler.Run(resync) 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/event_handler/polling/polling.go: -------------------------------------------------------------------------------- 1 | package polling 2 | 3 | import ( 4 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 5 | "github.com/port-labs/port-k8s-exporter/pkg/port/integration" 6 | "k8s.io/klog/v2" 7 | "os" 8 | "os/signal" 9 | "reflect" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | type ITicker interface { 15 | GetC() <-chan time.Time 16 | } 17 | 18 | type Ticker struct { 19 | ticker *time.Ticker 20 | } 21 | 22 | func NewTicker(d time.Duration) *Ticker { 23 | return &Ticker{ 24 | ticker: time.NewTicker(d), 25 | } 26 | } 27 | 28 | func (t *Ticker) Stop() { 29 | t.ticker.Stop() 30 | } 31 | 32 | func (t *Ticker) GetC() <-chan time.Time { 33 | return t.ticker.C 34 | } 35 | 36 | type Handler struct { 37 | ticker ITicker 38 | stateKey string 39 | portClient *cli.PortClient 40 | pollingRate uint 41 | } 42 | 43 | func NewPollingHandler(pollingRate uint, stateKey string, portClient *cli.PortClient, tickerOverride ITicker) *Handler { 44 | ticker := tickerOverride 45 | if ticker == nil { 46 | ticker = NewTicker(time.Second * time.Duration(pollingRate)) 47 | } 48 | rv := &Handler{ 49 | ticker: ticker, 50 | stateKey: stateKey, 51 | portClient: portClient, 52 | pollingRate: pollingRate, 53 | } 54 | return rv 55 | } 56 | 57 | func (h *Handler) Run(resync func()) { 58 | klog.Infof("Starting polling handler") 59 | currentState, err := integration.GetIntegration(h.portClient, h.stateKey) 60 | if err != nil { 61 | klog.Errorf("Error fetching the first AppConfig state: %s", err.Error()) 62 | } 63 | 64 | sigChan := make(chan os.Signal, 1) 65 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 66 | 67 | klog.Infof("Polling handler started") 68 | run := true 69 | for run { 70 | select { 71 | case sig := <-sigChan: 72 | klog.Infof("Received signal %v: terminating\n", sig) 73 | run = false 74 | case <-h.ticker.GetC(): 75 | klog.Infof("Polling event listener iteration after %d seconds. Checking for changes...", h.pollingRate) 76 | configuration, err := integration.GetIntegration(h.portClient, h.stateKey) 77 | if err != nil { 78 | klog.Errorf("error getting integration: %s", err.Error()) 79 | } else if reflect.DeepEqual(currentState, configuration) != true { 80 | klog.Infof("Changes detected. Resyncing...") 81 | currentState = configuration 82 | resync() 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /pkg/event_handler/polling/polling_test.go: -------------------------------------------------------------------------------- 1 | package polling 2 | 3 | import ( 4 | _ "github.com/port-labs/port-k8s-exporter/test_utils" 5 | 6 | "testing" 7 | "time" 8 | 9 | guuid "github.com/google/uuid" 10 | "github.com/port-labs/port-k8s-exporter/pkg/config" 11 | "github.com/port-labs/port-k8s-exporter/pkg/port" 12 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 13 | "github.com/port-labs/port-k8s-exporter/pkg/port/integration" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | type Fixture struct { 18 | t *testing.T 19 | ticker MockTicker 20 | portClient *cli.PortClient 21 | stateKey string 22 | } 23 | 24 | type MockTicker struct { 25 | c chan time.Time 26 | } 27 | 28 | func (m *MockTicker) GetC() <-chan time.Time { 29 | return m.c 30 | } 31 | 32 | func NewFixture(t *testing.T, c chan time.Time) *Fixture { 33 | stateKey := guuid.NewString() 34 | newConfig := &config.ApplicationConfiguration{ 35 | ConfigFilePath: config.ApplicationConfig.ConfigFilePath, 36 | ResyncInterval: config.ApplicationConfig.ResyncInterval, 37 | PortBaseURL: config.ApplicationConfig.PortBaseURL, 38 | EventListenerType: config.ApplicationConfig.EventListenerType, 39 | CreateDefaultResources: config.ApplicationConfig.CreateDefaultResources, 40 | OverwriteConfigurationOnRestart: config.ApplicationConfig.OverwriteConfigurationOnRestart, 41 | Resources: config.ApplicationConfig.Resources, 42 | DeleteDependents: config.ApplicationConfig.DeleteDependents, 43 | CreateMissingRelatedEntities: config.ApplicationConfig.CreateMissingRelatedEntities, 44 | UpdateEntityOnlyOnDiff: config.ApplicationConfig.UpdateEntityOnlyOnDiff, 45 | PortClientId: config.ApplicationConfig.PortClientId, 46 | PortClientSecret: config.ApplicationConfig.PortClientSecret, 47 | StateKey: stateKey, 48 | } 49 | 50 | portClient := cli.New(newConfig) 51 | _ = integration.DeleteIntegration(portClient, stateKey) 52 | _, err := integration.CreateIntegration(portClient, stateKey, "", &port.IntegrationAppConfig{ 53 | Resources: []port.Resource{}, 54 | }, false) 55 | if err != nil { 56 | t.Errorf("Error creating Port integration: %s", err.Error()) 57 | } 58 | return &Fixture{ 59 | t: t, 60 | ticker: MockTicker{c: c}, 61 | portClient: portClient, 62 | stateKey: stateKey, 63 | } 64 | } 65 | 66 | func (f *Fixture) CleanIntegration() { 67 | _ = integration.DeleteIntegration(f.portClient, f.stateKey) 68 | } 69 | 70 | func TestPolling_DifferentConfiguration(t *testing.T) { 71 | called := false 72 | c := make(chan time.Time) 73 | fixture := NewFixture(t, c) 74 | defer fixture.CleanIntegration() 75 | handler := NewPollingHandler(uint(1), fixture.stateKey, fixture.portClient, &fixture.ticker) 76 | go handler.Run(func() { 77 | called = true 78 | }) 79 | 80 | c <- time.Now() 81 | time.Sleep(time.Millisecond * 1500) 82 | assert.False(t, called) 83 | 84 | _ = integration.PatchIntegration(fixture.portClient, fixture.stateKey, &port.Integration{ 85 | Config: &port.IntegrationAppConfig{ 86 | Resources: []port.Resource{}, 87 | }, 88 | }) 89 | 90 | c <- time.Now() 91 | time.Sleep(time.Millisecond * 1500) 92 | 93 | assert.True(t, called) 94 | } 95 | -------------------------------------------------------------------------------- /pkg/goutils/env.go: -------------------------------------------------------------------------------- 1 | package goutils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | ) 8 | 9 | func GetStringEnvOrDefault(key string, defaultValue string) string { 10 | value := os.Getenv(key) 11 | if value == "" { 12 | return defaultValue 13 | } 14 | return value 15 | } 16 | 17 | func GetUintEnvOrDefault(key string, defaultValue uint64) uint64 { 18 | value := os.Getenv(key) 19 | if value == "" { 20 | return defaultValue 21 | } 22 | result, err := strconv.ParseUint(value, 10, 32) 23 | if err != nil { 24 | fmt.Printf("Using default value "+strconv.FormatUint(uint64(defaultValue), 10)+" for "+key+". error parsing env variable %s: %s", key, err.Error()) 25 | return defaultValue 26 | } 27 | return result 28 | } 29 | 30 | func GetBoolEnvOrDefault(key string, defaultValue bool) bool { 31 | value := os.Getenv(key) 32 | if value == "" { 33 | return defaultValue 34 | } 35 | result, err := strconv.ParseBool(value) 36 | if err != nil { 37 | fmt.Printf("Using default value "+strconv.FormatBool(defaultValue)+" for "+key+". error parsing env variable %s: %s", key, err.Error()) 38 | return defaultValue 39 | } 40 | return result 41 | } 42 | -------------------------------------------------------------------------------- /pkg/goutils/map.go: -------------------------------------------------------------------------------- 1 | package goutils 2 | 3 | import "encoding/json" 4 | 5 | func MergeMaps[T interface{}](ms ...map[string]T) map[string]T { 6 | res := map[string]T{} 7 | for _, m := range ms { 8 | for k, v := range m { 9 | res[k] = v 10 | } 11 | } 12 | return res 13 | } 14 | 15 | func StructToMap(obj interface{}) (newMap map[string]interface{}, err error) { 16 | data, err := json.Marshal(obj) 17 | 18 | if err != nil { 19 | return 20 | } 21 | 22 | err = json.Unmarshal(data, &newMap) 23 | return 24 | } 25 | -------------------------------------------------------------------------------- /pkg/goutils/slices.go: -------------------------------------------------------------------------------- 1 | package goutils 2 | 3 | func Filter[T comparable](l []T, item T) []T { 4 | out := make([]T, 0) 5 | for _, element := range l { 6 | if element != item { 7 | out = append(out, element) 8 | } 9 | } 10 | return out 11 | } 12 | -------------------------------------------------------------------------------- /pkg/handlers/controllers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/port-labs/port-k8s-exporter/pkg/config" 9 | "github.com/port-labs/port-k8s-exporter/pkg/crd" 10 | "github.com/port-labs/port-k8s-exporter/pkg/goutils" 11 | "github.com/port-labs/port-k8s-exporter/pkg/k8s" 12 | "github.com/port-labs/port-k8s-exporter/pkg/port" 13 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 14 | "github.com/port-labs/port-k8s-exporter/pkg/port/integration" 15 | "github.com/port-labs/port-k8s-exporter/pkg/signal" 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | "k8s.io/client-go/dynamic/dynamicinformer" 18 | "k8s.io/klog/v2" 19 | ) 20 | 21 | type ControllersHandler struct { 22 | controllers []*k8s.Controller 23 | informersFactory dynamicinformer.DynamicSharedInformerFactory 24 | stateKey string 25 | portClient *cli.PortClient 26 | stopCh chan struct{} 27 | isStopped bool 28 | portConfig *port.IntegrationAppConfig 29 | } 30 | 31 | func NewControllersHandler(exporterConfig *port.Config, portConfig *port.IntegrationAppConfig, k8sClient *k8s.Client, portClient *cli.PortClient) *ControllersHandler { 32 | resync := time.Minute * time.Duration(exporterConfig.ResyncInterval) 33 | informersFactory := dynamicinformer.NewDynamicSharedInformerFactory(k8sClient.DynamicClient, resync) 34 | 35 | crd.AutodiscoverCRDsToActions(portConfig, k8sClient.ApiExtensionClient, portClient) 36 | 37 | aggResources := make(map[string][]port.KindConfig) 38 | for _, resource := range portConfig.Resources { 39 | kindConfig := port.KindConfig{Selector: resource.Selector, Port: resource.Port} 40 | if _, ok := aggResources[resource.Kind]; ok { 41 | aggResources[resource.Kind] = append(aggResources[resource.Kind], kindConfig) 42 | } else { 43 | aggResources[resource.Kind] = []port.KindConfig{kindConfig} 44 | } 45 | } 46 | 47 | controllers := make([]*k8s.Controller, 0, len(portConfig.Resources)) 48 | 49 | for kind, kindConfigs := range aggResources { 50 | var gvr schema.GroupVersionResource 51 | gvr, err := k8s.GetGVRFromResource(k8sClient.DiscoveryMapper, kind) 52 | if err != nil { 53 | klog.Errorf("Error getting GVR, skip handling for resource '%s': %s.", kind, err.Error()) 54 | continue 55 | } 56 | 57 | informer := informersFactory.ForResource(gvr) 58 | controller := k8s.NewController(port.AggregatedResource{Kind: kind, KindConfigs: kindConfigs}, informer, portConfig, config.ApplicationConfig) 59 | controllers = append(controllers, controller) 60 | } 61 | 62 | controllersHandler := &ControllersHandler{ 63 | controllers: controllers, 64 | informersFactory: informersFactory, 65 | stateKey: exporterConfig.StateKey, 66 | portClient: portClient, 67 | stopCh: signal.SetupSignalHandler(), 68 | portConfig: portConfig, 69 | } 70 | 71 | return controllersHandler 72 | } 73 | 74 | func (c *ControllersHandler) Handle() { 75 | klog.Info("Starting informers") 76 | c.informersFactory.Start(c.stopCh) 77 | 78 | currentEntitiesSets, shouldDeleteStaleEntities := syncAllControllers(c) 79 | 80 | ctx, cancelCtx := context.WithCancel(context.Background()) 81 | defer cancelCtx() 82 | go func() { 83 | <-c.stopCh 84 | cancelCtx() 85 | }() 86 | 87 | if shouldDeleteStaleEntities { 88 | klog.Info("Deleting stale entities") 89 | c.runDeleteStaleEntities(ctx, currentEntitiesSets) 90 | klog.Info("Done deleting stale entities") 91 | } else { 92 | klog.Warning("Skipping delete of stale entities due to a failure in getting all current entities from k8s") 93 | } 94 | } 95 | 96 | func syncAllControllers(c *ControllersHandler) ([]map[string]interface{}, bool) { 97 | currentEntitiesSets := make([]map[string]interface{}, 0) 98 | shouldDeleteStaleEntities := true 99 | var syncWg sync.WaitGroup 100 | 101 | for _, controller := range c.controllers { 102 | controller := controller 103 | 104 | go func() { 105 | <-c.stopCh 106 | klog.Info("Shutting down controllers") 107 | controller.Shutdown() 108 | klog.Info("Exporter exiting") 109 | }() 110 | 111 | klog.Infof("Waiting for informer cache to sync for resource '%s'", controller.Resource.Kind) 112 | if err := controller.WaitForCacheSync(c.stopCh); err != nil { 113 | klog.Fatalf("Error while waiting for informer cache sync: %s", err.Error()) 114 | } 115 | 116 | if c.portConfig.CreateMissingRelatedEntities { 117 | syncWg.Add(1) 118 | go func() { 119 | defer syncWg.Done() 120 | controllerEntitiesSet, controllerShouldDeleteStaleEntities := syncController(controller, c) 121 | currentEntitiesSets = append(currentEntitiesSets, controllerEntitiesSet) 122 | shouldDeleteStaleEntities = shouldDeleteStaleEntities && controllerShouldDeleteStaleEntities 123 | }() 124 | continue 125 | } 126 | controllerEntitiesSet, controllerShouldDeleteStaleEntities := syncController(controller, c) 127 | currentEntitiesSets = append(currentEntitiesSets, controllerEntitiesSet) 128 | shouldDeleteStaleEntities = shouldDeleteStaleEntities && controllerShouldDeleteStaleEntities 129 | } 130 | syncWg.Wait() 131 | return currentEntitiesSets, shouldDeleteStaleEntities 132 | } 133 | 134 | func syncController(controller *k8s.Controller, c *ControllersHandler) (map[string]interface{}, bool) { 135 | klog.Infof("Starting full initial resync for resource '%s'", controller.Resource.Kind) 136 | initialSyncResult := controller.RunInitialSync() 137 | klog.Infof("Done full initial resync, starting live events sync for resource '%s'", controller.Resource.Kind) 138 | controller.RunEventsSync(1, c.stopCh) 139 | if len(initialSyncResult.RawDataExamples) > 0 { 140 | err := integration.PostIntegrationKindExample(c.portClient, c.stateKey, controller.Resource.Kind, initialSyncResult.RawDataExamples) 141 | if err != nil { 142 | klog.Warningf("failed to post integration kind example: %s", err.Error()) 143 | } 144 | } 145 | if initialSyncResult.EntitiesSet != nil { 146 | return initialSyncResult.EntitiesSet, initialSyncResult.ShouldDeleteStaleEntities 147 | } 148 | 149 | return map[string]interface{}{}, initialSyncResult.ShouldDeleteStaleEntities 150 | } 151 | 152 | func (c *ControllersHandler) runDeleteStaleEntities(ctx context.Context, currentEntitiesSet []map[string]interface{}) { 153 | _, err := c.portClient.Authenticate(ctx, c.portClient.ClientID, c.portClient.ClientSecret) 154 | if err != nil { 155 | klog.Errorf("error authenticating with Port: %v", err) 156 | } 157 | 158 | err = c.portClient.DeleteStaleEntities(ctx, c.stateKey, goutils.MergeMaps(currentEntitiesSet...)) 159 | if err != nil { 160 | klog.Errorf("error deleting stale entities: %s", err.Error()) 161 | } 162 | } 163 | 164 | func (c *ControllersHandler) Stop() { 165 | if c.isStopped { 166 | return 167 | } 168 | 169 | klog.Info("Stopping controllers") 170 | close(c.stopCh) 171 | c.isStopped = true 172 | } 173 | -------------------------------------------------------------------------------- /pkg/jq/parser.go: -------------------------------------------------------------------------------- 1 | package jq 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/itchyny/gojq" 10 | "github.com/port-labs/port-k8s-exporter/pkg/goutils" 11 | "k8s.io/klog/v2" 12 | ) 13 | 14 | func runJQQuery(jqQuery string, obj interface{}) (interface{}, error) { 15 | query, err := gojq.Parse(jqQuery) 16 | if err != nil { 17 | klog.Warningf("failed to parse jq query: %s", jqQuery) 18 | return nil, err 19 | } 20 | code, err := gojq.Compile( 21 | query, 22 | gojq.WithEnvironLoader(func() []string { 23 | return os.Environ() 24 | }), 25 | ) 26 | if err != nil { 27 | klog.Warningf("failed to compile jq query: %s", jqQuery) 28 | return nil, err 29 | } 30 | queryRes, ok := code.Run(obj).Next() 31 | 32 | if !ok { 33 | return nil, fmt.Errorf("query should return at least one value") 34 | } 35 | 36 | err, ok = queryRes.(error) 37 | if ok { 38 | return nil, err 39 | } 40 | 41 | return queryRes, nil 42 | } 43 | 44 | func ParseBool(jqQuery string, obj interface{}) (bool, error) { 45 | queryRes, err := runJQQuery(jqQuery, obj) 46 | if err != nil { 47 | return false, err 48 | } 49 | 50 | boolean, ok := queryRes.(bool) 51 | if !ok { 52 | return false, fmt.Errorf("failed to parse bool: %#v", queryRes) 53 | } 54 | 55 | return boolean, nil 56 | } 57 | 58 | func ParseString(jqQuery string, obj interface{}) (string, error) { 59 | queryRes, err := runJQQuery(jqQuery, obj) 60 | if err != nil { 61 | return "", err 62 | } 63 | 64 | str, ok := queryRes.(string) 65 | if !ok { 66 | return "", fmt.Errorf("failed to parse string with jq '%#v': %#v", jqQuery, queryRes) 67 | } 68 | 69 | return strings.Trim(str, "\""), nil 70 | } 71 | 72 | func ParseInterface(jqQuery string, obj interface{}) (interface{}, error) { 73 | queryRes, err := runJQQuery(jqQuery, obj) 74 | if err != nil { 75 | return "", err 76 | } 77 | 78 | return queryRes, nil 79 | } 80 | 81 | func ParseArray(jqQuery string, obj interface{}) ([]interface{}, error) { 82 | queryRes, err := runJQQuery(jqQuery, obj) 83 | 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | items, ok := queryRes.([]interface{}) 89 | if !ok { 90 | return nil, fmt.Errorf("failed to parse array with jq '%#v': %#v", jqQuery, queryRes) 91 | } 92 | 93 | return items, nil 94 | } 95 | 96 | func ParseMapInterface(jqQueries map[string]string, obj interface{}) (map[string]interface{}, error) { 97 | mapInterface := make(map[string]interface{}, len(jqQueries)) 98 | 99 | for key, jqQuery := range jqQueries { 100 | queryRes, err := ParseInterface(jqQuery, obj) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | if key != "*" { 106 | mapInterface[key] = queryRes 107 | } else { 108 | if _, ok := queryRes.(map[string]interface{}); ok { 109 | mapInterface = goutils.MergeMaps(mapInterface, queryRes.(map[string]interface{})) 110 | } else { 111 | mapInterface[key] = queryRes 112 | } 113 | } 114 | 115 | } 116 | 117 | return mapInterface, nil 118 | } 119 | 120 | func ParseMapRecursively(jqQueries map[string]interface{}, obj interface{}) (map[string]interface{}, error) { 121 | mapInterface := make(map[string]interface{}, len(jqQueries)) 122 | 123 | for key, jqQuery := range jqQueries { 124 | 125 | if reflect.TypeOf(jqQuery).Kind() == reflect.String { 126 | queryRes, err := ParseMapInterface(map[string]string{key: jqQuery.(string)}, obj) 127 | if err != nil { 128 | return nil, err 129 | } 130 | mapInterface = goutils.MergeMaps(mapInterface, queryRes) 131 | } else if reflect.TypeOf(jqQuery).Kind() == reflect.Map { 132 | for mapKey, mapValue := range jqQuery.(map[string]interface{}) { 133 | queryRes, err := ParseMapRecursively(map[string]interface{}{mapKey: mapValue}, obj) 134 | if err != nil { 135 | return nil, err 136 | } 137 | for queryKey, queryVal := range queryRes { 138 | if mapInterface[key] == nil { 139 | mapInterface[key] = make(map[string]interface{}) 140 | } 141 | mapInterface[key].(map[string]interface{})[queryKey] = queryVal 142 | } 143 | } 144 | } else if reflect.TypeOf(jqQuery).Kind() == reflect.Slice { 145 | jqArrayValue := reflect.ValueOf(jqQuery) 146 | relations := make([]interface{}, jqArrayValue.Len()) 147 | for i := 0; i < jqArrayValue.Len(); i++ { 148 | relation, err := ParseMapRecursively(map[string]interface{}{key: jqArrayValue.Index(i).Interface()}, obj) 149 | if err != nil { 150 | return nil, err 151 | } 152 | relations[i] = relation[key] 153 | } 154 | mapInterface[key] = relations 155 | } else { 156 | return nil, fmt.Errorf("invalid jq query type '%T'", jqQuery) 157 | } 158 | } 159 | 160 | return mapInterface, nil 161 | } 162 | -------------------------------------------------------------------------------- /pkg/jq/parser_test.go: -------------------------------------------------------------------------------- 1 | package jq 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/port-labs/port-k8s-exporter/pkg/port" 10 | _ "github.com/port-labs/port-k8s-exporter/test_utils" 11 | ) 12 | 13 | var ( 14 | blueprint = "k8s-export-test-bp" 15 | ) 16 | 17 | func TestJqSearchRelation(t *testing.T) { 18 | 19 | mapping := []port.EntityMapping{ 20 | { 21 | Identifier: ".metadata.name", 22 | Blueprint: fmt.Sprintf("\"%s\"", blueprint), 23 | Icon: "\"Microservice\"", 24 | Team: "\"Test\"", 25 | Properties: map[string]string{}, 26 | Relations: map[string]interface{}{ 27 | "k8s-relation": map[string]interface{}{ 28 | "combinator": "\"or\"", 29 | "rules": []interface{}{ 30 | map[string]interface{}{ 31 | "property": "\"$identifier\"", 32 | "operator": "\"=\"", 33 | "value": "\"e_AgPMYvq1tAs8TuqM\"", 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | } 40 | res, _ := ParseMapRecursively(mapping[0].Relations, nil) 41 | assert.Equal(t, res, map[string]interface{}{ 42 | "k8s-relation": map[string]interface{}{ 43 | "combinator": "or", 44 | "rules": []interface{}{ 45 | map[string]interface{}{ 46 | "property": "$identifier", 47 | "operator": "=", 48 | "value": "e_AgPMYvq1tAs8TuqM", 49 | }, 50 | }, 51 | }, 52 | }) 53 | 54 | } 55 | 56 | func TestJqSearchIdentifier(t *testing.T) { 57 | 58 | mapping := []port.EntityMapping{ 59 | { 60 | Identifier: map[string]interface{}{ 61 | "combinator": "\"and\"", 62 | "rules": []interface{}{ 63 | map[string]interface{}{ 64 | "property": "\"prop1\"", 65 | "operator": "\"in\"", 66 | "value": ".values", 67 | }, 68 | }, 69 | }, 70 | Blueprint: fmt.Sprintf("\"%s\"", blueprint), 71 | }, 72 | } 73 | res, _ := ParseMapRecursively(mapping[0].Identifier.(map[string]interface{}), map[string]interface{}{"values": []string{"val1", "val2"}}) 74 | assert.Equal(t, res, map[string]interface{}{ 75 | "combinator": "and", 76 | "rules": []interface{}{ 77 | map[string]interface{}{ 78 | "property": "prop1", 79 | "operator": "in", 80 | "value": []string{"val1", "val2"}, 81 | }, 82 | }, 83 | }) 84 | 85 | } 86 | 87 | func TestJqSearchTeam(t *testing.T) { 88 | mapping := []port.EntityMapping{ 89 | { 90 | Identifier: "\"Frontend-Service\"", 91 | Blueprint: fmt.Sprintf("\"%s\"", blueprint), 92 | Icon: "\"Microservice\"", 93 | Team: map[string]interface{}{ 94 | "combinator": "\"and\"", 95 | "rules": []interface{}{ 96 | map[string]interface{}{ 97 | "property": "\"team\"", 98 | "operator": "\"in\"", 99 | "value": ".values", 100 | }, 101 | }, 102 | }, 103 | }, 104 | } 105 | resMap, _ := ParseMapRecursively(mapping[0].Team.(map[string]interface{}), map[string]interface{}{"values": []string{"val1", "val2"}}) 106 | assert.Equal(t, resMap, map[string]interface{}{ 107 | "combinator": "and", 108 | "rules": []interface{}{ 109 | map[string]interface{}{ 110 | "property": "team", 111 | "operator": "in", 112 | "value": []string{"val1", "val2"}, 113 | }, 114 | }, 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /pkg/k8s/client.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | apiextensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" 5 | "k8s.io/client-go/discovery" 6 | "k8s.io/client-go/discovery/cached/memory" 7 | "k8s.io/client-go/dynamic" 8 | "k8s.io/client-go/rest" 9 | "k8s.io/client-go/restmapper" 10 | ) 11 | 12 | type Client struct { 13 | DiscoveryClient *discovery.DiscoveryClient 14 | DynamicClient dynamic.Interface 15 | DiscoveryMapper *restmapper.DeferredDiscoveryRESTMapper 16 | ApiExtensionClient *apiextensions.ApiextensionsV1Client 17 | } 18 | 19 | func NewClient(config *rest.Config) (*Client, error) { 20 | 21 | discoveryClient, err := discovery.NewDiscoveryClientForConfig(config) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | dynamicClient, err := dynamic.NewForConfig(config) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | apiextensionsClient, err := apiextensions.NewForConfig(config) 32 | 33 | cacheClient := memory.NewMemCacheClient(discoveryClient) 34 | cacheClient.Invalidate() 35 | 36 | discoveryMapper := restmapper.NewDeferredDiscoveryRESTMapper(cacheClient) 37 | 38 | return &Client{discoveryClient, dynamicClient, discoveryMapper, apiextensionsClient}, nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/k8s/config.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import "k8s.io/client-go/tools/clientcmd" 4 | 5 | func NewKubeConfig() clientcmd.ClientConfig { 6 | loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 7 | configOverrides := &clientcmd.ConfigOverrides{} 8 | return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/k8s/resource.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "strings" 5 | 6 | "k8s.io/apimachinery/pkg/runtime/schema" 7 | "k8s.io/client-go/restmapper" 8 | ) 9 | 10 | func GetGVRFromResource(discoveryMapper *restmapper.DeferredDiscoveryRESTMapper, resource string) (schema.GroupVersionResource, error) { 11 | var gvr schema.GroupVersionResource 12 | 13 | if strings.Count(resource, "/") >= 2 { 14 | s := strings.SplitN(resource, "/", 3) 15 | gvr = schema.GroupVersionResource{Group: s[0], Version: s[1], Resource: s[2]} 16 | } else if strings.Count(resource, "/") == 1 { 17 | s := strings.SplitN(resource, "/", 2) 18 | gvr = schema.GroupVersionResource{Group: "", Version: s[0], Resource: s[1]} 19 | } 20 | 21 | gvrs, err := discoveryMapper.ResourcesFor(gvr) 22 | if err != nil { 23 | return schema.GroupVersionResource{}, err 24 | } 25 | if len(gvrs) == 0 { 26 | return gvr, nil 27 | } 28 | 29 | return gvrs[0], nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/parsers/sensitive.go: -------------------------------------------------------------------------------- 1 | package parsers 2 | 3 | import ( 4 | "reflect" 5 | "regexp" 6 | ) 7 | 8 | var secretPatterns = map[string]string{ 9 | "Password in URL": `[a-zA-Z]{3,10}:\\/\\/[^\\/\\s:@]{3,20}:[^\\/\\s:@]{3,20}@.{1,100}["'\\s]`, 10 | "Generic API Key": `[a|A][p|P][i|I][_]?k[e|E]y[Y].*[\'|"][0-9a-zA-Z]{32,45}[\'|"]`, 11 | "Generic Secret": `[s|S][e|E][c|C][r|R][e|E][t|T].*[\'|"][0-9a-zA-Z]{32,45}[\'|"]`, 12 | "Google API Key": `AIza[0-9A-Za-z\\-_]{35}`, 13 | "Firebase URL": `.*firebaseio\\.com`, 14 | "RSA private key": `-----BEGIN RSA PRIVATE KEY-----`, 15 | "SSH (DSA) private key": `-----BEGIN DSA PRIVATE KEY-----`, 16 | "SSH (EC) private key": `-----BEGIN EC PRIVATE KEY-----`, 17 | "PGP private key block": `-----BEGIN PGP PRIVATE KEY BLOCK-----`, 18 | "Amazon AWS Access Key ID": `AKIA[0-9A-Z]{16}`, 19 | "Amazon MWS Auth Token": `amzn\\.mws\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`, 20 | "AWS API Key": `AKIA[0-9A-Z]{16}`, 21 | "GitHub": `[g|G][i|I][t|T][h|H][u|U][b|B].*[\'|"][0-9a-zA-Z]{35,40}[\'|"]`, 22 | "Google Cloud Platform API Key": `AIza[0-9A-Za-z\\-_]{35}`, 23 | "Google Cloud Platform OAuth": `[0-9]+-[0-9A-Za-z_]{32}\\.apps\\.googleusercontent\\.com`, 24 | "Google (GCP) Service-account": `"type": "service_account"`, 25 | "Google OAuth Access Token": `ya29\\.[0-9A-Za-z\\-_]+`, 26 | "Connection String": `[a-zA-Z]+:\\/\\/[^/\\s]+:[^/\\s]+@[^/\\s]+\\/[^/\\s]+`, 27 | } 28 | 29 | var compiledPatterns []*regexp.Regexp 30 | 31 | func init() { 32 | for _, pattern := range secretPatterns { 33 | compiledPatterns = append(compiledPatterns, regexp.MustCompile(pattern)) 34 | } 35 | } 36 | 37 | func maskString(input string) string { 38 | maskedString := input 39 | for _, pattern := range compiledPatterns { 40 | maskedString = pattern.ReplaceAllString(maskedString, "[REDACTED]") 41 | } 42 | return maskedString 43 | } 44 | 45 | func ParseSensitiveData(data interface{}) interface{} { 46 | switch v := reflect.ValueOf(data); v.Kind() { 47 | case reflect.String: 48 | return maskString(v.String()) 49 | case reflect.Slice: 50 | sliceCopy := reflect.MakeSlice(v.Type(), v.Len(), v.Len()) 51 | for i := 0; i < v.Len(); i++ { 52 | sliceCopy.Index(i).Set(reflect.ValueOf(ParseSensitiveData(v.Index(i).Interface()))) 53 | } 54 | return sliceCopy.Interface() 55 | case reflect.Map: 56 | mapCopy := reflect.MakeMap(v.Type()) 57 | for _, key := range v.MapKeys() { 58 | mapCopy.SetMapIndex(key, reflect.ValueOf(ParseSensitiveData(v.MapIndex(key).Interface()))) 59 | } 60 | return mapCopy.Interface() 61 | default: 62 | return data 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/port/blueprint/blueprint.go: -------------------------------------------------------------------------------- 1 | package blueprint 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/port-labs/port-k8s-exporter/pkg/port" 8 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 9 | ) 10 | 11 | func NewBlueprint(portClient *cli.PortClient, blueprint port.Blueprint) (*port.Blueprint, error) { 12 | return InnerNewBlueprint(portClient, blueprint, true) 13 | } 14 | 15 | func NewBlueprintWithoutPage(portClient *cli.PortClient, blueprint port.Blueprint) (*port.Blueprint, error) { 16 | return InnerNewBlueprint(portClient, blueprint, false) 17 | } 18 | 19 | func InnerNewBlueprint(portClient *cli.PortClient, blueprint port.Blueprint, shouldCreatePage bool) (*port.Blueprint, error) { 20 | var err error 21 | _, err = portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 22 | 23 | if err != nil { 24 | return nil, fmt.Errorf("error authenticating with Port: %v", err) 25 | } 26 | 27 | var bp *port.Blueprint 28 | if shouldCreatePage { 29 | bp, err = cli.CreateBlueprint(portClient, blueprint) 30 | } else { 31 | bp, err = cli.CreateBlueprintWithoutPage(portClient, blueprint) 32 | } 33 | 34 | if err != nil { 35 | return nil, fmt.Errorf("error creating blueprint: %v", err) 36 | } 37 | return bp, nil 38 | } 39 | 40 | func PatchBlueprint(portClient *cli.PortClient, blueprint port.Blueprint) (*port.Blueprint, error) { 41 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 42 | if err != nil { 43 | return nil, fmt.Errorf("error authenticating with Port: %v", err) 44 | } 45 | 46 | bp, err := cli.PatchBlueprint(portClient, blueprint) 47 | if err != nil { 48 | return nil, fmt.Errorf("error patching Port blueprint: %v", err) 49 | } 50 | return bp, nil 51 | } 52 | 53 | func DeleteBlueprint(portClient *cli.PortClient, blueprintIdentifier string) error { 54 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 55 | if err != nil { 56 | return fmt.Errorf("error authenticating with Port: %v", err) 57 | } 58 | 59 | err = cli.DeleteBlueprint(portClient, blueprintIdentifier) 60 | if err != nil { 61 | return fmt.Errorf("error deleting Port blueprint: %v", err) 62 | } 63 | return nil 64 | } 65 | 66 | func DeleteBlueprintEntities(portClient *cli.PortClient, blueprintIdentifier string) error { 67 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 68 | if err != nil { 69 | return fmt.Errorf("error authenticating with Port: %v", err) 70 | } 71 | 72 | err = cli.DeleteBlueprintEntities(portClient, blueprintIdentifier) 73 | if err != nil { 74 | return fmt.Errorf("error deleting Port blueprint entities: %v", err) 75 | } 76 | return nil 77 | } 78 | 79 | func GetBlueprint(portClient *cli.PortClient, blueprintIdentifier string) (*port.Blueprint, error) { 80 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 81 | if err != nil { 82 | return nil, fmt.Errorf("error authenticating with Port: %v", err) 83 | } 84 | 85 | bp, err := cli.GetBlueprint(portClient, blueprintIdentifier) 86 | if err != nil { 87 | return nil, fmt.Errorf("error getting Port blueprint: %v", err) 88 | } 89 | return bp, nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/port/cli/action.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/port-labs/port-k8s-exporter/pkg/port" 7 | ) 8 | 9 | func CreateAction(portClient *PortClient, action port.Action) (*port.Action, error) { 10 | pb := &port.ResponseBody{} 11 | resp, err := portClient.Client.R(). 12 | SetResult(&pb). 13 | SetBody(action). 14 | Post("v1/actions") 15 | if err != nil { 16 | return nil, err 17 | } 18 | if !pb.OK { 19 | return nil, fmt.Errorf("failed to create action, got: %s", resp.Body()) 20 | } 21 | return &pb.Action, nil 22 | } 23 | 24 | func UpdateAction(portClient *PortClient, action port.Action) (*port.Action, error) { 25 | pb := &port.ResponseBody{} 26 | resp, err := portClient.Client.R(). 27 | SetResult(&pb). 28 | SetBody(action). 29 | Put(fmt.Sprintf("v1/actions/%s", action.Identifier)) 30 | if err != nil { 31 | return nil, err 32 | } 33 | if !pb.OK { 34 | return nil, fmt.Errorf("failed to patch action, got: %s", resp.Body()) 35 | } 36 | return &pb.Action, nil 37 | } 38 | 39 | func GetAction(portClient *PortClient, actionIdentifier string) (*port.Action, error) { 40 | pb := &port.ResponseBody{} 41 | resp, err := portClient.Client.R(). 42 | SetResult(&pb). 43 | Get(fmt.Sprintf("v1/actions/%s", actionIdentifier)) 44 | if err != nil { 45 | return nil, err 46 | } 47 | if !pb.OK { 48 | return nil, fmt.Errorf("failed to get action, got: %s", resp.Body()) 49 | } 50 | return &pb.Action, nil 51 | } 52 | 53 | func DeleteAction(portClient *PortClient, actionIdentifier string) error { 54 | pb := &port.ResponseBody{} 55 | resp, err := portClient.Client.R(). 56 | SetResult(&pb). 57 | Delete(fmt.Sprintf("v1/actions/%s", actionIdentifier)) 58 | if err != nil { 59 | return err 60 | } 61 | if !pb.OK { 62 | return fmt.Errorf("failed to delete action, got: %s", resp.Body()) 63 | } 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/port/cli/blueprint.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "time" 7 | 8 | "github.com/port-labs/port-k8s-exporter/pkg/port" 9 | ) 10 | 11 | func CreateBlueprint(portClient *PortClient, blueprint port.Blueprint) (*port.Blueprint, error) { 12 | return InnerCreateBlueprint(portClient, blueprint, true) 13 | } 14 | 15 | func CreateBlueprintWithoutPage(portClient *PortClient, blueprint port.Blueprint) (*port.Blueprint, error) { 16 | return InnerCreateBlueprint(portClient, blueprint, false) 17 | } 18 | 19 | func InnerCreateBlueprint(portClient *PortClient, blueprint port.Blueprint, shouldCreatePage bool) (*port.Blueprint, error) { 20 | pb := &port.ResponseBody{} 21 | resp, err := portClient.Client.R(). 22 | SetResult(&pb). 23 | SetBody(blueprint). 24 | SetQueryParam("create_catalog_page", fmt.Sprintf("%t", shouldCreatePage)). 25 | Post("v1/blueprints") 26 | if err != nil { 27 | return nil, err 28 | } 29 | if !pb.OK { 30 | return nil, fmt.Errorf("failed to create blueprint, got: %s", resp.Body()) 31 | } 32 | return &pb.Blueprint, nil 33 | } 34 | 35 | func PatchBlueprint(portClient *PortClient, blueprint port.Blueprint) (*port.Blueprint, error) { 36 | pb := &port.ResponseBody{} 37 | resp, err := portClient.Client.R(). 38 | SetResult(&pb). 39 | SetBody(blueprint). 40 | Patch(fmt.Sprintf("v1/blueprints/%s", blueprint.Identifier)) 41 | if err != nil { 42 | return nil, err 43 | } 44 | if !pb.OK { 45 | return nil, fmt.Errorf("failed to patch blueprint, got: %s", resp.Body()) 46 | } 47 | return &pb.Blueprint, nil 48 | } 49 | 50 | func DeleteBlueprint(portClient *PortClient, blueprintIdentifier string) error { 51 | pb := &port.ResponseBody{} 52 | resp, err := portClient.Client.R(). 53 | SetResult(&pb). 54 | Delete(fmt.Sprintf("v1/blueprints/%s", blueprintIdentifier)) 55 | if err != nil { 56 | return err 57 | } 58 | if !pb.OK { 59 | return fmt.Errorf("failed to delete blueprint, got: %s", resp.Body()) 60 | } 61 | return nil 62 | } 63 | 64 | func DeleteBlueprintEntities(portClient *PortClient, blueprintIdentifier string) error { 65 | pb := &port.ResponseBody{} 66 | resp, err := portClient.Client.R(). 67 | SetResult(&pb). 68 | Delete(fmt.Sprintf("v1/blueprints/%s/all-entities?delete_blueprint=false", blueprintIdentifier)) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if !pb.OK { 74 | return fmt.Errorf("failed to delete blueprint, got: %s", resp.Body()) 75 | } 76 | 77 | migrationId := pb.MigrationId 78 | 79 | inProgressStatuses := []string{ 80 | "RUNNING", 81 | "INITIALIZING", 82 | "PENDING", 83 | } 84 | 85 | isCompleted := false 86 | for !isCompleted { 87 | migrResp, migrErr := portClient.Client.R(). 88 | SetResult(&pb). 89 | Get(fmt.Sprintf("v1/migrations/%s", migrationId)) 90 | 91 | if migrErr != nil { 92 | return fmt.Errorf("failed to fetch entities delete migration for '%s', got: %s", migrationId, migrResp.Body()) 93 | } 94 | 95 | if slices.Contains(inProgressStatuses, pb.Migration.Status) { 96 | time.Sleep(2 * time.Second) 97 | } else { 98 | isCompleted = true 99 | } 100 | 101 | } 102 | 103 | return nil 104 | } 105 | 106 | func GetBlueprint(portClient *PortClient, blueprintIdentifier string) (*port.Blueprint, error) { 107 | pb := &port.ResponseBody{} 108 | resp, err := portClient.Client.R(). 109 | SetResult(&pb). 110 | Get(fmt.Sprintf("v1/blueprints/%s", blueprintIdentifier)) 111 | if err != nil { 112 | return nil, err 113 | } 114 | if !pb.OK { 115 | return nil, fmt.Errorf("failed to get blueprint, got: %s", resp.Body()) 116 | } 117 | return &pb.Blueprint, nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/port/cli/client.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/go-resty/resty/v2" 10 | "github.com/port-labs/port-k8s-exporter/pkg/config" 11 | "github.com/port-labs/port-k8s-exporter/pkg/port" 12 | ) 13 | 14 | type ( 15 | Option func(*PortClient) 16 | PortClient struct { 17 | Client *resty.Client 18 | ClientID string 19 | ClientSecret string 20 | DeleteDependents bool 21 | CreateMissingRelatedEntities bool 22 | } 23 | ) 24 | 25 | func New(applicationConfig *config.ApplicationConfiguration, opts ...Option) *PortClient { 26 | c := &PortClient{ 27 | Client: resty.New(). 28 | SetBaseURL(applicationConfig.PortBaseURL). 29 | SetRetryCount(5). 30 | SetRetryWaitTime(300). 31 | // retry when create permission fails because scopes are created async-ly and sometimes (mainly in tests) the scope doesn't exist yet. 32 | AddRetryCondition(func(r *resty.Response, err error) bool { 33 | if err != nil { 34 | return true 35 | } 36 | if !strings.Contains(r.Request.URL, "/permissions") { 37 | return false 38 | } 39 | b := make(map[string]interface{}) 40 | err = json.Unmarshal(r.Body(), &b) 41 | return err != nil || b["ok"] != true 42 | }), 43 | } 44 | 45 | WithClientID(applicationConfig.PortClientId)(c) 46 | WithClientSecret(applicationConfig.PortClientSecret)(c) 47 | WithHeader("User-Agent", fmt.Sprintf("port-k8s-exporter/^0.3.4 (statekey/%s)", applicationConfig.StateKey))(c) 48 | 49 | for _, opt := range opts { 50 | opt(c) 51 | } 52 | 53 | return c 54 | } 55 | 56 | func (c *PortClient) Authenticate(ctx context.Context, clientID, clientSecret string) (string, error) { 57 | url := "v1/auth/access_token" 58 | resp, err := c.Client.R(). 59 | SetBody(map[string]interface{}{ 60 | "clientId": clientID, 61 | "clientSecret": clientSecret, 62 | }). 63 | SetContext(ctx). 64 | Post(url) 65 | if err != nil { 66 | return "", err 67 | } 68 | if resp.StatusCode() != 200 { 69 | return "", fmt.Errorf("failed to authenticate, got: %s", resp.Body()) 70 | } 71 | 72 | var tokenResp port.AccessTokenResponse 73 | err = json.Unmarshal(resp.Body(), &tokenResp) 74 | if err != nil { 75 | return "", err 76 | } 77 | c.Client.SetAuthToken(tokenResp.AccessToken) 78 | return tokenResp.AccessToken, nil 79 | } 80 | 81 | func WithHeader(key, val string) Option { 82 | return func(pc *PortClient) { 83 | pc.Client.SetHeader(key, val) 84 | } 85 | } 86 | 87 | func WithClientID(clientID string) Option { 88 | return func(pc *PortClient) { 89 | pc.ClientID = clientID 90 | } 91 | } 92 | 93 | func WithClientSecret(clientSecret string) Option { 94 | return func(pc *PortClient) { 95 | pc.ClientSecret = clientSecret 96 | } 97 | } 98 | 99 | func WithDeleteDependents(deleteDependents bool) Option { 100 | return func(pc *PortClient) { 101 | pc.DeleteDependents = deleteDependents 102 | } 103 | } 104 | 105 | func WithCreateMissingRelatedEntities(createMissingRelatedEntities bool) Option { 106 | return func(pc *PortClient) { 107 | pc.CreateMissingRelatedEntities = createMissingRelatedEntities 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /pkg/port/cli/entity.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/url" 8 | "strconv" 9 | 10 | "github.com/port-labs/port-k8s-exporter/pkg/port" 11 | "k8s.io/klog/v2" 12 | ) 13 | 14 | func (c *PortClient) SearchEntities(ctx context.Context, body port.SearchBody) ([]port.Entity, error) { 15 | pb := &port.ResponseBody{} 16 | resp, err := c.Client.R(). 17 | SetBody(body). 18 | SetHeader("Accept", "application/json"). 19 | SetQueryParam("exclude_calculated_properties", "true"). 20 | SetQueryParamsFromValues(url.Values{ 21 | "include": []string{"blueprint", "identifier"}, 22 | }). 23 | SetResult(&pb). 24 | Post("/v1/entities/search") 25 | if err != nil { 26 | return nil, err 27 | } 28 | if !pb.OK { 29 | return nil, fmt.Errorf("failed to search entities, got: %s", resp.Body()) 30 | } 31 | return pb.Entities, nil 32 | } 33 | 34 | func (c *PortClient) ReadEntity(ctx context.Context, id string, blueprint string) (*port.Entity, error) { 35 | resp, err := c.Client.R(). 36 | SetHeader("Accept", "application/json"). 37 | SetQueryParam("exclude_calculated_properties", "true"). 38 | SetPathParam(("blueprint"), blueprint). 39 | SetPathParam("identifier", id). 40 | Get("v1/blueprints/{blueprint}/entities/{identifier}") 41 | if err != nil { 42 | return nil, err 43 | } 44 | var pb port.ResponseBody 45 | err = json.Unmarshal(resp.Body(), &pb) 46 | if err != nil { 47 | return nil, err 48 | } 49 | if !pb.OK { 50 | return nil, fmt.Errorf("failed to read entity, got: %s", resp.Body()) 51 | } 52 | return &pb.Entity, nil 53 | } 54 | 55 | func (c *PortClient) CreateEntity(ctx context.Context, e *port.EntityRequest, runID string, createMissingRelatedEntities bool) (*port.Entity, error) { 56 | pb := &port.ResponseBody{} 57 | resp, err := c.Client.R(). 58 | SetBody(e). 59 | SetPathParam(("blueprint"), e.Blueprint). 60 | SetQueryParam("upsert", "true"). 61 | SetQueryParam("merge", "true"). 62 | SetQueryParam("run_id", runID). 63 | SetQueryParam("create_missing_related_entities", strconv.FormatBool(createMissingRelatedEntities)). 64 | SetResult(&pb). 65 | Post("v1/blueprints/{blueprint}/entities") 66 | if err != nil { 67 | return nil, err 68 | } 69 | if !pb.OK { 70 | return nil, fmt.Errorf("failed to create entity, got: %s", resp.Body()) 71 | } 72 | return &pb.Entity, nil 73 | } 74 | 75 | func (c *PortClient) DeleteEntity(ctx context.Context, id string, blueprint string, deleteDependents bool) error { 76 | pb := &port.ResponseBody{} 77 | resp, err := c.Client.R(). 78 | SetHeader("Accept", "application/json"). 79 | SetPathParam("blueprint", blueprint). 80 | SetPathParam("identifier", id). 81 | SetQueryParam("delete_dependents", strconv.FormatBool(deleteDependents)). 82 | SetResult(pb). 83 | Delete("v1/blueprints/{blueprint}/entities/{identifier}") 84 | if err != nil { 85 | return err 86 | } 87 | if !pb.OK { 88 | return fmt.Errorf("failed to delete entity, got: %s", resp.Body()) 89 | } 90 | return nil 91 | } 92 | 93 | func (c *PortClient) DeleteStaleEntities(ctx context.Context, stateKey string, existingEntitiesSet map[string]interface{}) error { 94 | portEntities, err := c.SearchEntities(ctx, port.SearchBody{ 95 | Rules: []port.Rule{ 96 | { 97 | Property: "$datasource", 98 | Operator: "contains", 99 | Value: "port-k8s-exporter", 100 | }, 101 | { 102 | Property: "$datasource", 103 | Operator: "contains", 104 | Value: fmt.Sprintf("(statekey/%s)", stateKey), 105 | }, 106 | }, 107 | Combinator: "and", 108 | }) 109 | if err != nil { 110 | return fmt.Errorf("error searching Port entities: %v", err) 111 | } 112 | 113 | for _, portEntity := range portEntities { 114 | _, ok := existingEntitiesSet[c.GetEntityIdentifierKey(&portEntity)] 115 | if !ok { 116 | err := c.DeleteEntity(ctx, portEntity.Identifier, portEntity.Blueprint, c.DeleteDependents) 117 | if err != nil { 118 | klog.Errorf("error deleting Port entity '%s' of blueprint '%s': %v", portEntity.Identifier, portEntity.Blueprint, err) 119 | continue 120 | } 121 | klog.V(0).Infof("Successfully deleted entity '%s' of blueprint '%s'", portEntity.Identifier, portEntity.Blueprint) 122 | } 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func (c *PortClient) GetEntityIdentifierKey(portEntity *port.Entity) string { 129 | return fmt.Sprintf("%s;%s", portEntity.Blueprint, portEntity.Identifier) 130 | } 131 | -------------------------------------------------------------------------------- /pkg/port/cli/integration.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "github.com/port-labs/port-k8s-exporter/pkg/parsers" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port" 7 | "net/url" 8 | ) 9 | 10 | func parseIntegration(i *port.Integration) *port.Integration { 11 | x := &port.Integration{ 12 | Title: i.Title, 13 | InstallationAppType: i.InstallationAppType, 14 | InstallationId: i.InstallationId, 15 | Config: i.Config, 16 | } 17 | 18 | if i.EventListener.Type == "KAFKA" { 19 | x.EventListener = &port.EventListenerSettings{ 20 | Type: i.EventListener.Type, 21 | } 22 | } 23 | 24 | return x 25 | } 26 | 27 | func (c *PortClient) CreateIntegration(i *port.Integration, queryParams map[string]string) (*port.Integration, error) { 28 | pb := &port.ResponseBody{} 29 | resp, err := c.Client.R(). 30 | SetQueryParams(queryParams). 31 | SetBody(parseIntegration(i)). 32 | SetResult(&pb). 33 | Post("v1/integration") 34 | if err != nil { 35 | return nil, err 36 | } 37 | if !pb.OK { 38 | return nil, fmt.Errorf("failed to create integration, got: %s", resp.Body()) 39 | } 40 | return &pb.Integration, nil 41 | } 42 | 43 | func (c *PortClient) GetIntegration(stateKey string) (*port.Integration, error) { 44 | pb := &port.ResponseBody{} 45 | resp, err := c.Client.R(). 46 | SetResult(&pb). 47 | Get(fmt.Sprintf("v1/integration/%s", stateKey)) 48 | if err != nil { 49 | return nil, err 50 | } 51 | if !pb.OK { 52 | return nil, fmt.Errorf("failed to get integration, got: %s", resp.Body()) 53 | } 54 | return &pb.Integration, nil 55 | } 56 | 57 | func (c *PortClient) DeleteIntegration(stateKey string) error { 58 | resp, err := c.Client.R(). 59 | Delete(fmt.Sprintf("v1/integration/%s", stateKey)) 60 | if err != nil { 61 | return err 62 | } 63 | if resp.StatusCode() != 200 { 64 | return fmt.Errorf("failed to delete integration, got: %s", resp.Body()) 65 | } 66 | return nil 67 | } 68 | 69 | func (c *PortClient) PatchIntegration(stateKey string, integration *port.Integration) error { 70 | pb := &port.ResponseBody{} 71 | resp, err := c.Client.R(). 72 | SetBody(integration). 73 | SetResult(&pb). 74 | Patch(fmt.Sprintf("v1/integration/%s", stateKey)) 75 | if err != nil { 76 | return err 77 | } 78 | if !pb.OK { 79 | return fmt.Errorf("failed to update config, got: %s", resp.Body()) 80 | } 81 | return nil 82 | } 83 | 84 | func (c *PortClient) PostIntegrationKindExample(stateKey string, kind string, examples []interface{}) error { 85 | pb := &port.ResponseBody{} 86 | resp, err := c.Client.R(). 87 | SetBody(map[string]interface{}{ 88 | "examples": parsers.ParseSensitiveData(examples), 89 | }). 90 | SetResult(&pb). 91 | Post(fmt.Sprintf("v1/integration/%s/kinds/%s/examples", url.QueryEscape(stateKey), url.QueryEscape(kind))) 92 | if err != nil { 93 | return err 94 | } 95 | if !pb.OK { 96 | return fmt.Errorf("failed to post integration kind example, got: %s", resp.Body()) 97 | } 98 | return nil 99 | } 100 | 101 | func (c *PortClient) GetIntegrationKinds(stateKey string) (map[string]port.IntegrationKind, error) { 102 | pb := &port.IntegrationKindsResponse{} 103 | resp, err := c.Client.R(). 104 | SetResult(&pb). 105 | Get(fmt.Sprintf("v1/integration/%s/kinds", stateKey)) 106 | if err != nil { 107 | return nil, err 108 | } 109 | if !pb.OK { 110 | return nil, fmt.Errorf("failed to get integration kinds, got: %s", resp.Body()) 111 | } 112 | return pb.Data, nil 113 | } 114 | -------------------------------------------------------------------------------- /pkg/port/cli/kafka_crednetials.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "github.com/port-labs/port-k8s-exporter/pkg/port" 6 | ) 7 | 8 | func (c *PortClient) GetKafkaCredentials() (*port.OrgKafkaCredentials, error) { 9 | pb := &port.ResponseBody{} 10 | resp, err := c.Client.R(). 11 | SetResult(&pb). 12 | Get("v1/kafka-credentials") 13 | if err != nil { 14 | return nil, err 15 | } 16 | if !pb.OK { 17 | return nil, fmt.Errorf("failed to get kafka crednetials, got: %s", resp.Body()) 18 | } 19 | return &pb.KafkaCredentials, nil 20 | } 21 | -------------------------------------------------------------------------------- /pkg/port/cli/org_details.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port" 7 | ) 8 | 9 | func (c *PortClient) GetOrgId() (string, error) { 10 | pb := &port.ResponseBody{} 11 | resp, err := c.Client.R(). 12 | SetResult(&pb). 13 | Get("v1/organization") 14 | 15 | z := &struct { 16 | }{} 17 | _ = json.Unmarshal(resp.Body(), &z) 18 | if err != nil { 19 | return "", err 20 | } 21 | if !pb.OK { 22 | return "", fmt.Errorf("failed to get orgId, got: %s", resp.Body()) 23 | } 24 | return pb.OrgDetails.OrgId, nil 25 | } 26 | 27 | func (c *PortClient) GetOrganizationFeatureFlags() ([]string, error) { 28 | pb := &port.ResponseBody{} 29 | resp, err := c.Client.R(). 30 | SetResult(&pb). 31 | Get("v1/organization") 32 | if err != nil { 33 | return nil, err 34 | } 35 | if !pb.OK { 36 | return nil, fmt.Errorf("failed to get organization feature flags, got: %s", resp.Body()) 37 | } 38 | return pb.OrgDetails.FeatureFlags, nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/port/cli/page.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "github.com/port-labs/port-k8s-exporter/pkg/port" 6 | ) 7 | 8 | func (c *PortClient) CreatePage(p port.Page) error { 9 | pb := &port.ResponseBody{} 10 | resp, err := c.Client.R(). 11 | SetBody(p). 12 | SetResult(&pb). 13 | Post("v1/pages") 14 | if err != nil { 15 | return err 16 | } 17 | 18 | if resp.IsError() { 19 | return fmt.Errorf("failed to create page, got: %s", resp.Body()) 20 | } 21 | return nil 22 | } 23 | 24 | func (c *PortClient) GetPage(identifier string) (*port.Page, error) { 25 | pb := &port.ResponseBody{} 26 | resp, err := c.Client.R(). 27 | SetResult(&pb). 28 | SetPathParam("page", identifier). 29 | Get("v1/pages/{page}") 30 | if err != nil { 31 | return nil, err 32 | } 33 | if resp.IsError() { 34 | return nil, fmt.Errorf("failed to get page, got: %s", resp.Body()) 35 | } 36 | return &pb.Pages, nil 37 | } 38 | 39 | func (c *PortClient) DeletePage(identifier string) error { 40 | resp, err := c.Client.R(). 41 | SetPathParam("page", identifier). 42 | Delete("v1/pages/{page}") 43 | if err != nil { 44 | return err 45 | } 46 | if resp.IsError() { 47 | return fmt.Errorf("failed to delete page, got: %s", resp.Body()) 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/port/cli/scorecards.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "github.com/port-labs/port-k8s-exporter/pkg/port" 6 | ) 7 | 8 | func (c *PortClient) CreateScorecard(blueprintIdentifier string, scorecard port.Scorecard) (*port.Scorecard, error) { 9 | pb := &port.ResponseBody{} 10 | resp, err := c.Client.R(). 11 | SetResult(&pb). 12 | SetBody(scorecard). 13 | SetPathParam("blueprint", blueprintIdentifier). 14 | Post("v1/blueprints/{blueprint}/scorecards") 15 | if err != nil { 16 | return nil, err 17 | } 18 | if !pb.OK { 19 | return nil, fmt.Errorf("failed to create scorecard, got: %s", resp.Body()) 20 | } 21 | return &pb.Scorecard, nil 22 | } 23 | -------------------------------------------------------------------------------- /pkg/port/entity/entity.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "hash/fnv" 8 | "reflect" 9 | "strconv" 10 | 11 | "github.com/port-labs/port-k8s-exporter/pkg/config" 12 | "github.com/port-labs/port-k8s-exporter/pkg/jq" 13 | "github.com/port-labs/port-k8s-exporter/pkg/port" 14 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 15 | ) 16 | 17 | func CheckIfOwnEntity(entity port.EntityRequest, portClient *cli.PortClient) (*bool, error) { 18 | portEntities, err := portClient.SearchEntities(context.Background(), port.SearchBody{ 19 | Rules: []port.Rule{ 20 | { 21 | Property: "$datasource", 22 | Operator: "contains", 23 | Value: "port-k8s-exporter", 24 | }, 25 | { 26 | Property: "$identifier", 27 | Operator: "=", 28 | Value: entity.Identifier, 29 | }, 30 | { 31 | Property: "$datasource", 32 | Operator: "contains", 33 | Value: fmt.Sprintf("(statekey/%s)", config.ApplicationConfig.StateKey), 34 | }, 35 | { 36 | Property: "$blueprint", 37 | Operator: "=", 38 | Value: entity.Blueprint, 39 | }, 40 | }, 41 | Combinator: "and", 42 | }) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | if len(portEntities) > 0 { 48 | result := true 49 | return &result, nil 50 | } 51 | result := false 52 | return &result, nil 53 | } 54 | 55 | func HashAllEntities(entities []port.EntityRequest) (string, error) { 56 | h := fnv.New64a() 57 | for _, entity := range entities { 58 | entityBytes, err := json.Marshal(entity) 59 | if err != nil { 60 | return "", err 61 | } 62 | _, err = h.Write(entityBytes) 63 | if err != nil { 64 | return "", err 65 | } 66 | } 67 | return strconv.FormatUint(h.Sum64(), 10), nil 68 | } 69 | 70 | func MapEntities(obj interface{}, mappings []port.EntityMapping) ([]port.EntityRequest, error) { 71 | entities := make([]port.EntityRequest, 0, len(mappings)) 72 | for _, entityMapping := range mappings { 73 | portEntity, err := newEntityRequest(obj, entityMapping) 74 | if err != nil { 75 | return nil, fmt.Errorf("invalid entity mapping '%#v': %v", entityMapping, err) 76 | } 77 | entities = append(entities, *portEntity) 78 | } 79 | 80 | return entities, nil 81 | } 82 | 83 | func newEntityRequest(obj interface{}, mapping port.EntityMapping) (*port.EntityRequest, error) { 84 | var err error 85 | entity := &port.EntityRequest{} 86 | 87 | if reflect.TypeOf(mapping.Identifier).Kind() == reflect.String { 88 | entity.Identifier, err = jq.ParseString(mapping.Identifier.(string), obj) 89 | } else if reflect.TypeOf(mapping.Identifier).Kind() == reflect.Map { 90 | entity.Identifier, err = jq.ParseMapRecursively(mapping.Identifier.(map[string]interface{}), obj) 91 | } else { 92 | return nil, fmt.Errorf("invalid identifier type '%T'", mapping.Identifier) 93 | } 94 | 95 | if err != nil { 96 | return nil, err 97 | } 98 | if mapping.Title != "" { 99 | entity.Title, err = jq.ParseString(mapping.Title, obj) 100 | if err != nil { 101 | return nil, err 102 | } 103 | } 104 | entity.Blueprint, err = jq.ParseString(mapping.Blueprint, obj) 105 | if err != nil { 106 | return nil, err 107 | } 108 | if mapping.Team != nil { 109 | if reflect.TypeOf(mapping.Team).Kind() == reflect.String { 110 | entity.Team, err = jq.ParseString(mapping.Team.(string), obj) 111 | } else if reflect.TypeOf(mapping.Team).Kind() == reflect.Map { 112 | entity.Team, err = jq.ParseMapRecursively(mapping.Team.(map[string]interface{}), obj) 113 | } else { 114 | return nil, fmt.Errorf("invalid team type '%T'", mapping.Team) 115 | } 116 | if err != nil { 117 | return nil, err 118 | } 119 | } 120 | if mapping.Icon != "" { 121 | entity.Icon, err = jq.ParseString(mapping.Icon, obj) 122 | if err != nil { 123 | return nil, err 124 | } 125 | } 126 | entity.Properties, err = jq.ParseMapInterface(mapping.Properties, obj) 127 | if err != nil { 128 | return nil, err 129 | } 130 | entity.Relations, err = jq.ParseMapRecursively(mapping.Relations, obj) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | return entity, err 136 | 137 | } 138 | -------------------------------------------------------------------------------- /pkg/port/integration/integration.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port" 7 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const ( 13 | integrationPollingInitialSeconds = 3 14 | integrationPollingRetryLimit = 30 15 | integrationPollingBackoffFactor = 1.15 16 | createResourcesParamName = "integration_modes" 17 | ) 18 | 19 | var createResourcesParamValue = []string{"create_resources"} 20 | 21 | type DefaultsProvisionFailedError struct { 22 | RetryLimit int 23 | } 24 | 25 | func (e *DefaultsProvisionFailedError) Error() string { 26 | return fmt.Sprintf("integration config was not provisioned after %d attempts", e.RetryLimit) 27 | } 28 | 29 | func CreateIntegration(portClient *cli.PortClient, stateKey string, eventListenerType string, appConfig *port.IntegrationAppConfig, createPortResourcesOriginInPort bool) (*port.Integration, error) { 30 | integration := &port.Integration{ 31 | Title: stateKey, 32 | InstallationAppType: "K8S EXPORTER", 33 | InstallationId: stateKey, 34 | EventListener: &port.EventListenerSettings{ 35 | Type: eventListenerType, 36 | }, 37 | Config: appConfig, 38 | } 39 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 40 | if err != nil { 41 | return nil, fmt.Errorf("error authenticating with Port: %v", err) 42 | } 43 | queryParams := map[string]string{} 44 | if createPortResourcesOriginInPort { 45 | queryParams = map[string]string{ 46 | createResourcesParamName: strings.Join(createResourcesParamValue, ","), 47 | } 48 | } 49 | 50 | createdIntegration, err := portClient.CreateIntegration(integration, queryParams) 51 | if err != nil { 52 | return nil, fmt.Errorf("error creating Port integration: %v", err) 53 | } 54 | 55 | if createPortResourcesOriginInPort { 56 | return PollIntegrationUntilDefaultProvisioningComplete(portClient, stateKey) 57 | } 58 | 59 | return createdIntegration, nil 60 | } 61 | 62 | func GetIntegration(portClient *cli.PortClient, stateKey string) (*port.Integration, error) { 63 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 64 | if err != nil { 65 | return nil, fmt.Errorf("error authenticating with Port: %v", err) 66 | } 67 | 68 | apiIntegration, err := portClient.GetIntegration(stateKey) 69 | if err != nil { 70 | return nil, fmt.Errorf("error getting Port integration: %v", err) 71 | } 72 | 73 | if apiIntegration.Config != nil { 74 | defaultTrue := true 75 | if apiIntegration.Config.SendRawDataExamples == nil { 76 | apiIntegration.Config.SendRawDataExamples = &defaultTrue 77 | } 78 | } 79 | 80 | return apiIntegration, nil 81 | } 82 | 83 | func DeleteIntegration(portClient *cli.PortClient, stateKey string) error { 84 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 85 | if err != nil { 86 | return fmt.Errorf("error authenticating with Port: %v", err) 87 | } 88 | 89 | err = portClient.DeleteIntegration(stateKey) 90 | if err != nil { 91 | return fmt.Errorf("error deleting Port integration: %v", err) 92 | } 93 | return nil 94 | } 95 | 96 | func PatchIntegration(portClient *cli.PortClient, stateKey string, integration *port.Integration) error { 97 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 98 | if err != nil { 99 | return fmt.Errorf("error authenticating with Port: %v", err) 100 | } 101 | 102 | err = portClient.PatchIntegration(stateKey, integration) 103 | if err != nil { 104 | return fmt.Errorf("error updating Port integration: %v", err) 105 | } 106 | return nil 107 | } 108 | 109 | func PostIntegrationKindExample(portClient *cli.PortClient, stateKey string, kind string, examples []interface{}) error { 110 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 111 | if err != nil { 112 | return fmt.Errorf("error authenticating with Port: %v", err) 113 | } 114 | 115 | err = portClient.PostIntegrationKindExample(stateKey, kind, examples) 116 | if err != nil { 117 | return err 118 | } 119 | return nil 120 | } 121 | 122 | func PollIntegrationUntilDefaultProvisioningComplete(portClient *cli.PortClient, stateKey string) (*port.Integration, error) { 123 | attempts := 0 124 | currentIntervalSeconds := integrationPollingInitialSeconds 125 | 126 | for attempts < integrationPollingRetryLimit { 127 | fmt.Printf("Fetching created integration and validating config, attempt %d/%d\n", attempts+1, integrationPollingRetryLimit) 128 | 129 | integration, err := GetIntegration(portClient, stateKey) 130 | if err != nil { 131 | return nil, fmt.Errorf("error getting integration during polling: %v", err) 132 | } 133 | 134 | if integration.Config.Resources != nil { 135 | return integration, nil 136 | } 137 | 138 | fmt.Printf("Integration config is still being provisioned, retrying in %d seconds\n", currentIntervalSeconds) 139 | time.Sleep(time.Duration(currentIntervalSeconds) * time.Second) 140 | 141 | attempts++ 142 | currentIntervalSeconds = int(float64(currentIntervalSeconds) * integrationPollingBackoffFactor) 143 | } 144 | 145 | return nil, &DefaultsProvisionFailedError{RetryLimit: integrationPollingRetryLimit} 146 | } 147 | -------------------------------------------------------------------------------- /pkg/port/kafka_credentials/credentials.go: -------------------------------------------------------------------------------- 1 | package kafka_credentials 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port" 7 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 8 | "k8s.io/klog/v2" 9 | ) 10 | 11 | func GetKafkaCredentials(portClient *cli.PortClient) (*port.OrgKafkaCredentials, error) { 12 | klog.Infof("Getting Port kafka credentials") 13 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 14 | if err != nil { 15 | return nil, fmt.Errorf("error authenticating with Port: %v", err) 16 | } 17 | 18 | r, err := portClient.GetKafkaCredentials() 19 | if err != nil { 20 | return nil, fmt.Errorf("error getting Port org credentials: %v", err) 21 | } 22 | 23 | if r.Username == "" || r.Password == "" { 24 | return nil, fmt.Errorf("error getting Port org credentials: username or password is empty") 25 | } 26 | 27 | return r, nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/port/models.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ( 8 | Meta struct { 9 | CreatedAt *time.Time `json:"createdAt,omitempty"` 10 | UpdatedAt *time.Time `json:"updatedAt,omitempty"` 11 | CreatedBy string `json:"createdBy,omitempty"` 12 | UpdatedBy string `json:"updatedBy,omitempty"` 13 | } 14 | AccessTokenResponse struct { 15 | Ok bool `json:"ok"` 16 | AccessToken string `json:"accessToken"` 17 | ExpiresIn int64 `json:"expiresIn"` 18 | TokenType string `json:"tokenType"` 19 | } 20 | Entity struct { 21 | Meta 22 | Identifier string `json:"identifier,omitempty"` 23 | Title string `json:"title,omitempty"` 24 | Blueprint string `json:"blueprint"` 25 | Icon string `json:"icon,omitempty"` 26 | Team interface{} `json:"team,omitempty"` 27 | Properties map[string]interface{} `json:"properties"` 28 | Relations map[string]interface{} `json:"relations"` 29 | } 30 | 31 | Integration struct { 32 | InstallationId string `json:"installationId,omitempty"` 33 | Title string `json:"title,omitempty"` 34 | Version string `json:"version,omitempty"` 35 | InstallationAppType string `json:"installationAppType,omitempty"` 36 | EventListener *EventListenerSettings `json:"changelogDestination,omitempty"` 37 | Config *IntegrationAppConfig `json:"config,omitempty"` 38 | UpdatedAt *time.Time `json:"updatedAt,omitempty"` 39 | } 40 | 41 | Example struct { 42 | Id string `json:"_id,omitempty"` 43 | Data map[string]any `json:"data,omitempty"` 44 | } 45 | 46 | IntegrationKind struct { 47 | Examples []Example `json:"examples"` 48 | } 49 | 50 | Property struct { 51 | Type string `json:"type,omitempty"` 52 | Title string `json:"title,omitempty"` 53 | Identifier string `json:"identifier,omitempty"` 54 | Default any `json:"default,omitempty"` 55 | Icon string `json:"icon,omitempty"` 56 | Format string `json:"format,omitempty"` 57 | Description string `json:"description,omitempty"` 58 | Blueprint string `json:"blueprint,omitempty"` 59 | Pattern string `json:"pattern,omitempty"` 60 | Enum []string `json:"enum,omitempty"` 61 | EnumColors map[string]string `json:"enumColors,omitempty"` 62 | } 63 | 64 | ActionProperty struct { 65 | Type string `json:"type,omitempty"` 66 | Title string `json:"title,omitempty"` 67 | Identifier string `json:"identifier,omitempty"` 68 | Default any `json:"default,omitempty"` 69 | Icon string `json:"icon,omitempty"` 70 | Format string `json:"format,omitempty"` 71 | Description string `json:"description,omitempty"` 72 | Blueprint string `json:"blueprint,omitempty"` 73 | Pattern string `json:"pattern,omitempty"` 74 | Enum []string `json:"enum,omitempty"` 75 | EnumColors map[string]string `json:"enumColors,omitempty"` 76 | Visible *bool `json:"visible,omitempty"` 77 | } 78 | 79 | BlueprintMirrorProperty struct { 80 | Identifier string `json:"identifier,omitempty"` 81 | Title string `json:"title,omitempty"` 82 | Path string `json:"path,omitempty"` 83 | } 84 | 85 | BlueprintCalculationProperty struct { 86 | Identifier string `json:"identifier,omitempty"` 87 | Title string `json:"title,omitempty"` 88 | Calculation string `json:"calculation,omitempty"` 89 | Colors map[string]string `json:"colors,omitempty"` 90 | Colorized bool `json:"colorized,omitempty"` 91 | Format string `json:"format,omitempty"` 92 | Type string `json:"type,omitempty"` 93 | } 94 | 95 | BlueprintAggregationProperty struct { 96 | Title string `json:"title"` 97 | Target string `json:"target"` 98 | CalculationSpec interface{} `json:"calculationSpec"` 99 | Query interface{} `json:"query,omitempty"` 100 | Description string `json:"description,omitempty"` 101 | Icon string `json:"icon,omitempty"` 102 | Type string `json:"type,omitempty"` 103 | } 104 | 105 | BlueprintSchema struct { 106 | Properties map[string]Property `json:"properties"` 107 | Required []string `json:"required,omitempty"` 108 | } 109 | 110 | InvocationMethod struct { 111 | Type string `json:"type,omitempty"` 112 | Url string `json:"url,omitempty"` 113 | Organization string `json:"org,omitempty"` 114 | Repository string `json:"repo,omitempty"` 115 | Workflow string `json:"workflow,omitempty"` 116 | WorkflowInputs map[string]interface{} `json:"workflowInputs,omitempty"` 117 | ReportWorkflowStatus bool `json:"reportWorkflowStatus,omitempty"` 118 | } 119 | 120 | ChangelogDestination struct { 121 | Type string `json:"type,omitempty"` 122 | Url string `json:"url,omitempty"` 123 | } 124 | 125 | ActionUserInputs struct { 126 | Properties map[string]ActionProperty `json:"properties"` 127 | Required []string `json:"required,omitempty"` 128 | } 129 | 130 | Blueprint struct { 131 | Meta 132 | Identifier string `json:"identifier,omitempty"` 133 | Title string `json:"title,omitempty"` 134 | Icon string `json:"icon"` 135 | Description string `json:"description"` 136 | Schema BlueprintSchema `json:"schema"` 137 | CalculationProperties map[string]BlueprintCalculationProperty `json:"calculationProperties,omitempty"` 138 | AggregationProperties map[string]BlueprintAggregationProperty `json:"aggregationProperties,omitempty"` 139 | MirrorProperties map[string]BlueprintMirrorProperty `json:"mirrorProperties,omitempty"` 140 | ChangelogDestination *ChangelogDestination `json:"changelogDestination,omitempty"` 141 | Relations map[string]Relation `json:"relations,omitempty"` 142 | Ownership *Ownership `json:"ownership,omitempty"` 143 | } 144 | 145 | Ownership struct { 146 | Type string `json:"type,omitempty"` 147 | } 148 | 149 | Page struct { 150 | Identifier string `json:"identifier"` 151 | Blueprint string `json:"blueprint,omitempty"` 152 | Title string `json:"title,omitempty"` 153 | Icon string `json:"icon,omitempty"` 154 | Widgets interface{} `json:"widgets,omitempty"` 155 | Type string `json:"type,omitempty"` 156 | } 157 | TriggerEvent struct { 158 | Type string `json:"type"` 159 | BlueprintIdentifier *string `json:"blueprintIdentifier,omitempty"` 160 | PropertyIdentifier *string `json:"propertyIdentifier,omitempty"` 161 | } 162 | 163 | TriggerCondition struct { 164 | Type string `json:"type"` 165 | Expressions []string `json:"expressions"` 166 | Combinator *string `json:"combinator,omitempty"` 167 | } 168 | Trigger struct { 169 | Type string `json:"type"` 170 | BlueprintIdentifier string `json:"blueprintIdentifier,omitempty"` 171 | Operation string `json:"operation,omitempty"` 172 | UserInputs *ActionUserInputs `json:"userInputs,omitempty"` 173 | Event *TriggerEvent `json:"event,omitempty"` 174 | Condition *TriggerCondition `json:"condition,omitempty"` 175 | } 176 | 177 | Action struct { 178 | ID string `json:"id,omitempty"` 179 | Identifier string `json:"identifier"` 180 | Title string `json:"title,omitempty"` 181 | Icon string `json:"icon,omitempty"` 182 | Description string `json:"description,omitempty"` 183 | Trigger *Trigger `json:"trigger"` 184 | InvocationMethod *InvocationMethod `json:"invocationMethod,omitempty"` 185 | Publish bool `json:"publish,omitempty"` 186 | } 187 | 188 | Scorecard struct { 189 | Identifier string `json:"identifier,omitempty"` 190 | Title string `json:"title,omitempty"` 191 | Filter interface{} `json:"filter,omitempty"` 192 | Rules []interface{} `json:"rules,omitempty"` 193 | } 194 | 195 | Relation struct { 196 | Identifier string `json:"identifier,omitempty"` 197 | Title string `json:"title,omitempty"` 198 | Target string `json:"target,omitempty"` 199 | Required bool `json:"required,omitempty"` 200 | Many bool `json:"many,omitempty"` 201 | } 202 | 203 | Rule struct { 204 | Property string `json:"property"` 205 | Operator string `json:"operator"` 206 | Value interface{} `json:"value"` 207 | } 208 | 209 | OrgKafkaCredentials struct { 210 | Username string `json:"username"` 211 | Password string `json:"password"` 212 | } 213 | 214 | OrgDetails struct { 215 | OrgId string `json:"id"` 216 | FeatureFlags []string `json:"featureFlags,omitempty"` 217 | } 218 | ) 219 | 220 | type SearchBody struct { 221 | Rules []Rule `json:"rules"` 222 | Combinator string `json:"combinator"` 223 | } 224 | 225 | type ResponseBody struct { 226 | OK bool `json:"ok"` 227 | Entity Entity `json:"entity"` 228 | Blueprint Blueprint `json:"blueprint"` 229 | Action Action `json:"action"` 230 | Entities []Entity `json:"entities"` 231 | Integration Integration `json:"integration"` 232 | KafkaCredentials OrgKafkaCredentials `json:"credentials"` 233 | OrgDetails OrgDetails `json:"organization"` 234 | Scorecard Scorecard `json:"scorecard"` 235 | Pages Page `json:"pages"` 236 | MigrationId string `json:"migrationId"` 237 | Migration Migration `json:"migration"` 238 | } 239 | 240 | type Migration struct { 241 | Status string `json:"status"` 242 | } 243 | 244 | type IntegrationKindsResponse struct { 245 | OK bool `json:"ok"` 246 | Data map[string]IntegrationKind `json:"data"` 247 | } 248 | 249 | type EntityMapping struct { 250 | Identifier interface{} `json:"identifier" yaml:"identifier"` 251 | Title string `json:"title,omitempty" yaml:"title,omitempty"` 252 | Blueprint string `json:"blueprint" yaml:"blueprint"` 253 | Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` 254 | Team interface{} `json:"team,omitempty" yaml:"team,omitempty"` 255 | Properties map[string]string `json:"properties,omitempty" yaml:"properties,omitempty"` 256 | Relations map[string]interface{} `json:"relations,omitempty" yaml:"relations,omitempty"` 257 | } 258 | 259 | type EntityRequest struct { 260 | Identifier interface{} `json:"identifier" yaml:"identifier"` 261 | Title string `json:"title,omitempty" yaml:"title,omitempty"` 262 | Blueprint string `json:"blueprint" yaml:"blueprint"` 263 | Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` 264 | Team interface{} `json:"team,omitempty" yaml:"team,omitempty"` 265 | Properties map[string]interface{} `json:"properties,omitempty" yaml:"properties,omitempty"` 266 | Relations map[string]interface{} `json:"relations,omitempty" yaml:"relations,omitempty"` 267 | } 268 | 269 | type EntityMappings struct { 270 | Mappings []EntityMapping `json:"mappings" yaml:"mappings"` 271 | } 272 | 273 | type Port struct { 274 | Entity EntityMappings `json:"entity" yaml:"entity"` 275 | ItemsToParse string `json:"itemsToParse,omitempty" yaml:"itemsToParse"` 276 | } 277 | 278 | type Selector struct { 279 | Query string `json:"query,omitempty" yaml:"query"` 280 | } 281 | 282 | type Resource struct { 283 | Kind string `json:"kind" yaml:"kind"` 284 | Selector Selector `json:"selector,omitempty" yaml:"selector,omitempty"` 285 | Port Port `json:"port" yaml:"port"` 286 | } 287 | 288 | type EventListenerSettings struct { 289 | Type string `json:"type,omitempty"` 290 | } 291 | 292 | type KindConfig struct { 293 | Selector Selector 294 | Port Port 295 | } 296 | 297 | type AggregatedResource struct { 298 | Kind string 299 | KindConfigs []KindConfig 300 | } 301 | 302 | type IntegrationAppConfig struct { 303 | DeleteDependents bool `json:"deleteDependents,omitempty" yaml:"deleteDependents,omitempty"` 304 | CreateMissingRelatedEntities bool `json:"createMissingRelatedEntities,omitempty" yaml:"createMissingRelatedEntities,omitempty"` 305 | Resources []Resource `json:"resources,omitempty" yaml:"resources,omitempty"` 306 | CRDSToDiscover string `json:"crdsToDiscover,omitempty"` 307 | OverwriteCRDsActions bool `json:"overwriteCrdsActions,omitempty"` 308 | UpdateEntityOnlyOnDiff *bool `json:"updateEntityOnlyOnDiff,omitempty"` 309 | SendRawDataExamples *bool `json:"sendRawDataExamples,omitempty"` 310 | } 311 | 312 | const ( 313 | OrgUseProvisionedDefaultsFeatureFlag = "USE_PROVISIONED_DEFAULTS" 314 | ) 315 | 316 | type CreatePortResourcesOrigin string 317 | 318 | const ( 319 | CreatePortResourcesOriginPort CreatePortResourcesOrigin = "Port" 320 | CreatePortResourcesOriginK8S CreatePortResourcesOrigin = "K8S" 321 | ) 322 | 323 | type Config struct { 324 | ResyncInterval uint `yaml:"resyncInterval,omitempty"` 325 | StateKey string `yaml:"stateKey,omitempty"` 326 | EventListenerType string `yaml:"eventListenerType,omitempty"` 327 | CreateDefaultResources bool `yaml:"createDefaultResources,omitempty"` 328 | CreatePortResourcesOrigin CreatePortResourcesOrigin `yaml:"createPortResourcesOrigin,omitempty"` 329 | OverwriteConfigurationOnRestart bool `yaml:"overwriteConfigurationOnRestart,omitempty"` 330 | // These Configurations are used only for setting up the Integration on installation or when using OverwriteConfigurationOnRestart flag. 331 | Resources []Resource `yaml:"resources,omitempty"` 332 | CRDSToDiscover string `yaml:"crdsToDiscover,omitempty"` 333 | OverwriteCRDsActions bool `yaml:"overwriteCrdsActions,omitempty"` 334 | DeleteDependents bool `yaml:"deleteDependents,omitempty"` 335 | CreateMissingRelatedEntities bool `yaml:"createMissingRelatedEntities,omitempty"` 336 | } 337 | 338 | type Team struct { 339 | Name string `json:"name"` 340 | } 341 | -------------------------------------------------------------------------------- /pkg/port/org_details/org_details.go: -------------------------------------------------------------------------------- 1 | package org_details 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 7 | ) 8 | 9 | func GetOrgId(portClient *cli.PortClient) (string, error) { 10 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 11 | if err != nil { 12 | return "", fmt.Errorf("error authenticating with Port: %v", err) 13 | } 14 | 15 | r, err := portClient.GetOrgId() 16 | if err != nil { 17 | return "", fmt.Errorf("error getting Port org credentials: %v", err) 18 | } 19 | 20 | return r, nil 21 | } 22 | 23 | func GetOrganizationFeatureFlags(portClient *cli.PortClient) ([]string, error) { 24 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 25 | if err != nil { 26 | return nil, fmt.Errorf("error authenticating with Port: %v", err) 27 | } 28 | 29 | flags, err := portClient.GetOrganizationFeatureFlags() 30 | if err != nil { 31 | return nil, fmt.Errorf("error getting organization feature flags: %v", err) 32 | } 33 | 34 | return flags, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/port/page/page.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port" 7 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 8 | ) 9 | 10 | func CreatePage(portClient *cli.PortClient, page port.Page) error { 11 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 12 | if err != nil { 13 | return fmt.Errorf("error authenticating with Port: %v", err) 14 | } 15 | 16 | err = portClient.CreatePage(page) 17 | if err != nil { 18 | return fmt.Errorf("error creating Port page: %v", err) 19 | } 20 | return nil 21 | } 22 | 23 | func GetPage(portClient *cli.PortClient, identifier string) (*port.Page, error) { 24 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 25 | if err != nil { 26 | return nil, fmt.Errorf("error authenticating with Port: %v", err) 27 | } 28 | 29 | apiPage, err := portClient.GetPage(identifier) 30 | if err != nil { 31 | return nil, fmt.Errorf("error getting Port page: %v", err) 32 | } 33 | 34 | return apiPage, nil 35 | } 36 | 37 | func DeletePage(portClient *cli.PortClient, identifier string) error { 38 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 39 | if err != nil { 40 | return fmt.Errorf("error authenticating with Port: %v", err) 41 | } 42 | 43 | err = portClient.DeletePage(identifier) 44 | if err != nil { 45 | return fmt.Errorf("error deleting Port page: %v", err) 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/port/scorecards/scorecards.go: -------------------------------------------------------------------------------- 1 | package scorecards 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/port-labs/port-k8s-exporter/pkg/port" 7 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 8 | ) 9 | 10 | func CreateScorecard(portClient *cli.PortClient, blueprintIdentifier string, scorecard port.Scorecard) error { 11 | _, err := portClient.Authenticate(context.Background(), portClient.ClientID, portClient.ClientSecret) 12 | if err != nil { 13 | return fmt.Errorf("error authenticating with Port: %v", err) 14 | } 15 | 16 | _, err = portClient.CreateScorecard(blueprintIdentifier, scorecard) 17 | if err != nil { 18 | return fmt.Errorf("error creating Port integration: %v", err) 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /pkg/signal/signal.go: -------------------------------------------------------------------------------- 1 | package signal 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "sync" 8 | "syscall" 9 | ) 10 | 11 | func SetupSignalHandler() (stopCh chan struct{}) { 12 | mutex := sync.Mutex{} 13 | stop := make(chan struct{}) 14 | gracefulStop := false 15 | shutdownCh := make(chan os.Signal, 2) 16 | signal.Notify(shutdownCh, os.Interrupt, syscall.SIGTERM) 17 | go func() { 18 | <-shutdownCh 19 | mutex.Lock() 20 | if gracefulStop == false { 21 | fmt.Fprint(os.Stderr, "Received SIGTERM, exiting gracefully...\n") 22 | close(stop) 23 | } 24 | mutex.Unlock() 25 | <-shutdownCh 26 | mutex.Lock() 27 | if gracefulStop == false { 28 | fmt.Fprint(os.Stderr, "Received SIGTERM again, exiting forcefully...\n") 29 | os.Exit(1) 30 | } 31 | mutex.Unlock() 32 | }() 33 | 34 | go func() { 35 | <-stop 36 | mutex.Lock() 37 | gracefulStop = true 38 | mutex.Unlock() 39 | close(shutdownCh) 40 | }() 41 | 42 | return stop 43 | } 44 | -------------------------------------------------------------------------------- /test_utils/cleanup.go: -------------------------------------------------------------------------------- 1 | package testing_init 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/port-labs/port-k8s-exporter/pkg/port/blueprint" 7 | "github.com/port-labs/port-k8s-exporter/pkg/port/cli" 8 | "github.com/port-labs/port-k8s-exporter/pkg/port/page" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func CheckResourcesExistence( 13 | shouldExist bool, 14 | shouldDeleteEntities bool, 15 | portClient *cli.PortClient, 16 | t *testing.T, 17 | blueprints []string, 18 | pages []string, 19 | actions []string, 20 | ) { 21 | for _, a := range actions { 22 | _, err := cli.GetAction(portClient, a) 23 | if err == nil { 24 | _ = cli.DeleteAction(portClient, a) 25 | } 26 | if shouldExist { 27 | assert.Nil(t, err) 28 | } else { 29 | assert.NotNil(t, err) 30 | } 31 | } 32 | 33 | for _, bp := range blueprints { 34 | _, err := blueprint.GetBlueprint(portClient, bp) 35 | if err == nil { 36 | if shouldDeleteEntities { 37 | _ = blueprint.DeleteBlueprintEntities(portClient, bp) 38 | } 39 | _ = blueprint.DeleteBlueprint(portClient, bp) 40 | } 41 | if shouldExist { 42 | assert.Nil(t, err) 43 | } else { 44 | assert.NotNil(t, err) 45 | } 46 | } 47 | 48 | for _, p := range pages { 49 | _, err := page.GetPage(portClient, p) 50 | if err == nil { 51 | _ = page.DeletePage(portClient, p) 52 | } 53 | if shouldExist { 54 | assert.Nil(t, err) 55 | } else { 56 | assert.NotNil(t, err) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test_utils/immutable.go: -------------------------------------------------------------------------------- 1 | package testing_init 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func GetBlueprintIdFromPrefixAndStateKey(blueprintPrefix string, stateKey string) string { 9 | stateKeySplit := strings.Split(stateKey, "-") 10 | return fmt.Sprintf("%s-%s", blueprintPrefix, stateKeySplit[len(stateKeySplit)-1]) 11 | } 12 | -------------------------------------------------------------------------------- /test_utils/testing_init.go: -------------------------------------------------------------------------------- 1 | package testing_init 2 | 3 | import ( 4 | "github.com/port-labs/port-k8s-exporter/pkg/config" 5 | "os" 6 | "path" 7 | "runtime" 8 | "testing" 9 | ) 10 | 11 | func init() { 12 | _, filename, _, _ := runtime.Caller(0) 13 | dir := path.Join(path.Dir(filename), "..") 14 | err := os.Chdir(dir) 15 | if err != nil { 16 | panic(err) 17 | } 18 | testing.Init() 19 | config.Init() 20 | } 21 | --------------------------------------------------------------------------------