├── .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 | [](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 |
--------------------------------------------------------------------------------