├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── tech_debt.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── codeql.yml │ ├── commitlint.yml │ ├── dependency-review.yml │ ├── e2e.yml │ ├── nightlies.yml │ ├── node.js.yml │ ├── pexex.yaml │ ├── release.yml │ └── scorecard.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .npmignore ├── .prettierignore ├── .prettierrc ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── codeql-config.yaml ├── commitlint.config.cjs ├── e2e ├── cli.e2e.test.ts ├── crds │ ├── policyreports.default.expected │ │ ├── policyreport-v1alpha1.ts │ │ ├── policyreport-v1alpha2.ts │ │ └── policyreport-v1beta1.ts │ ├── policyreports.no.post.expected │ │ ├── policyreport-v1alpha1.ts │ │ ├── policyreport-v1alpha2.ts │ │ └── policyreport-v1beta1.ts │ ├── test.yaml │ │ ├── policyreports.test.yaml │ │ └── uds-podmonitors.test.yaml │ ├── uds-podmonitors.default.expected │ │ └── podmonitor-v1.ts │ └── uds-podmonitors.no.post.expected │ │ └── podmonitor-v1.ts ├── kinds.e2e.test.ts ├── main.e2e.test.ts ├── matrix.mts ├── tsconfig.json └── watch.e2e.test.ts ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── scripts └── nightlies.sh ├── src ├── cli.ts ├── fetch.test.ts ├── fetch.ts ├── fluent │ ├── index.test.ts │ ├── index.ts │ ├── shared-types.ts │ ├── types.ts │ ├── utils.test.ts │ ├── utils.ts │ ├── watch.test.ts │ └── watch.ts ├── generate.test.ts ├── generate.ts ├── helpers.test.ts ├── helpers.ts ├── index.ts ├── kinds.test.ts ├── kinds.ts ├── normalization.test.ts ├── normalization.ts ├── patch.ts ├── postProcessing.test.ts ├── postProcessing.ts ├── types.ts └── upstream.ts ├── test ├── datastore.crd.yaml └── webapp.crd.yaml └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: possible-bug 6 | assignees: "" 7 | --- 8 | 9 | ### Environment 10 | 11 | Device and OS: 12 | App version: 13 | Kubernetes distro being used: 14 | Other: 15 | 16 | ### Steps to reproduce 17 | 18 | 1. 19 | 20 | ### Expected result 21 | 22 | ### Actual Result 23 | 24 | ### Visual Proof (screenshots, videos, text, etc) 25 | 26 | ### Severity/Priority 27 | 28 | ### Additional Context 29 | 30 | Add any other context or screenshots about the technical debt here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "enhancement" 6 | assignees: "" 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe. 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ### Describe the solution you'd like 14 | 15 | - **Given** a state 16 | - **When** an action is taken 17 | - **Then** something happens 18 | 19 | ### Describe alternatives you've considered 20 | 21 | (optional) A clear and concise description of any alternative solutions or features you've considered. 22 | 23 | ### Additional context 24 | 25 | Add any other context or screenshots about the feature request here. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/tech_debt.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tech debt 3 | about: Record something that should be investigated or refactored in the future. 4 | title: "" 5 | labels: "tech-debt" 6 | assignees: "" 7 | --- 8 | 9 | ### Describe what should be investigated or refactored 10 | 11 | A clear and concise description of what should be changed/researched. Ex. This piece of the code is not DRY enough [...] 12 | 13 | ### Links to any relevant code 14 | 15 | (optional) i.e. - https://github.com/defenseunicorns/pepr/blob/24f1f253b1db4c9d97717c0282b8f39c74d3ace1/README.md?plain=1#L3 16 | 17 | ### Additional context 18 | 19 | Add any other context or screenshots about the technical debt here. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: npm # See documentation for possible values 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: daily 10 | groups: 11 | production-dependencies: 12 | dependency-type: production 13 | development-dependencies: 14 | dependency-type: development 15 | - package-ecosystem: github-actions 16 | directory: / 17 | schedule: 18 | interval: daily 19 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | ... 4 | 5 | ## Related Issue 6 | 7 | Fixes # 8 | 9 | 10 | 11 | Relates to # 12 | 13 | ## Type of change 14 | 15 | - [ ] Bug fix (non-breaking change which fixes an issue) 16 | - [ ] New feature (non-breaking change which adds functionality) 17 | - [ ] Other (security config, docs update, etc) 18 | 19 | ## Checklist before merging 20 | 21 | - [ ] Test, docs, adr added or updated as needed 22 | - [ ] [Contributor Guide Steps](https://docs.pepr.dev/main/contribute/#submitting-a-pull-request) followed 23 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: CodeQL 13 | 14 | on: 15 | push: 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | analyze: 22 | name: Analyze 23 | runs-on: ubuntu-latest 24 | permissions: 25 | actions: read 26 | contents: read 27 | security-events: write 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: [javascript, typescript] 33 | # CodeQL supports [ $supported-codeql-languages ] 34 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 43 | with: 44 | languages: ${{ matrix.language }} 45 | config-file: codeql-config.yaml 46 | 47 | - name: Perform CodeQL Analysis 48 | uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 49 | with: 50 | category: "/language:${{matrix.language}}" 51 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | name: PR Title Check 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | types: [opened, edited, synchronize] 7 | merge_group: 8 | 9 | permissions: # added using https://github.com/step-security/secure-repo 10 | contents: read 11 | 12 | jobs: 13 | title_check: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | pull-requests: read 17 | 18 | steps: 19 | - name: Harden Runner 20 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 21 | with: 22 | egress-policy: audit 23 | 24 | - name: Checkout 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 31 | 32 | - name: Install commitlint 33 | run: npm install --save-dev @commitlint/{config-conventional,cli} 34 | 35 | - name: Lint PR title 36 | if: ${{ github.event.pull_request && github.event.pull_request.title }} 37 | env: 38 | PR_TITLE: ${{ github.event.pull_request.title }} 39 | run: echo "$PR_TITLE" | npx commitlint 40 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: Dependency Review 10 | on: 11 | pull_request: 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | dependency-review: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout Repository 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | - name: Dependency Review 23 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 24 | circular-dependencies: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: setup node 28 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 29 | with: 30 | node-version: 20 31 | cache-dependency-path: pepr 32 | - name: "Checkout Repository" 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | - name: Circular Dependency Check 35 | run: | 36 | npx madge --circular --ts-config tsconfig.json --extensions ts,js src/ 37 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: e2e Test 2 | 3 | permissions: 4 | contents: read 5 | on: 6 | # Triggered by node.js.yaml & release.yaml 7 | workflow_call: 8 | 9 | jobs: 10 | e2e: 11 | name: e2e 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Harden Runner 15 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 16 | with: 17 | egress-policy: audit 18 | 19 | - name: clone kfc 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | repository: defenseunicorns/kubernetes-fluent-client 23 | path: kubernetes-fluent-client 24 | 25 | - name: "set env: KFC" 26 | run: echo "KFC=${GITHUB_WORKSPACE}/kubernetes-fluent-client" >> "$GITHUB_ENV" 27 | 28 | - name: setup node 29 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | cache: "npm" 33 | cache-dependency-path: kubernetes-fluent-client 34 | 35 | - name: install kubernetes-fluent-client deps 36 | run: | 37 | cd "$KFC" 38 | npm ci 39 | shell: bash 40 | 41 | - name: "install k3d" 42 | run: "curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash" 43 | shell: bash 44 | 45 | - name: Set up Kubernetes 46 | uses: azure/setup-kubectl@3e0aec4d80787158d308d7b364cb1b702e7feb7f # v4.0.0 47 | with: 48 | version: "latest" 49 | 50 | - name: "create k3d cluster" 51 | run: "k3d cluster create kfc-dev --k3s-arg '--debug@server:0' --wait && kubectl rollout status deployment -n kube-system" 52 | shell: bash 53 | 54 | - name: Prepare CRDs and Generate Classes 55 | run: | 56 | cd "$KFC" 57 | npm run test:e2e:prep-crds 58 | sed -i 's|from "kubernetes-fluent-client"|from "../src"|g' e2e/datastore-v1alpha1.ts 59 | sed -i 's|from "kubernetes-fluent-client"|from "../src"|g' e2e/webapp-v1alpha1.ts 60 | shell: bash 61 | 62 | - name: Prepare Image and Test 63 | run: | 64 | cd "$KFC" 65 | npm run build 66 | npm pack 67 | npm i kubernetes-fluent-client-0.0.0-development.tgz --no-save 68 | npm run test:e2e 69 | shell: bash 70 | -------------------------------------------------------------------------------- /.github/workflows/nightlies.yml: -------------------------------------------------------------------------------- 1 | name: Nightlies 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | contents: read 17 | packages: write 18 | id-token: write 19 | 20 | steps: 21 | - name: Harden Runner 22 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 23 | with: 24 | egress-policy: audit 25 | 26 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | 28 | - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 29 | 30 | - name: Use Node.js 20 31 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 32 | with: 33 | node-version: 20 34 | registry-url: "https://registry.npmjs.org" 35 | cache: "npm" 36 | 37 | - name: Publish to NPM 38 | env: 39 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | run: ./scripts/nightlies.sh 41 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: "Node.js CI" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_call: 9 | merge_group: 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | e2e: 16 | uses: "./.github/workflows/e2e.yml" 17 | format: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 24 | with: 25 | node-version: latest 26 | cache: npm 27 | 28 | - run: npm ci 29 | - run: npm run format:check 30 | - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies 31 | run: npm audit signatures 32 | test: 33 | needs: e2e 34 | runs-on: ubuntu-latest 35 | strategy: 36 | matrix: 37 | node-version: [20, 22, 24] 38 | steps: 39 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 40 | 41 | - name: Use Node.js ${{ matrix.node-version }} 42 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | cache: npm 46 | 47 | - name: Install dependencies 48 | run: npm ci 49 | 50 | - name: Build 51 | run: npm run build 52 | 53 | - name: Test Unit 54 | run: npm test 55 | -------------------------------------------------------------------------------- /.github/workflows/pexex.yaml: -------------------------------------------------------------------------------- 1 | name: E2E - Pepr Excellent Examples 2 | 3 | permissions: read-all 4 | on: 5 | workflow_dispatch: 6 | merge_group: 7 | paths-ignore: 8 | - "LICENSE" 9 | - "CODEOWNERS" 10 | - "**.md" 11 | schedule: 12 | - cron: "0 4 * * *" # 12AM EST/9PM PST 13 | push: 14 | branches: ["main"] 15 | pull_request: 16 | branches: ["main"] 17 | 18 | # refs 19 | # https://frontside.com/blog/2022-12-12-dynamic-github-action-jobs/ 20 | # https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ 21 | 22 | jobs: 23 | kubernetes-fluent-client-build: 24 | name: controller image 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Harden Runner 28 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 29 | with: 30 | egress-policy: audit 31 | 32 | - name: clone kubernetes-fluent-client 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | repository: defenseunicorns/kubernetes-fluent-client 36 | path: kubernetes-fluent-client 37 | 38 | - name: "set env: KUBERNETES_FLUENT_CLIENT" 39 | run: echo "KUBERNETES_FLUENT_CLIENT=${GITHUB_WORKSPACE}/kubernetes-fluent-client" >> "$GITHUB_ENV" 40 | 41 | - name: setup node 42 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 43 | with: 44 | node-version: 20 45 | cache: "npm" 46 | cache-dependency-path: kubernetes-fluent-client 47 | 48 | - name: install kubernetes-fluent-client deps 49 | run: | 50 | cd "$KUBERNETES_FLUENT_CLIENT" 51 | npm ci 52 | 53 | - name: build kubernetes-fluent-client package 54 | if: ${{ (github.event.inputs.kfcBranch || 'none') == 'none' }} 55 | run: | 56 | cd "$KUBERNETES_FLUENT_CLIENT" 57 | npm run build 58 | npm pack 59 | mv kubernetes-fluent-client-0.0.0-development.tgz ${GITHUB_WORKSPACE}/kubernetes-fluent-client-0.0.0-development.tgz 60 | ls -l ${GITHUB_WORKSPACE} 61 | 62 | - name: upload artifacts 63 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 64 | with: 65 | name: kubernetes-fluent-client-package 66 | path: | 67 | kubernetes-fluent-client-0.0.0-development.tgz 68 | if-no-files-found: error 69 | retention-days: 1 70 | 71 | examples-matrix: 72 | name: job matrix 73 | runs-on: ubuntu-latest 74 | needs: 75 | - kubernetes-fluent-client-build 76 | outputs: 77 | matrix: ${{ steps.create-matrix.outputs.matrix }} 78 | steps: 79 | - name: Harden Runner 80 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 81 | with: 82 | egress-policy: audit 83 | 84 | - name: clone kubernetes-fluent-client 85 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 86 | with: 87 | repository: defenseunicorns/kubernetes-fluent-client 88 | path: kubernetes-fluent-client 89 | 90 | - name: "set env: KUBERNETES_FLUENT_CLIENT" 91 | run: echo "KUBERNETES_FLUENT_CLIENT=${GITHUB_WORKSPACE}/kubernetes-fluent-client" >> "$GITHUB_ENV" 92 | 93 | - name: clone pepr-excellent-examples 94 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 95 | with: 96 | repository: defenseunicorns/pepr-excellent-examples 97 | path: pepr-excellent-examples 98 | 99 | - name: "set env: PEPR_EXCELLENT_EXAMPLES_PATH" 100 | run: echo "PEPR_EXCELLENT_EXAMPLES_PATH=${GITHUB_WORKSPACE}/pepr-excellent-examples" >> "$GITHUB_ENV" 101 | 102 | - name: setup node 103 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 104 | with: 105 | node-version: 20 106 | cache: "npm" 107 | cache-dependency-path: kubernetes-fluent-client 108 | 109 | - name: create matrix 110 | run: | 111 | matrix=$( 112 | npx tsx "$KUBERNETES_FLUENT_CLIENT/e2e/matrix.mts" "$PEPR_EXCELLENT_EXAMPLES_PATH" 113 | ) 114 | echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" 115 | id: create-matrix 116 | 117 | excellent-examples: 118 | name: ${{ matrix.name }} 119 | runs-on: ubuntu-latest 120 | needs: 121 | - examples-matrix 122 | if: needs.examples-matrix.outputs.matrix != '' 123 | strategy: 124 | fail-fast: false 125 | max-parallel: 32 # Roughly matches the number of E2E tests and below GitHub concurrency limit 126 | matrix: ${{ fromJSON(needs.examples-matrix.outputs.matrix) }} 127 | steps: 128 | - name: Harden Runner 129 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 130 | with: 131 | egress-policy: audit 132 | 133 | - name: "install k3d" 134 | run: "curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash" 135 | shell: bash 136 | 137 | - name: download artifacts 138 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 139 | with: 140 | name: kubernetes-fluent-client-package 141 | path: ${{ github.workspace }} 142 | 143 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 144 | with: 145 | repository: defenseunicorns/pepr-excellent-examples 146 | path: pepr-excellent-examples 147 | 148 | - name: "set env: PEPR_EXCELLENT_EXAMPLES_PATH" 149 | run: echo "PEPR_EXCELLENT_EXAMPLES_PATH=${GITHUB_WORKSPACE}/pepr-excellent-examples" >> "$GITHUB_ENV" 150 | 151 | - name: setup node 152 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 153 | with: 154 | node-version: 20 155 | cache: "npm" 156 | cache-dependency-path: pepr-excellent-examples 157 | 158 | - name: install pepr-excellent-examples deps 159 | run: | 160 | cd "$PEPR_EXCELLENT_EXAMPLES_PATH" 161 | npm ci 162 | 163 | - name: run e2e tests 164 | uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 165 | with: 166 | max_attempts: 3 167 | retry_on: error 168 | timeout_minutes: 8 169 | command: | 170 | cd "$PEPR_EXCELLENT_EXAMPLES_PATH" 171 | npm run --workspace=${{ matrix.name }} test:e2e -- \ 172 | --kfc ../kubernetes-fluent-client-0.0.0-development.tgz 173 | 174 | - name: upload artifacts (troubleshooting) 175 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 176 | if: always() 177 | with: 178 | name: "troubleshooting_logs_${{matrix.name}}" 179 | path: | 180 | pepr-excellent-examples/package.json 181 | pepr-excellent-examples/package-lock.json 182 | if-no-files-found: error 183 | retention-days: 1 184 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: read # for checkout 9 | 10 | jobs: 11 | test: 12 | # Runs e2e tests and unit tests 13 | uses: "./.github/workflows/node.js.yml" 14 | 15 | release: 16 | name: Release 17 | runs-on: ubuntu-latest 18 | needs: test 19 | permissions: 20 | contents: write # to be able to publish a GitHub release 21 | issues: write # to be able to comment on released issues 22 | pull-requests: write # to be able to comment on released pull requests 23 | id-token: write # to enable use of OIDC for npm provenance 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | with: 28 | fetch-depth: 0 29 | 30 | - name: Setup Node.js 31 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 32 | with: 33 | node-version: "lts/*" 34 | 35 | - name: Install dependencies 36 | run: npm ci 37 | 38 | - name: Build 39 | run: npm run build 40 | 41 | - name: Release 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | run: npx semantic-release 46 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | name: OpenSSF Scorecard 2 | on: 3 | # For Branch-Protection check. Only the default branch is supported. See 4 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 5 | branch_protection_rule: 6 | # To guarantee Maintained check is occasionally updated. See 7 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 8 | schedule: 9 | - cron: "40 5 * * 5" 10 | push: 11 | branches: ["main"] 12 | 13 | # Declare default permissions as read only. 14 | permissions: read-all 15 | 16 | jobs: 17 | scorecard: 18 | name: Scorecard analysis 19 | runs-on: ubuntu-latest 20 | permissions: 21 | # Needed to upload the results to code-scanning dashboard. 22 | security-events: write 23 | # Needed to publish results and get a badge (see publish_results below). 24 | id-token: write 25 | 26 | steps: 27 | - name: "Checkout code" 28 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | with: 30 | persist-credentials: false 31 | 32 | - name: "Run analysis" 33 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 34 | with: 35 | results_file: results.sarif 36 | results_format: sarif 37 | publish_results: true 38 | 39 | # Format to the repository Actions tab. 40 | - name: "Upload artifact" 41 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 42 | with: 43 | name: SARIF file 44 | path: results.sarif 45 | retention-days: 5 46 | 47 | # Upload the results to GitHub's code scanning dashboard. 48 | - name: "Upload to code-scanning" 49 | uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 50 | with: 51 | sarif_file: results.sarif 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Generated CRD files 133 | generated/ 134 | 135 | # Ignore all v1alpha1/v1alpha2/v1beta1 TypeScript files in e2e directory 136 | e2e/**/*v1alpha*.ts 137 | e2e/**/*v1beta*.ts 138 | 139 | # Ignore specific podmonitor files 140 | e2e/crds/uds-podmonitors/podmonitor-v*.ts 141 | 142 | # Ignore all JSON schema files in e2e 143 | e2e/**/*.json-schema 144 | e2e/*v1alpha1.ts 145 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged --verbose 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.yml": ["prettier --write"], 3 | "*.yaml": ["prettier --write"], 4 | "*.ts": ["prettier --write", "eslint --fix"], 5 | "*.js": ["prettier --write", "eslint --fix"], 6 | "*.json": ["prettier --write"], 7 | "exclude": ["e2e/**"] 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | coverage 3 | .eslintrc.json 4 | .prettierrc 5 | jest.config.json 6 | tsconfig.json 7 | __mocks__ 8 | *.config.js 9 | .prettierrc 10 | e2e 11 | *.tgz 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | e2e/crds 2 | e2e/crds/**/*.ts 3 | e2e/generated 4 | generated/**/*.ts 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "insertPragma": false, 7 | "printWidth": 100, 8 | "quoteProps": "as-needed", 9 | "requirePragma": false, 10 | "semi": true, 11 | "useTabs": false, 12 | "tabWidth": 2 13 | } 14 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @defenseunicorns/pepr 2 | 3 | # Additional privileged files 4 | /CODEOWNERS @jeff-mccoy @daveworth 5 | /cosign.pub @jeff-mccoy @daveworth 6 | /LICENSE @jeff-mccoy @austenbryan 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | pepr-dev-private@googlegroups.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Guide 2 | 3 | Thank you for your interest in contributing to Kubernetes Fluent Client! We welcome all contributions and are grateful for your help. This guide outlines how to get started with contributing to this project. 4 | 5 | ## Table of Contents 6 | 7 | - [Contributor Guide](#contributor-guide) 8 | - [Table of Contents](#table-of-contents) 9 | - [Code of Conduct](#code-of-conduct) 10 | - [Getting Started](#getting-started) 11 | - [Setup](#setup) 12 | - [Submitting a Pull Request](#submitting-a-pull-request) 13 | - [PR Requirements](#pr-requirements) 14 | - [Coding Guidelines](#coding-guidelines) 15 | - [Running Tests](#running-tests) 16 | - [Run Tests Locally](#run-tests-locally) 17 | - [Contact](#contact) 18 | 19 | ## Code of Conduct 20 | 21 | Please follow our [Code of Conduct](./CODE_OF_CONDUCT.md) to maintain a respectful and collaborative environment. 22 | 23 | ## Getting Started 24 | 25 | - **Repository**: [https://github.com/defenseunicorns/kubernetes-fluent-client/](https://github.com/defenseunicorns/kubernetes-fluent-client/) 26 | - **npm package**: [https://www.npmjs.com/package/kubernetes-fluent-client](https://www.npmjs.com/package/kubernetes-fluent-client) 27 | - **Required Node version**: `>=20.0.0` 28 | 29 | ### Setup 30 | 31 | 1. Fork the repository. 32 | 2. Clone your fork locally: `git clone https://github.com/your-username/kubernetes-fluent-client.git`. 33 | 3. Install dependencies: `npm ci`. 34 | 4. Create a new branch for your feature or fix: `git checkout -b my-feature-branch`. 35 | 36 | ## Submitting a Pull Request 37 | 38 | 1. **Create an Issue**: For significant changes, please create an issue first, describing the problem or feature proposal. Trivial fixes, such as typo corrections, do not require an issue. 39 | 2. **Commit Your Changes**: Make your changes and commit them. All commits must be signed. 40 | 3. **Run Tests**: Ensure that your changes pass all tests by running unit tests (`npm test`) and integration tests (`test:e2e:run`). 41 | 4. **Push Your Branch**: Push your branch to your fork on GitHub. 42 | 5. **Create a Pull Request**: Open a pull request against the `main` branch of the Kubernetes Fluent Client repository. Please make sure that your PR passes all CI checks. 43 | 44 | ### PR Requirements 45 | 46 | - PRs must be against the `main` branch. 47 | - PRs must pass CI checks. 48 | - Ensure all commits in your PR are signed. 49 | - PRs should have a related issue, except for trivial fixes. 50 | 51 | ## Coding Guidelines 52 | 53 | Please follow the coding conventions and style used in the project. Use ESLint and Prettier for linting and formatting: 54 | 55 | - Check formatting: `npm run format:check` 56 | - Fix formatting: `npm run format:fix` 57 | 58 | ## Running Tests 59 | 60 | ### Run Tests Locally 61 | 62 | - Unit: `npm test` 63 | - End to end: `npm run test:e2e:run` 64 | 65 | ### Running Development Version Locally 66 | 67 | 1. Run `npm run build` to build the package. 68 | 2. For CLI, you can run `npx ts-node src/cli.ts`. 69 | 3. To consume the package in another project, you can run `npm pack` to generate the `kubernetes-fluent-client-0.0.0-development.tgz`, then you can install with `npm i kubernetes-fluent-client-0.0.0-development.tgz --no-save`. 70 | 71 | > [!TIP] 72 | > Make sure to re-run `npm run build` after you modify any of the Kubernetes Fluent Client source files. 73 | 74 | ## Contact 75 | 76 | For any questions or concerns, please open an issue on GitHub or contact the maintainers. 77 | -------------------------------------------------------------------------------- /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 | # Kubernetes Fluent Client for Node 2 | 3 | [![Npm package license](https://badgen.net/npm/license/kubernetes-fluent-client)](https://npmjs.com/package/kubernetes-fluent-client) 4 | [![Known Vulnerabilities](https://snyk.io/test/npm/kubernetes-fluent-client/badge.svg)](https://snyk.io/advisor/npm-package/kubernetes-fluent-client) 5 | [![Npm package version](https://badgen.net/npm/v/kubernetes-fluent-client)](https://npmjs.com/package/kubernetes-fluent-client) 6 | [![Npm package total downloads](https://badgen.net/npm/dt/kubernetes-fluent-client)](https://npmjs.com/package/kubernetes-fluent-client) 7 | 8 | The Kubernetes Fluent Client for Node is a fluent API for the [Kubernetes JavaScript Client](https://github.com/kubernetes-client/javascript) with some additional logic for [Server Side Apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/), [Watch](https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes) with retry/signal control, and [Field Selectors](https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/). In addition to providing a human-friendly API, it also provides a simple way to create and manage resources in the cluster and integrate with K8s in a type-safe way. 9 | 10 | To install the Kubernetes Fluent Client, run the following command: 11 | 12 | ```bash 13 | npm install kubernetes-fluent-client 14 | ``` 15 | 16 | See below for some example uses of the library. 17 | 18 | ```typescript 19 | import { K8s, kind } from "kubernetes-fluent-client"; 20 | 21 | // Let's create a random namespace to work in 22 | const namespace = "my-namespace" + Math.floor(Math.random() * 1000); 23 | 24 | // This will be called after the resources are created in the cluster 25 | async function demo() { 26 | // Now, we can use the fluent API to query for the resources we just created 27 | 28 | // You can use watch to monitor resources in the cluster and react to changes 29 | const watcher = K8s(kind.Pod).Watch((pod, phase) => { 30 | console.log(`Pod ${pod.metadata?.name} is ${phase}`); 31 | }); 32 | 33 | // This will run until the process is terminated or the watch is aborted 34 | await watcher.start(); 35 | 36 | // Let's abort the watch after 5 seconds 37 | setTimeout(watcher.close, 5 * 1000); 38 | 39 | // Passing the name to Get() will return a single resource 40 | const ns = await K8s(kind.Namespace).Get(namespace); 41 | console.log(ns); 42 | 43 | // This time we'll use the InNamespace() method to filter the results by namespace and name 44 | const cm = await K8s(kind.ConfigMap).InNamespace(namespace).Get("my-configmap"); 45 | console.log(cm); 46 | 47 | // If we don't pass a name to Get(), we'll get a list of resources as KubernetesListObject 48 | // The matching resources will be in the items property 49 | const pods = await K8s(kind.Pod).InNamespace(namespace).Get(); 50 | console.log(pods); 51 | 52 | // Now let's delete the resources we created, you can pass the name to Delete() or the resource itself 53 | await K8s(kind.Namespace).Delete(namespace); 54 | 55 | // Let's use the field selector to find all the running pods in the cluster 56 | const runningPods = await K8s(kind.Pod).WithField("status.phase", "Running").Get(); 57 | runningPods.items.forEach(pod => { 58 | console.log(`${pod.metadata?.namespace}/${pod.metadata?.name} is running`); 59 | }); 60 | 61 | // Get logs from a Deployment named "nginx" in the namespace 62 | const logs = await K8s(kind.Deployment).InNamespace(namespace).Logs("nginx"); 63 | console.log(logs); 64 | } 65 | 66 | // Create a few resources to work with: Namespace, ConfigMap, and Pod 67 | Promise.all([ 68 | // Create the namespace 69 | K8s(kind.Namespace).Apply({ 70 | metadata: { 71 | name: namespace, 72 | }, 73 | }), 74 | 75 | // Create the ConfigMap in the namespace 76 | K8s(kind.ConfigMap).Apply({ 77 | metadata: { 78 | name: "my-configmap", 79 | namespace, 80 | }, 81 | data: { 82 | "my-key": "my-value", 83 | }, 84 | }), 85 | 86 | // Create the Pod in the namespace 87 | K8s(kind.Pod).Apply({ 88 | metadata: { 89 | name: "my-pod", 90 | namespace, 91 | }, 92 | spec: { 93 | containers: [ 94 | { 95 | name: "my-container", 96 | image: "nginx", 97 | }, 98 | ], 99 | }, 100 | }), 101 | ]) 102 | .then(demo) 103 | .catch(err => { 104 | console.error(err); 105 | }); 106 | ``` 107 | 108 | ### Generating TypeScript Definitions from CRDs 109 | 110 | The Kubernetes Fluent Client can generate TypeScript definitions from Custom Resource Definitions (CRDs) using the `generate` command. This command will generate TypeScript interfaces for the CRDs in the cluster and save them to a file. 111 | 112 | To generate TypeScript definitions from CRDs, run the following command: 113 | 114 | ```bash 115 | kubernetes-fluent-client crd /path/to/input.yaml /path/to/output/folder 116 | ``` 117 | 118 | If you have a CRD in a file named `crd.yaml` and you want to generate TypeScript definitions in a folder named `types`, you can run the following command: 119 | 120 | ```bash 121 | kubernetes-fluent-client crd crd.yaml types 122 | ``` 123 | 124 | This will generate TypeScript interfaces for the CRD in the `crd.yaml` file and save them to the `types` folder. 125 | 126 | By default, the generated TypeScript interfaces will be post-processed to make them more user-friendly. If you want to disable this post-processing, you can use the `--noPost` flag: 127 | 128 | ```bash 129 | kubernetes-fluent-client crd crd.yaml types --noPost 130 | ``` 131 | 132 | ### Community 🦄 133 | 134 | To chat with other users & see some examples of the fluent client in active use, go to [Kubernetes Slack](https://communityinviter.com/apps/kubernetes/community) and join `#pepr` channel. 135 | -------------------------------------------------------------------------------- /codeql-config.yaml: -------------------------------------------------------------------------------- 1 | name: "Custom CodeQL Config" 2 | exclude: 3 | - "**/e2e/**" 4 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | ignores: [ 4 | // prevent header-max-length error on long, dependabot-gen'd commits titles 5 | // https://github.com/dependabot/dependabot-core/issues/2445 6 | message => /^chore: bump .+ from .+ to .+$/m.test(message), 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /e2e/cli.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { execFile } from "child_process"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import { describe, beforeEach, it, expect, afterEach } from "vitest"; 5 | 6 | // Utility function to execute the CLI command 7 | const runCliCommand = ( 8 | args: string[], 9 | callback: (error: Error | null, stdout: string, stderr: string) => void, 10 | ) => { 11 | execFile("node", ["./dist/cli.js", ...args], callback); // Path to built CLI JS file 12 | }; 13 | 14 | // Utility function to compare generated files to expected files 15 | const compareGeneratedToExpected = (generatedFile: string, expectedFile: string) => { 16 | // Check if the expected file exists 17 | expect(fs.existsSync(expectedFile)).toBe(true); 18 | 19 | // Read and compare the content of the generated file to the expected file 20 | const generatedContent = fs.readFileSync(generatedFile, "utf8").trim(); 21 | const expectedContent = fs.readFileSync(expectedFile, "utf8").trim(); 22 | 23 | expect(generatedContent).toBe(expectedContent); 24 | }; 25 | it("should generate a json schema for package crd", async () => { 26 | const jsonSchema = fs.readFileSync( 27 | path.join(__dirname, "schemas/webapp/webapp-v1alpha1.json-schema"), 28 | "utf8", 29 | ); 30 | expect(jsonSchema).toContain('"$schema": "http://json-schema.org/draft-06/schema#"'); 31 | }); 32 | describe("End-to-End CLI tests with multiple test files", () => { 33 | const testFolder = path.join(__dirname, "crds/test.yaml"); // Directory containing .test.yaml files 34 | 35 | // Get all .test.yaml files in the test folder 36 | const testFiles = fs.readdirSync(testFolder).filter(file => file.endsWith(".test.yaml")); 37 | 38 | testFiles.forEach(testFile => { 39 | const name = path.basename(testFile, ".test.yaml"); // Extract name from the filename 40 | const mockYamlPath = path.join(testFolder, testFile); // Full path to the test YAML file 41 | const mockDir = path.join(__dirname, "crds/", name); // Output directory based on name 42 | const expectedDir = path.join(__dirname, `crds/${name}.default.expected`); // Expected default directory 43 | const expectedPostDir = path.join(__dirname, `crds/${name}.no.post.expected`); // Expected post-processing directory 44 | 45 | const testInfoMessage = ` 46 | Running tests for ${name} 47 | Test file: ${mockYamlPath} 48 | Output directory: ${mockDir} 49 | Expected directory: ${expectedDir} 50 | Expected post-processing directory: ${expectedPostDir} 51 | `; 52 | 53 | console.log(testInfoMessage); 54 | 55 | beforeEach(() => { 56 | // Ensure the output directory is clean 57 | if (fs.existsSync(mockDir)) { 58 | fs.rmSync(mockDir, { recursive: true }); 59 | } 60 | 61 | // Recreate the output directory 62 | fs.mkdirSync(mockDir); 63 | }); 64 | 65 | afterEach(() => { 66 | // Cleanup the output directory after each test 67 | if (fs.existsSync(mockDir)) { 68 | fs.rmSync(mockDir, { recursive: true }); 69 | } 70 | }); 71 | 72 | it(`should generate TypeScript types and run post-processing for ${name}`, async () => { 73 | // Run the CLI command with the appropriate arguments 74 | await runCliCommand(["crd", mockYamlPath, mockDir], async (error, stdout) => { 75 | expect(error).toBeNull(); // Ensure no errors occurred 76 | 77 | // Get the list of generated files 78 | const generatedFiles = fs.readdirSync(mockDir); 79 | 80 | // Compare each generated file to the corresponding expected file in expectedDir 81 | generatedFiles.forEach(file => { 82 | const generatedFilePath = path.join(mockDir, file); 83 | const expectedFilePath = path.join(expectedDir, file); 84 | 85 | compareGeneratedToExpected(generatedFilePath, expectedFilePath); 86 | }); 87 | 88 | // Verify stdout output 89 | expect(stdout).toContain("✅ Generated"); 90 | }); 91 | }); 92 | 93 | it(`should skip post-processing for ${name} when using --noPost`, async () => { 94 | // Run the CLI command without the --noPost flag 95 | await runCliCommand(["crd", mockYamlPath, mockDir, "--noPost"], async (error, stdout) => { 96 | expect(error).toBeNull(); // Ensure no errors occurred 97 | 98 | // Ensure post-processing was not run (stdout should reflect this) 99 | expect(stdout).not.toContain("🔧 Post-processing started"); 100 | }); 101 | }); 102 | 103 | it(`should skip post-processing for ${name} when using --noPost`, async () => { 104 | // Run the CLI command without post-processing 105 | await runCliCommand(["crd", mockYamlPath, mockDir, "--noPost"], async (error, stdout) => { 106 | expect(error).toBeNull(); // Ensure no errors occurred 107 | 108 | // Get the list of generated files 109 | const generatedFiles = fs.readdirSync(mockDir); 110 | 111 | // Compare each generated file to the corresponding expected file in expectedPostDir 112 | generatedFiles.forEach(file => { 113 | const generatedFilePath = path.join(mockDir, file); 114 | const expectedFilePath = path.join(expectedPostDir, file); 115 | 116 | compareGeneratedToExpected(generatedFilePath, expectedFilePath); 117 | }); 118 | 119 | // Ensure post-processing was not run (stdout should reflect this) 120 | expect(stdout).not.toContain("🔧 Post-processing started"); 121 | }); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /e2e/crds/policyreports.default.expected/policyreport-v1alpha1.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by kubernetes-fluent-client, do not edit manually 2 | import { GenericKind, RegisterKind } from "kubernetes-fluent-client"; 3 | /** 4 | * PolicyReport is the Schema for the policyreports API 5 | */ 6 | export class PolicyReport extends GenericKind { 7 | /** 8 | * APIVersion defines the versioned schema of this representation of an object. Servers 9 | * should convert recognized schemas to the latest internal value, and may reject 10 | * unrecognized values. More info: 11 | * https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 12 | */ 13 | declare apiVersion?: string; 14 | /** 15 | * Kind is a string value representing the REST resource this object represents. Servers may 16 | * infer this from the endpoint the client submits requests to. Cannot be updated. In 17 | * CamelCase. More info: 18 | * https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 19 | */ 20 | declare kind?: string; 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | declare metadata?: { [key: string]: any }; 23 | /** 24 | * PolicyReportResult provides result details 25 | */ 26 | results?: Result[]; 27 | /** 28 | * Scope is an optional reference to the report scope (e.g. a Deployment, Namespace, or Node) 29 | */ 30 | scope?: Scope; 31 | /** 32 | * ScopeSelector is an optional selector for multiple scopes (e.g. Pods). Either one of, or 33 | * none of, but not both of, Scope or ScopeSelector should be specified. 34 | */ 35 | scopeSelector?: ScopeSelector; 36 | /** 37 | * PolicyReportSummary provides a summary of results 38 | */ 39 | summary?: Summary; 40 | } 41 | 42 | /** 43 | * PolicyReportResult provides the result for an individual policy 44 | */ 45 | export interface Result { 46 | /** 47 | * Category indicates policy category 48 | */ 49 | category?: string; 50 | /** 51 | * Data provides additional information for the policy rule 52 | */ 53 | data?: { [key: string]: string }; 54 | /** 55 | * Message is a short user friendly description of the policy rule 56 | */ 57 | message?: string; 58 | /** 59 | * Policy is the name of the policy 60 | */ 61 | policy: string; 62 | /** 63 | * Resources is an optional reference to the resource checked by the policy and rule 64 | */ 65 | resources?: Resource[]; 66 | /** 67 | * ResourceSelector is an optional selector for policy results that apply to multiple 68 | * resources. For example, a policy result may apply to all pods that match a label. Either 69 | * a Resource or a ResourceSelector can be specified. If neither are provided, the result is 70 | * assumed to be for the policy report scope. 71 | */ 72 | resourceSelector?: ResourceSelector; 73 | /** 74 | * Rule is the name of the policy rule 75 | */ 76 | rule?: string; 77 | /** 78 | * Scored indicates if this policy rule is scored 79 | */ 80 | scored?: boolean; 81 | /** 82 | * Severity indicates policy severity 83 | */ 84 | severity?: Severity; 85 | /** 86 | * Status indicates the result of the policy rule check 87 | */ 88 | status?: Status; 89 | } 90 | 91 | /** 92 | * ResourceSelector is an optional selector for policy results that apply to multiple 93 | * resources. For example, a policy result may apply to all pods that match a label. Either 94 | * a Resource or a ResourceSelector can be specified. If neither are provided, the result is 95 | * assumed to be for the policy report scope. 96 | */ 97 | export interface ResourceSelector { 98 | /** 99 | * matchExpressions is a list of label selector requirements. The requirements are ANDed. 100 | */ 101 | matchExpressions?: ResourceSelectorMatchExpression[]; 102 | /** 103 | * matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is 104 | * equivalent to an element of matchExpressions, whose key field is "key", the operator is 105 | * "In", and the values array contains only "value". The requirements are ANDed. 106 | */ 107 | matchLabels?: { [key: string]: string }; 108 | } 109 | 110 | /** 111 | * A label selector requirement is a selector that contains values, a key, and an operator 112 | * that relates the key and values. 113 | */ 114 | export interface ResourceSelectorMatchExpression { 115 | /** 116 | * key is the label key that the selector applies to. 117 | */ 118 | key: string; 119 | /** 120 | * operator represents a key's relationship to a set of values. Valid operators are In, 121 | * NotIn, Exists and DoesNotExist. 122 | */ 123 | operator: string; 124 | /** 125 | * values is an array of string values. If the operator is In or NotIn, the values array 126 | * must be non-empty. If the operator is Exists or DoesNotExist, the values array must be 127 | * empty. This array is replaced during a strategic merge patch. 128 | */ 129 | values?: string[]; 130 | } 131 | 132 | /** 133 | * ObjectReference contains enough information to let you inspect or modify the referred 134 | * object. --- New uses of this type are discouraged because of difficulty describing its 135 | * usage when embedded in APIs. 1. Ignored fields. It includes many fields which are not 136 | * generally honored. For instance, ResourceVersion and FieldPath are both very rarely 137 | * valid in actual usage. 2. Invalid usage help. It is impossible to add specific help for 138 | * individual usage. In most embedded usages, there are particular restrictions like, "must 139 | * refer only to types A and B" or "UID not honored" or "name must be restricted". Those 140 | * cannot be well described when embedded. 3. Inconsistent validation. Because the usages 141 | * are different, the validation rules are different by usage, which makes it hard for users 142 | * to predict what will happen. 4. The fields are both imprecise and overly precise. Kind 143 | * is not a precise mapping to a URL. This can produce ambiguity during interpretation and 144 | * require a REST mapping. In most cases, the dependency is on the group,resource tuple and 145 | * the version of the actual struct is irrelevant. 5. We cannot easily change it. Because 146 | * this type is embedded in many locations, updates to this type will affect numerous 147 | * schemas. Don't make new APIs embed an underspecified API type they do not control. 148 | * Instead of using this type, create a locally provided and used type that is well-focused 149 | * on your reference. For example, ServiceReferences for admission registration: 150 | * https://github.com/kubernetes/api/blob/release-1.17/admissionregistration/v1/types.go#L533 151 | * . 152 | */ 153 | export interface Resource { 154 | /** 155 | * API version of the referent. 156 | */ 157 | apiVersion?: string; 158 | /** 159 | * If referring to a piece of an object instead of an entire object, this string should 160 | * contain a valid JSON/Go field access statement, such as 161 | * desiredState.manifest.containers[2]. For example, if the object reference is to a 162 | * container within a pod, this would take on a value like: "spec.containers{name}" (where 163 | * "name" refers to the name of the container that triggered the event) or if no container 164 | * name is specified "spec.containers[2]" (container with index 2 in this pod). This syntax 165 | * is chosen only to have some well-defined way of referencing a part of an object. TODO: 166 | * this design is not final and this field is subject to change in the future. 167 | */ 168 | fieldPath?: string; 169 | /** 170 | * Kind of the referent. More info: 171 | * https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 172 | */ 173 | kind?: string; 174 | /** 175 | * Name of the referent. More info: 176 | * https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 177 | */ 178 | name?: string; 179 | /** 180 | * Namespace of the referent. More info: 181 | * https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ 182 | */ 183 | namespace?: string; 184 | /** 185 | * Specific resourceVersion to which this reference is made, if any. More info: 186 | * https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency 187 | */ 188 | resourceVersion?: string; 189 | /** 190 | * UID of the referent. More info: 191 | * https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids 192 | */ 193 | uid?: string; 194 | } 195 | 196 | /** 197 | * Severity indicates policy severity 198 | */ 199 | export enum Severity { 200 | High = "high", 201 | Low = "low", 202 | Medium = "medium", 203 | } 204 | 205 | /** 206 | * Status indicates the result of the policy rule check 207 | */ 208 | export enum Status { 209 | Error = "error", 210 | Fail = "fail", 211 | Pass = "pass", 212 | Skip = "skip", 213 | Warn = "warn", 214 | } 215 | 216 | /** 217 | * Scope is an optional reference to the report scope (e.g. a Deployment, Namespace, or Node) 218 | */ 219 | export interface Scope { 220 | /** 221 | * API version of the referent. 222 | */ 223 | apiVersion?: string; 224 | /** 225 | * If referring to a piece of an object instead of an entire object, this string should 226 | * contain a valid JSON/Go field access statement, such as 227 | * desiredState.manifest.containers[2]. For example, if the object reference is to a 228 | * container within a pod, this would take on a value like: "spec.containers{name}" (where 229 | * "name" refers to the name of the container that triggered the event) or if no container 230 | * name is specified "spec.containers[2]" (container with index 2 in this pod). This syntax 231 | * is chosen only to have some well-defined way of referencing a part of an object. TODO: 232 | * this design is not final and this field is subject to change in the future. 233 | */ 234 | fieldPath?: string; 235 | /** 236 | * Kind of the referent. More info: 237 | * https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 238 | */ 239 | kind?: string; 240 | /** 241 | * Name of the referent. More info: 242 | * https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 243 | */ 244 | name?: string; 245 | /** 246 | * Namespace of the referent. More info: 247 | * https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ 248 | */ 249 | namespace?: string; 250 | /** 251 | * Specific resourceVersion to which this reference is made, if any. More info: 252 | * https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency 253 | */ 254 | resourceVersion?: string; 255 | /** 256 | * UID of the referent. More info: 257 | * https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids 258 | */ 259 | uid?: string; 260 | } 261 | 262 | /** 263 | * ScopeSelector is an optional selector for multiple scopes (e.g. Pods). Either one of, or 264 | * none of, but not both of, Scope or ScopeSelector should be specified. 265 | */ 266 | export interface ScopeSelector { 267 | /** 268 | * matchExpressions is a list of label selector requirements. The requirements are ANDed. 269 | */ 270 | matchExpressions?: ScopeSelectorMatchExpression[]; 271 | /** 272 | * matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is 273 | * equivalent to an element of matchExpressions, whose key field is "key", the operator is 274 | * "In", and the values array contains only "value". The requirements are ANDed. 275 | */ 276 | matchLabels?: { [key: string]: string }; 277 | } 278 | 279 | /** 280 | * A label selector requirement is a selector that contains values, a key, and an operator 281 | * that relates the key and values. 282 | */ 283 | export interface ScopeSelectorMatchExpression { 284 | /** 285 | * key is the label key that the selector applies to. 286 | */ 287 | key: string; 288 | /** 289 | * operator represents a key's relationship to a set of values. Valid operators are In, 290 | * NotIn, Exists and DoesNotExist. 291 | */ 292 | operator: string; 293 | /** 294 | * values is an array of string values. If the operator is In or NotIn, the values array 295 | * must be non-empty. If the operator is Exists or DoesNotExist, the values array must be 296 | * empty. This array is replaced during a strategic merge patch. 297 | */ 298 | values?: string[]; 299 | } 300 | 301 | /** 302 | * PolicyReportSummary provides a summary of results 303 | */ 304 | export interface Summary { 305 | /** 306 | * Error provides the count of policies that could not be evaluated 307 | */ 308 | error?: number; 309 | /** 310 | * Fail provides the count of policies whose requirements were not met 311 | */ 312 | fail?: number; 313 | /** 314 | * Pass provides the count of policies whose requirements were met 315 | */ 316 | pass?: number; 317 | /** 318 | * Skip indicates the count of policies that were not selected for evaluation 319 | */ 320 | skip?: number; 321 | /** 322 | * Warn provides the count of unscored policies whose requirements were not met 323 | */ 324 | warn?: number; 325 | } 326 | 327 | RegisterKind(PolicyReport, { 328 | group: "wgpolicyk8s.io", 329 | version: "v1alpha1", 330 | kind: "PolicyReport", 331 | plural: "policyreports", 332 | }); -------------------------------------------------------------------------------- /e2e/kinds.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { kind } from "../src"; 2 | import { modelToGroupVersionKind } from "../src/index.js"; 3 | import { RegisterKind } from "../src/kinds.js"; 4 | import { expect, it } from "vitest"; 5 | 6 | const testCases = [ 7 | { 8 | name: kind.Event, 9 | expected: { group: "events.k8s.io", version: "v1", kind: "Event" }, 10 | }, 11 | { 12 | name: kind.CoreEvent, 13 | expected: { group: "", version: "v1", kind: "Event" }, 14 | }, 15 | { 16 | name: kind.ClusterRole, 17 | expected: { group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole" }, 18 | }, 19 | { 20 | name: kind.ClusterRoleBinding, 21 | expected: { group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding" }, 22 | }, 23 | { 24 | name: kind.Role, 25 | expected: { group: "rbac.authorization.k8s.io", version: "v1", kind: "Role" }, 26 | }, 27 | { 28 | name: kind.RoleBinding, 29 | expected: { group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding" }, 30 | }, 31 | { name: kind.Pod, expected: { group: "", version: "v1", kind: "Pod" } }, 32 | { name: kind.Deployment, expected: { group: "apps", version: "v1", kind: "Deployment" } }, 33 | { name: kind.StatefulSet, expected: { group: "apps", version: "v1", kind: "StatefulSet" } }, 34 | { name: kind.DaemonSet, expected: { group: "apps", version: "v1", kind: "DaemonSet" } }, 35 | { name: kind.Job, expected: { group: "batch", version: "v1", kind: "Job" } }, 36 | { name: kind.CronJob, expected: { group: "batch", version: "v1", kind: "CronJob" } }, 37 | { name: kind.ConfigMap, expected: { group: "", version: "v1", kind: "ConfigMap" } }, 38 | { name: kind.Secret, expected: { group: "", version: "v1", kind: "Secret" } }, 39 | { name: kind.Service, expected: { group: "", version: "v1", kind: "Service" } }, 40 | { name: kind.ServiceAccount, expected: { group: "", version: "v1", kind: "ServiceAccount" } }, 41 | { name: kind.Namespace, expected: { group: "", version: "v1", kind: "Namespace" } }, 42 | { 43 | name: kind.HorizontalPodAutoscaler, 44 | expected: { group: "autoscaling", version: "v2", kind: "HorizontalPodAutoscaler" }, 45 | }, 46 | { 47 | name: kind.CustomResourceDefinition, 48 | expected: { group: "apiextensions.k8s.io", version: "v1", kind: "CustomResourceDefinition" }, 49 | }, 50 | { name: kind.Ingress, expected: { group: "networking.k8s.io", version: "v1", kind: "Ingress" } }, 51 | { 52 | name: kind.NetworkPolicy, 53 | expected: { 54 | group: "networking.k8s.io", 55 | version: "v1", 56 | kind: "NetworkPolicy", 57 | plural: "networkpolicies", 58 | }, 59 | }, 60 | { name: kind.Node, expected: { group: "", version: "v1", kind: "Node" } }, 61 | { name: kind.PersistentVolume, expected: { group: "", version: "v1", kind: "PersistentVolume" } }, 62 | { 63 | name: kind.PersistentVolumeClaim, 64 | expected: { group: "", version: "v1", kind: "PersistentVolumeClaim" }, 65 | }, 66 | { name: kind.Pod, expected: { group: "", version: "v1", kind: "Pod" } }, 67 | { 68 | name: kind.PodDisruptionBudget, 69 | expected: { group: "policy", version: "v1", kind: "PodDisruptionBudget" }, 70 | }, 71 | { name: kind.PodTemplate, expected: { group: "", version: "v1", kind: "PodTemplate" } }, 72 | { name: kind.ReplicaSet, expected: { group: "apps", version: "v1", kind: "ReplicaSet" } }, 73 | { 74 | name: kind.ReplicationController, 75 | expected: { group: "", version: "v1", kind: "ReplicationController" }, 76 | }, 77 | { name: kind.ResourceQuota, expected: { group: "", version: "v1", kind: "ResourceQuota" } }, 78 | { 79 | name: kind.RuntimeClass, 80 | expected: { group: "node.k8s.io", version: "v1", kind: "RuntimeClass" }, 81 | }, 82 | { name: kind.Secret, expected: { group: "", version: "v1", kind: "Secret" } }, 83 | { 84 | name: kind.SelfSubjectAccessReview, 85 | expected: { group: "authorization.k8s.io", version: "v1", kind: "SelfSubjectAccessReview" }, 86 | }, 87 | { 88 | name: kind.SelfSubjectRulesReview, 89 | expected: { group: "authorization.k8s.io", version: "v1", kind: "SelfSubjectRulesReview" }, 90 | }, 91 | { name: kind.Service, expected: { group: "", version: "v1", kind: "Service" } }, 92 | { name: kind.ServiceAccount, expected: { group: "", version: "v1", kind: "ServiceAccount" } }, 93 | { name: kind.StatefulSet, expected: { group: "apps", version: "v1", kind: "StatefulSet" } }, 94 | { 95 | name: kind.StorageClass, 96 | expected: { group: "storage.k8s.io", version: "v1", kind: "StorageClass" }, 97 | }, 98 | { 99 | name: kind.SubjectAccessReview, 100 | expected: { group: "authorization.k8s.io", version: "v1", kind: "SubjectAccessReview" }, 101 | }, 102 | { 103 | name: kind.TokenReview, 104 | expected: { group: "authentication.k8s.io", version: "v1", kind: "TokenReview" }, 105 | }, 106 | { 107 | name: kind.ValidatingWebhookConfiguration, 108 | expected: { 109 | group: "admissionregistration.k8s.io", 110 | version: "v1", 111 | kind: "ValidatingWebhookConfiguration", 112 | }, 113 | }, 114 | { 115 | name: kind.VolumeAttachment, 116 | expected: { group: "storage.k8s.io", version: "v1", kind: "VolumeAttachment" }, 117 | }, 118 | { 119 | name: kind.Endpoints, 120 | expected: { group: "", version: "v1", kind: "Endpoints", plural: "endpoints" }, 121 | }, 122 | ]; 123 | 124 | it.each(testCases)("should return the correct GroupVersionKind for '%s'", ({ name, expected }) => { 125 | const { name: modelName } = name; 126 | const gvk = modelToGroupVersionKind(modelName); 127 | try { 128 | expect(gvk.group).toBe(expected.group); 129 | expect(gvk.version).toBe(expected.version); 130 | expect(gvk.kind).toBe(expected.kind); 131 | } catch (error) { 132 | console.error( 133 | `Failed for model ${modelName}: Expected GroupVersionKind to be ${JSON.stringify( 134 | expected, 135 | )}, but got ${JSON.stringify(gvk)}`, 136 | ); 137 | throw error; 138 | } 139 | }); 140 | 141 | it("registers a new type", () => { 142 | class UnicornKind extends kind.GenericKind {} 143 | 144 | try { 145 | RegisterKind(UnicornKind, { 146 | group: "pepr.dev", 147 | version: "v1", 148 | kind: "Unicorn", 149 | }); 150 | } catch (e) { 151 | expect(e).not.toBeDefined(); 152 | } 153 | }); 154 | 155 | it("throws an error if the kind is already registered", () => { 156 | class UnicornKind extends kind.GenericKind {} 157 | 158 | try { 159 | RegisterKind(UnicornKind, { 160 | group: "pepr.dev", 161 | version: "v1", 162 | kind: "Unicorn", 163 | }); 164 | } catch (e) { 165 | expect(e).toBeDefined(); 166 | } 167 | }); 168 | -------------------------------------------------------------------------------- /e2e/matrix.mts: -------------------------------------------------------------------------------- 1 | // ----- deps ----- // 2 | import { execSync } from "node:child_process"; 3 | 4 | // ----- args ----- // 5 | process.argv.shift(); // node 6 | process.argv.shift(); // script 7 | const peprExcellentExamplesPath = process.argv.shift(); // abs path to pepr-excellent-examples 8 | 9 | // ----- main ----- // 10 | 11 | // find examples 12 | const cmd = "npm exec -c pwd -ws"; 13 | const stdout = execSync(cmd, { cwd: peprExcellentExamplesPath }); 14 | const examples = stdout.toLocaleString().trim().split("\n"); 15 | 16 | // select those with 'test:e2e' scripts 17 | const raw = await Promise.all( 18 | examples.map(async ex => { 19 | const cfg = await import(`${ex}/package.json`, { assert: { type: "json" } }); 20 | return [ex, cfg.default] as const; 21 | }), 22 | ); 23 | 24 | const e2es = raw 25 | .filter(([, cfg]) => Object.hasOwn(cfg.scripts ?? {}, "test:e2e")) 26 | .filter(([, cfg]) => cfg.name !== "test-specific-version"); // requires package.json.bak which is only present when overriding the Pepr version 27 | 28 | // gen matrix spec 29 | const spec = { 30 | include: e2es.map(([ex, cfg]) => ({ 31 | name: cfg.name, 32 | path: ex, 33 | })), 34 | }; 35 | 36 | console.log(JSON.stringify(spec)); 37 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["ES2022"], 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "target": "ES2022", 10 | "useUnknownInCatchVariables": false 11 | }, 12 | "include": ["./**/*.e2e.test.ts", "matrix.mts"], 13 | "exclude": ["node_modules", "dist", "**/crds/**"] 14 | } 15 | -------------------------------------------------------------------------------- /e2e/watch.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { GenericClass, K8s, kind, KubernetesObject } from "../src"; 2 | import { beforeAll, describe, expect, it } from "vitest"; 3 | import { execSync } from "child_process"; 4 | import { WatchPhase } from "../src/fluent/shared-types.js"; 5 | import { WatchEvent } from "../src"; 6 | const namespace = `kfc-watch`; 7 | describe("watcher e2e", () => { 8 | beforeAll(async () => { 9 | try { 10 | await K8s(kind.Namespace).Apply( 11 | { metadata: { name: namespace } }, 12 | { 13 | force: true, 14 | }, 15 | ); 16 | await K8s(kind.Pod).Apply( 17 | { 18 | metadata: { name: namespace, namespace, labels: { app: "nginx" } }, 19 | spec: { containers: [{ name: "nginx", image: "nginx" }] }, 20 | }, 21 | { force: true }, 22 | ); 23 | await waitForRunningStatusPhase(kind.Pod, { 24 | metadata: { name: namespace, namespace }, 25 | }); 26 | } catch (e) { 27 | expect(e).toBeUndefined(); 28 | } 29 | }, 80000); 30 | 31 | it("should watch named resources", () => { 32 | return new Promise(resolve => { 33 | const watcher = K8s(kind.Pod) 34 | .InNamespace(namespace) 35 | .Watch(po => { 36 | expect(po.metadata!.name).toBe(namespace); 37 | watcher.close(); 38 | resolve(); 39 | }); 40 | void watcher.start(); 41 | }); 42 | }); 43 | 44 | it("should call the event handler for each event", () => { 45 | return new Promise(resolve => { 46 | const watcher = K8s(kind.Pod) 47 | .InNamespace(namespace) 48 | .Watch((po, evt) => { 49 | expect(po.metadata!.name).toBe(namespace); 50 | expect(evt).toBe(WatchPhase.Added); 51 | watcher.close(); 52 | resolve(); 53 | }); 54 | void watcher.start(); 55 | }); 56 | }); 57 | 58 | it("should handle the CONNECT event", async () => { 59 | const watcher = K8s(kind.Pod) 60 | .InNamespace(namespace) 61 | .Watch(po => { 62 | expect(po.metadata!.name).toBe(namespace); 63 | }); 64 | 65 | const connectPromise = new Promise(resolve => { 66 | watcher.events.once(WatchEvent.CONNECT, path => { 67 | expect(path).toBe("/api/v1/namespaces/kfc-watch/pods"); 68 | resolve(); 69 | }); 70 | }); 71 | 72 | void watcher.start(); 73 | await connectPromise; 74 | watcher.close(); 75 | }); 76 | 77 | it("should handle the RECONNECT event", () => { 78 | return new Promise(resolve => { 79 | const watcher = K8s(kind.Pod) 80 | .InNamespace(namespace) 81 | .Watch(po => { 82 | expect(po.metadata!.name).toBe(namespace); 83 | }); 84 | void watcher.start(); 85 | 86 | watcher.events.on(WatchEvent.RECONNECT, num => { 87 | expect(num).toBe(1); 88 | }); 89 | execSync(`k3d cluster stop kfc-dev`, { stdio: "inherit" }); 90 | execSync(`k3d cluster start kfc-dev`, { stdio: "inherit" }); 91 | watcher.close(); 92 | resolve(); 93 | }); 94 | }, 90000); 95 | 96 | it("should handle the DATA event", () => { 97 | return new Promise(resolve => { 98 | const watcher = K8s(kind.Pod) 99 | .InNamespace(namespace) 100 | .Watch(po => { 101 | expect(po.metadata!.name).toBe(namespace); 102 | }); 103 | void watcher.start(); 104 | 105 | watcher.events.on(WatchEvent.DATA, po => { 106 | expect(po.metadata.name).toBe(namespace); 107 | }); 108 | watcher.close(); 109 | resolve(); 110 | }); 111 | }); 112 | 113 | it("should handle the GIVE_UP event", () => { 114 | return new Promise(resolve => { 115 | const watcher = K8s(kind.Pod) 116 | .InNamespace(namespace) 117 | .Watch( 118 | po => { 119 | expect(po.metadata!.name).toBe(namespace); 120 | }, 121 | { 122 | resyncDelaySec: 1, 123 | resyncFailureMax: 1, 124 | }, 125 | ); 126 | void watcher.start(); 127 | 128 | watcher.events.on(WatchEvent.GIVE_UP, err => { 129 | expect(err).toBeDefined(); 130 | }); 131 | watcher.close(); 132 | resolve(); 133 | }); 134 | }); 135 | 136 | it("should handle the GIVE_UP event", () => { 137 | return new Promise(resolve => { 138 | const watcher = K8s(kind.Pod) 139 | .InNamespace(namespace) 140 | .Watch( 141 | po => { 142 | expect(po.metadata!.name).toBe(namespace); 143 | }, 144 | { 145 | resyncDelaySec: 1, 146 | resyncFailureMax: 1, 147 | }, 148 | ); 149 | void watcher.start(); 150 | 151 | watcher.events.on(WatchEvent.GIVE_UP, err => { 152 | expect(err).toBeDefined(); 153 | }); 154 | watcher.close(); 155 | resolve(); 156 | }); 157 | }); 158 | 159 | it("should perform a resync after the resync interval", () => { 160 | return new Promise(resolve => { 161 | const watcher = K8s(kind.Pod) 162 | .InNamespace(namespace) 163 | .Watch( 164 | po => { 165 | expect(po.metadata!.name).toBe(namespace); 166 | }, 167 | { 168 | resyncDelaySec: 1, 169 | resyncFailureMax: 1, 170 | }, 171 | ); 172 | void watcher.start(); 173 | 174 | watcher.events.on(WatchEvent.RECONNECT, num => { 175 | expect(num).toBe(1); 176 | }); 177 | 178 | watcher.close(); 179 | resolve(); 180 | }); 181 | }); 182 | }); 183 | 184 | /** 185 | * sleep for a given number of seconds 186 | * 187 | * @param seconds - number of seconds to sleep 188 | * @returns Promise 189 | */ 190 | export function sleep(seconds: number): Promise { 191 | return new Promise(resolve => setTimeout(resolve, seconds * 1000)); 192 | } 193 | 194 | /** 195 | * Wait for the status phase to be Running 196 | * 197 | * @param k - GenericClass 198 | * @param o - KubernetesObject 199 | * @returns Promise 200 | */ 201 | export async function waitForRunningStatusPhase( 202 | k: GenericClass, 203 | o: KubernetesObject, 204 | ): Promise { 205 | const object = await K8s(k) 206 | .InNamespace(o.metadata?.namespace || "") 207 | .Get(o.metadata?.name || ""); 208 | 209 | if (object.status?.phase !== "Running") { 210 | await sleep(2); 211 | return waitForRunningStatusPhase(k, o); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import globals from "globals"; 3 | import tsParser from "@typescript-eslint/parser"; 4 | import path from "node:path"; 5 | import { fileURLToPath } from "node:url"; 6 | import js from "@eslint/js"; 7 | import { FlatCompat } from "@eslint/eslintrc"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }); 16 | 17 | export default [ 18 | { 19 | ignores: [ 20 | "**/node_modules", 21 | "**/dist", 22 | "**/__mocks__", 23 | ".github/workflows/matrix.js", 24 | "**/e2e/crds/**", 25 | ], 26 | }, 27 | ...compat.extends( 28 | "eslint:recommended", 29 | "plugin:@typescript-eslint/recommended", 30 | "plugin:jsdoc/recommended-typescript-error", 31 | ), 32 | { 33 | plugins: { 34 | "@typescript-eslint": typescriptEslint, 35 | }, 36 | 37 | languageOptions: { 38 | globals: { 39 | ...Object.fromEntries(Object.entries(globals.browser).map(([key]) => [key, "off"])), 40 | }, 41 | 42 | parser: tsParser, 43 | ecmaVersion: 2022, 44 | sourceType: "script", 45 | 46 | parserOptions: { 47 | project: ["tsconfig.json", "./e2e/tsconfig.json"], 48 | }, 49 | }, 50 | 51 | rules: { 52 | "@typescript-eslint/no-floating-promises": "warn", 53 | "class-methods-use-this": "warn", 54 | complexity: [ 55 | "warn", 56 | { 57 | max: 10, 58 | }, 59 | ], 60 | "consistent-this": "warn", 61 | eqeqeq: "error", 62 | "max-depth": [ 63 | "warn", 64 | { 65 | max: 3, 66 | }, 67 | ], 68 | "max-nested-callbacks": [ 69 | "warn", 70 | { 71 | max: 4, 72 | }, 73 | ], 74 | "max-params": [ 75 | "warn", 76 | { 77 | max: 4, 78 | }, 79 | ], 80 | "max-statements": [ 81 | "warn", 82 | { 83 | max: 20, 84 | }, 85 | { 86 | ignoreTopLevelFunctions: true, 87 | }, 88 | ], 89 | "no-invalid-this": "warn", 90 | "class-methods-use-this": "warn", 91 | "consistent-this": "warn", 92 | "no-invalid-this": "warn", 93 | "@typescript-eslint/no-floating-promises": [ 94 | "warn", 95 | { 96 | ignoreVoid: true, 97 | }, 98 | ], 99 | 100 | "jsdoc/tag-lines": [ 101 | "error", 102 | "any", 103 | { 104 | startLines: 1, 105 | }, 106 | ], 107 | }, 108 | }, 109 | { 110 | files: ["**/*.test.ts"], 111 | 112 | rules: { 113 | "max-nested-callbacks": [ 114 | "warn", 115 | { 116 | max: 8, 117 | }, 118 | ], 119 | }, 120 | }, 121 | ]; 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kubernetes-fluent-client", 3 | "version": "0.0.0-development", 4 | "description": "A @kubernetes/client-node fluent API wrapper that leverages K8s Server Side Apply.", 5 | "bin": "./dist/cli.js", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "type": "module", 9 | "engines": { 10 | "node": ">=20.0.0" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/defenseunicorns/kubernetes-fluent-client.git" 15 | }, 16 | "keywords": [ 17 | "kubernetes", 18 | "k8s", 19 | "fluent", 20 | "devops", 21 | "devsecops", 22 | "api" 23 | ], 24 | "author": "Defense Unicorns", 25 | "license": "Apache-2.0", 26 | "bugs": { 27 | "url": "https://github.com/defenseunicorns/kubernetes-fluent-client/issues" 28 | }, 29 | "homepage": "https://github.com/defenseunicorns/kubernetes-fluent-client#readme", 30 | "files": [ 31 | "/src", 32 | "/dist", 33 | "!src/**/*.test.ts", 34 | "!dist/**/*.test.js*", 35 | "!dist/**/*.test.d.ts*" 36 | ], 37 | "scripts": { 38 | "prebuild": "rm -rf dist", 39 | "build": "tsc", 40 | "semantic-release": "semantic-release", 41 | "test": "vitest src run --coverage", 42 | "test:e2e": "vitest run e2e/", 43 | "test:e2e:prep-crds": "kubectl apply -f test/ && npx tsx src/cli.ts crd ./test/datastore.crd.yaml e2e && npx tsx src/cli.ts crd https://raw.githubusercontent.com/defenseunicorns/kubernetes-fluent-client/refs/heads/main/test/webapp.crd.yaml e2e && npx tsx src/cli.ts crd https://raw.githubusercontent.com/defenseunicorns/kubernetes-fluent-client/refs/heads/main/test/webapp.crd.yaml -l json-schema e2e/schemas/webapp", 44 | "test:e2e:prep-cluster": "k3d cluster create kfc-dev --k3s-arg '--debug@server:0' --wait && kubectl rollout status deployment -n kube-system", 45 | "test:e2e:prep-image": "npm run build && npm pack && npm i kubernetes-fluent-client-0.0.0-development.tgz --no-save", 46 | "test:e2e:run": "npm run test:e2e:prep-cluster && npm run test:e2e:prep-crds && npm run test:e2e:prep-image && npm run test:e2e && npm run test:e2e:cleanup", 47 | "test:e2e:cleanup": "k3d cluster delete kfc-dev", 48 | "format:check": "eslint src e2e && prettier . --check", 49 | "format:fix": "eslint --fix src e2e && prettier . --write", 50 | "prepare": "if [ \"$NODE_ENV\" != 'production' ]; then husky; fi" 51 | }, 52 | "dependencies": { 53 | "@kubernetes/client-node": "1.3.0", 54 | "fast-json-patch": "3.1.1", 55 | "http-status-codes": "2.3.0", 56 | "node-fetch": "2.7.0", 57 | "quicktype-core": "23.2.6", 58 | "type-fest": "^4.39.1", 59 | "undici": "^7.7.0", 60 | "yargs": "18.0.0" 61 | }, 62 | "devDependencies": { 63 | "@commitlint/cli": "19.8.1", 64 | "@commitlint/config-conventional": "19.8.1", 65 | "@eslint/eslintrc": "^3.1.0", 66 | "@eslint/js": "^9.14.0", 67 | "@types/byline": "4.2.36", 68 | "@types/command-line-args": "^5.2.3", 69 | "@types/readable-stream": "4.0.21", 70 | "@types/urijs": "^1.19.25", 71 | "@types/ws": "^8.18.1", 72 | "@types/yargs": "17.0.33", 73 | "@typescript-eslint/eslint-plugin": "8.34.0", 74 | "@typescript-eslint/parser": "8.34.0", 75 | "@vitest/coverage-v8": "^3.2.1", 76 | "command-line-args": "^6.0.1", 77 | "eslint-plugin-jsdoc": "50.7.1", 78 | "globals": "^16.0.0", 79 | "husky": "^9.1.6", 80 | "lint-staged": "^16.0.0", 81 | "prettier": "3.5.3", 82 | "semantic-release": "24.2.5", 83 | "typescript": "5.8.3", 84 | "vitest": "^3.2.1" 85 | }, 86 | "overrides": { 87 | "semantic-release@24.2.0": { 88 | "npm": { 89 | "glob": { 90 | "foreground-child": { 91 | "cross-spawn": "^7.0.6" 92 | } 93 | } 94 | } 95 | } 96 | }, 97 | "release": { 98 | "branches": [ 99 | "main", 100 | "next" 101 | ] 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /scripts/nightlies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # SPDX-License-Identifier: Apache-2.0 4 | # SPDX-FileCopyrightText: 2023-Present The Pepr Authors 5 | 6 | # Script to build and publish nightly versions of kubernetes-fluent-client. 7 | 8 | set -e 9 | npm install -g npm 10 | 11 | LATEST_VERSION=$(npx --yes kubernetes-fluent-client@latest --version 2>/dev/null) 12 | RAW_NIGHTLY_VERSION=$(npx --yes kubernetes-fluent-client@nightly --version 2>/dev/null || echo "none") 13 | 14 | if [[ "$RAW_NIGHTLY_VERSION" == "none" ]]; then 15 | echo "No nightly version found. Setting NIGHTLY_VERSION=0." 16 | NIGHTLY_VERSION=0 17 | else 18 | NIGHTLY_VERSION_PART=$(echo "$RAW_NIGHTLY_VERSION" | grep -oE "nightly\.([0-9]+)" | cut -d. -f2) 19 | 20 | BASE_NIGHTLY_VERSION=${RAW_NIGHTLY_VERSION%-nightly*} 21 | if [[ "$LATEST_VERSION" > "$BASE_NIGHTLY_VERSION" ]]; then 22 | echo "Nightly version is less than the latest version. Resetting NIGHTLY_VERSION to 0." 23 | NIGHTLY_VERSION=0 24 | else 25 | NIGHTLY_VERSION=$((NIGHTLY_VERSION_PART + 1)) 26 | echo "Incrementing NIGHTLY_VERSION to $NIGHTLY_VERSION." 27 | fi 28 | fi 29 | 30 | FULL_VERSION="${LATEST_VERSION}-nightly.${NIGHTLY_VERSION}" 31 | 32 | echo "FULL_VERSION=$FULL_VERSION" >> "$GITHUB_ENV" 33 | 34 | npm version --no-git-tag-version "$FULL_VERSION" 35 | 36 | npm install 37 | npm run build 38 | 39 | npm publish --tag "nightly" 40 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // SPDX-License-Identifier: Apache-2.0 4 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 5 | 6 | import { hideBin } from "yargs/helpers"; 7 | import yargs from "yargs/yargs"; 8 | import { GenerateOptions, generate } from "./generate.js"; 9 | import { postProcessing } from "./postProcessing.js"; 10 | import { createRequire } from "node:module"; 11 | const require = createRequire(import.meta.url); 12 | const { version } = require("../package.json"); 13 | 14 | void yargs(hideBin(process.argv)) 15 | .version("version", "Display version number", `${version}`) 16 | .alias("version", "V") 17 | .command( 18 | "crd [source] [directory]", 19 | "generate usable types from a K8s CRD", 20 | yargs => { 21 | return yargs 22 | .positional("source", { 23 | describe: "the yaml file path, remote url, or K8s CRD name", 24 | type: "string", 25 | }) 26 | .positional("directory", { 27 | describe: "the directory to output the generated types to", 28 | type: "string", 29 | }) 30 | .option("plain", { 31 | alias: "p", 32 | type: "boolean", 33 | description: 34 | "generate plain types without binding to the fluent client, automatically enabled when an alternate language is specified", 35 | }) 36 | .option("language", { 37 | alias: "l", 38 | type: "string", 39 | default: "ts", 40 | description: 41 | "the language to generate types in, see https://github.com/glideapps/quicktype#target-languages for a list of supported languages", 42 | }) 43 | .option("noPost", { 44 | alias: "x", 45 | type: "boolean", 46 | default: false, 47 | description: "disable post-processing after generating the types", 48 | }) 49 | .demandOption(["source", "directory"]); 50 | }, 51 | async argv => { 52 | const opts = argv as unknown as GenerateOptions; 53 | opts.logFn = console.log; 54 | 55 | // Pass the `post` flag to opts 56 | opts.noPost = argv.noPost as boolean; 57 | 58 | // Use NodeFileSystem as the file system for post-processing 59 | 60 | if (!opts.noPost) { 61 | console.log("\n✅ Post-processing has been enabled.\n"); 62 | } 63 | 64 | try { 65 | // Capture the results returned by generate 66 | const allResults = await generate(opts); 67 | 68 | // If noPost is false, run post-processing 69 | if (!opts.noPost) { 70 | await postProcessing(allResults, opts); // Pass the file system to postProcessing 71 | } 72 | } catch (e) { 73 | console.log(`\n❌ ${e.message}`); 74 | } 75 | }, 76 | ) 77 | .parse(); 78 | -------------------------------------------------------------------------------- /src/fetch.test.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { expect, test, beforeEach, afterEach } from "vitest"; 5 | import { StatusCodes } from "http-status-codes"; 6 | import { RequestInit } from "undici"; 7 | import { fetch } from "./fetch.js"; 8 | import { MockAgent, setGlobalDispatcher } from "undici"; 9 | 10 | let mockAgent: MockAgent; 11 | interface Todo { 12 | userId: number; 13 | id: number; 14 | title: string; 15 | completed: boolean; 16 | } 17 | beforeEach(() => { 18 | mockAgent = new MockAgent(); 19 | setGlobalDispatcher(mockAgent); 20 | mockAgent.disableNetConnect(); 21 | 22 | const mockClient = mockAgent.get("https://jsonplaceholder.typicode.com"); 23 | 24 | mockClient.intercept({ path: "/todos/1", method: "GET" }).reply( 25 | StatusCodes.OK, 26 | { 27 | userId: 1, 28 | id: 1, 29 | title: "Example title", 30 | completed: false, 31 | }, 32 | { 33 | headers: { 34 | "Content-Type": "application/json; charset=utf-8", 35 | }, 36 | }, 37 | ); 38 | 39 | mockClient.intercept({ path: "/todos", method: "POST" }).reply( 40 | StatusCodes.OK, 41 | { title: "test todo", userId: 1, completed: false }, 42 | { 43 | headers: { 44 | "Content-Type": "application/json; charset=utf-8", 45 | }, 46 | }, 47 | ); 48 | 49 | mockClient 50 | .intercept({ path: "/todos/empty-null", method: "GET" }) 51 | .reply(StatusCodes.OK, undefined); 52 | 53 | mockClient.intercept({ path: "/todos/empty-string", method: "GET" }).reply(StatusCodes.OK, ""); 54 | 55 | mockClient.intercept({ path: "/todos/empty-object", method: "GET" }).reply( 56 | StatusCodes.OK, 57 | {}, 58 | { 59 | headers: { 60 | "Content-Type": "application/json; charset=utf-8", 61 | }, 62 | }, 63 | ); 64 | 65 | mockClient 66 | .intercept({ path: "/todos/invalid", method: "GET" }) 67 | .replyWithError(new Error("Something bad happened")); 68 | }); 69 | 70 | afterEach(async () => { 71 | try { 72 | await mockAgent.close(); 73 | } catch (error) { 74 | console.error("Error closing mock agent", error); 75 | } 76 | }); 77 | 78 | test("fetch: should return without type data", async () => { 79 | const url = "https://jsonplaceholder.typicode.com/todos/1"; 80 | const requestOptions: RequestInit = { 81 | method: "GET", 82 | headers: { 83 | hi: "there", 84 | "content-type": "application/json; charset=UTF-8", 85 | }, 86 | }; 87 | const { data, ok } = await fetch(url, requestOptions); 88 | expect(ok).toBe(true); 89 | expect(data.title).toBe("Example title"); 90 | }); 91 | 92 | test("fetch: should return parsed JSON response as a specific type", async () => { 93 | const url = "https://jsonplaceholder.typicode.com/todos/1"; 94 | const requestOptions: RequestInit = { 95 | method: "GET", 96 | headers: { 97 | "Content-Type": "application/json; charset=UTF-8", 98 | }, 99 | }; 100 | const res = await fetch(url, requestOptions); 101 | expect(res.ok).toBe(true); 102 | 103 | expect(res.data.id).toBe(1); 104 | expect(typeof res.data.title).toBe("string"); 105 | expect(typeof res.data.completed).toBe("boolean"); 106 | }); 107 | 108 | test("fetch: should handle additional request options", async () => { 109 | const url = "https://jsonplaceholder.typicode.com/todos"; 110 | const requestOptions: RequestInit = { 111 | method: "POST", 112 | body: JSON.stringify({ 113 | title: "test todo", 114 | userId: 1, 115 | completed: false, 116 | }), 117 | headers: { 118 | "Content-Type": "application/json; charset=UTF-8", 119 | }, 120 | }; 121 | 122 | const res = await fetch(url, requestOptions); 123 | expect(res.ok).toBe(true); 124 | expect(res.data).toStrictEqual({ title: "test todo", userId: 1, completed: false }); 125 | }); 126 | 127 | test("fetch: should handle empty (null) responses", async () => { 128 | const url = "https://jsonplaceholder.typicode.com/todos/empty-null"; 129 | const resp = await fetch(url); 130 | expect(resp.data).toBe(""); 131 | expect(resp.ok).toBe(true); 132 | expect(resp.status).toBe(StatusCodes.OK); 133 | }); 134 | 135 | test("fetch: should handle empty (string) responses", async () => { 136 | const url = "https://jsonplaceholder.typicode.com/todos/empty-string"; 137 | const resp = await fetch(url); 138 | expect(resp.data).toBe(""); 139 | expect(resp.ok).toBe(true); 140 | expect(resp.status).toBe(StatusCodes.OK); 141 | }); 142 | 143 | test("fetch: should handle empty (object) responses", async () => { 144 | const url = "https://jsonplaceholder.typicode.com/todos/empty-object"; 145 | const requestOptions: RequestInit = { 146 | method: "GET", 147 | headers: { 148 | "Content-Type": "application/json; charset=UTF-8", 149 | }, 150 | }; 151 | const resp = await fetch(url, requestOptions); 152 | expect(resp.data).toEqual({}); 153 | expect(resp.ok).toBe(true); 154 | expect(resp.status).toBe(StatusCodes.OK); 155 | }); 156 | 157 | test("fetch: should handle failed requests without throwing an error", async () => { 158 | const url = "https://jsonplaceholder.typicode.com/todos/invalid"; 159 | const resp = await fetch(url); 160 | 161 | expect(resp.data).toBe(undefined); 162 | expect(resp.ok).toBe(false); 163 | expect(resp.status).toBe(StatusCodes.BAD_REQUEST); 164 | }); 165 | 166 | test("fetch wrapper respects MockAgent", async () => { 167 | const mockClient = mockAgent.get("https://example.com"); 168 | 169 | mockClient.intercept({ path: "/test", method: "GET" }).reply( 170 | 200, 171 | { success: true }, 172 | { 173 | headers: { 174 | "Content-Type": "application/json; charset=utf-8", 175 | }, 176 | }, 177 | ); 178 | 179 | const response = await fetch<{ success: boolean }>("https://example.com/test"); 180 | 181 | expect(response.ok).toBe(true); 182 | expect(response.data).toEqual({ success: true }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { StatusCodes } from "http-status-codes"; 5 | import { fetch as undiciFetch, RequestInfo, RequestInit } from "undici"; 6 | 7 | export type FetchResponse = { 8 | data: T; 9 | ok: boolean; 10 | status: number; 11 | statusText: string; 12 | }; 13 | 14 | /** 15 | * Perform an async HTTP call and return the parsed JSON response, optionally 16 | * as a specific type. 17 | * 18 | * @example 19 | * ```ts 20 | * fetch("https://example.com/api/foo"); 21 | * ``` 22 | * 23 | * @param url The URL or Request object to fetch 24 | * @param init Additional options for the request 25 | * @returns The parsed JSON response 26 | */ 27 | export async function fetch( 28 | url: URL | RequestInfo, 29 | init?: RequestInit, 30 | ): Promise> { 31 | let data = undefined as unknown as T; 32 | try { 33 | const resp = await undiciFetch(url, init); 34 | const contentType = resp.headers.get("content-type") || ""; 35 | 36 | // Parse the response as JSON if the content type is JSON 37 | if (contentType.includes("application/json")) { 38 | data = (await resp.json()) as T; 39 | } else { 40 | // Otherwise, return however the response was read 41 | data = (await resp.text()) as unknown as T; 42 | } 43 | 44 | return { 45 | data, 46 | ok: resp.ok, 47 | status: resp.status, 48 | statusText: resp.statusText, 49 | }; 50 | } catch (e) { 51 | const status = parseInt(e?.code) || StatusCodes.BAD_REQUEST; 52 | const statusText = e?.message || "Unknown error"; 53 | 54 | return { 55 | data, 56 | ok: false, 57 | status, 58 | statusText, 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/fluent/index.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | import { V1APIGroup } from "@kubernetes/client-node"; 3 | import { Operation } from "fast-json-patch"; 4 | 5 | import { K8s } from "./index.js"; 6 | import { fetch } from "../fetch.js"; 7 | import { Pod } from "../upstream.js"; 8 | import { k8sCfg, k8sExec } from "./utils.js"; 9 | 10 | // Setup mocks 11 | vi.mock("./utils"); 12 | vi.mock("../fetch"); 13 | 14 | const generateFakePodManagedFields = (manager: string) => { 15 | return [ 16 | { 17 | apiVersion: "v1", 18 | fieldsType: "FieldsV1", 19 | fieldsV1: { 20 | "f:metadata": { 21 | "f:labels": { 22 | "f:fake": {}, 23 | }, 24 | "f:spec": { 25 | "f:containers": { 26 | 'k:{"name":"fake"}': { 27 | "f:image": {}, 28 | "f:name": {}, 29 | "f:resources": { 30 | "f:limits": { 31 | "f:cpu": {}, 32 | "f:memory": {}, 33 | }, 34 | "f:requests": { 35 | "f:cpu": {}, 36 | "f:memory": {}, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | manager: manager, 45 | operation: "Apply", 46 | }, 47 | ]; 48 | }; 49 | describe("Kube", () => { 50 | const fakeResource = { 51 | metadata: { 52 | name: "fake", 53 | namespace: "default", 54 | managedFields: generateFakePodManagedFields("pepr"), 55 | }, 56 | }; 57 | 58 | const mockedKubeCfg = vi.mocked(k8sCfg); 59 | const mockedKubeExec = vi.mocked(k8sExec).mockResolvedValue(fakeResource); 60 | 61 | beforeEach(() => { 62 | // Clear all instances and calls to constructor and all methods: 63 | mockedKubeExec.mockClear(); 64 | }); 65 | 66 | it("should create a resource", async () => { 67 | const result = await K8s(Pod).Create(fakeResource); 68 | 69 | expect(result).toEqual(fakeResource); 70 | expect(mockedKubeExec).toHaveBeenCalledWith( 71 | Pod, 72 | expect.objectContaining({ 73 | name: "fake", 74 | namespace: "default", 75 | }), 76 | { method: "POST", payload: fakeResource }, 77 | ); 78 | }); 79 | 80 | it("should delete a resource", async () => { 81 | await K8s(Pod).Delete(fakeResource); 82 | 83 | expect(mockedKubeExec).toHaveBeenCalledWith( 84 | Pod, 85 | expect.objectContaining({ 86 | name: "fake", 87 | namespace: "default", 88 | }), 89 | { method: "DELETE" }, 90 | ); 91 | }); 92 | 93 | it("should evict a resource", async () => { 94 | await K8s(Pod).Evict(fakeResource); 95 | 96 | expect(mockedKubeExec).toHaveBeenCalledWith( 97 | Pod, 98 | expect.objectContaining({ 99 | name: "fake", 100 | namespace: "default", 101 | }), 102 | { 103 | method: "POST", 104 | payload: { 105 | apiVersion: "policy/v1", 106 | kind: "Eviction", 107 | metadata: { name: "fake", namespace: "default" }, 108 | }, 109 | }, 110 | ); 111 | }); 112 | 113 | it("should patch a resource", async () => { 114 | const patchOperations: Operation[] = [ 115 | { op: "replace", path: "/metadata/name", value: "new-fake" }, 116 | ]; 117 | 118 | const result = await K8s(Pod).Patch(patchOperations); 119 | 120 | expect(result).toEqual(fakeResource); 121 | expect(mockedKubeExec).toHaveBeenCalledWith( 122 | Pod, 123 | {}, 124 | { method: "PATCH", payload: patchOperations }, 125 | ); 126 | }); 127 | 128 | it("should patch the status of a resource", async () => { 129 | await K8s(Pod).PatchStatus({ 130 | metadata: { 131 | name: "fake", 132 | namespace: "default", 133 | managedFields: generateFakePodManagedFields("pepr"), 134 | }, 135 | spec: { priority: 3 }, 136 | status: { 137 | phase: "Ready", 138 | }, 139 | }); 140 | 141 | expect(k8sExec).toBeCalledWith( 142 | Pod, 143 | expect.objectContaining({ 144 | name: "fake", 145 | namespace: "default", 146 | }), 147 | { 148 | method: "PATCH_STATUS", 149 | payload: { 150 | apiVersion: "v1", 151 | kind: "Pod", 152 | metadata: { 153 | name: "fake", 154 | namespace: "default", 155 | managedFields: generateFakePodManagedFields("pepr"), 156 | }, 157 | spec: { priority: 3 }, 158 | status: { 159 | phase: "Ready", 160 | }, 161 | }, 162 | }, 163 | ); 164 | }); 165 | 166 | it("should filter with WithField", async () => { 167 | await K8s(Pod).WithField("metadata.name", "fake").Get(); 168 | 169 | expect(mockedKubeExec).toHaveBeenCalledWith( 170 | Pod, 171 | expect.objectContaining({ 172 | fields: { 173 | "metadata.name": "fake", 174 | }, 175 | }), 176 | { method: "GET" }, 177 | ); 178 | }); 179 | 180 | it("should filter with WithLabel", async () => { 181 | await K8s(Pod).WithLabel("app", "fakeApp").Get(); 182 | 183 | expect(mockedKubeExec).toHaveBeenCalledWith( 184 | Pod, 185 | expect.objectContaining({ 186 | labels: { 187 | app: "fakeApp", 188 | }, 189 | }), 190 | { method: "GET" }, 191 | ); 192 | }); 193 | 194 | it("should use InNamespace", async () => { 195 | await K8s(Pod).InNamespace("fakeNamespace").Get(); 196 | 197 | expect(mockedKubeExec).toHaveBeenCalledWith( 198 | Pod, 199 | expect.objectContaining({ 200 | namespace: "fakeNamespace", 201 | }), 202 | { method: "GET" }, 203 | ); 204 | }); 205 | 206 | it("should throw an error if namespace is already specified", async () => { 207 | expect(() => K8s(Pod, { namespace: "default" }).InNamespace("fakeNamespace")).toThrow( 208 | "Namespace already specified: default", 209 | ); 210 | }); 211 | 212 | it("should handle Delete when the resource doesn't exist", async () => { 213 | mockedKubeExec.mockRejectedValueOnce({ status: 404 }); // Not Found on first call 214 | await expect(K8s(Pod).Delete("fakeResource")).resolves.toBeUndefined(); 215 | }); 216 | 217 | it("should handle Evict when the resource doesn't exist", async () => { 218 | mockedKubeExec.mockRejectedValueOnce({ status: 404 }); // Not Found on first call 219 | await expect(K8s(Pod).Evict("fakeResource")).resolves.toBeUndefined(); 220 | }); 221 | 222 | it("should handle Get", async () => { 223 | const result = await K8s(Pod).Get("fakeResource"); 224 | 225 | expect(result).toEqual(fakeResource); 226 | expect(mockedKubeExec).toHaveBeenCalledWith( 227 | Pod, 228 | expect.objectContaining({ 229 | name: "fakeResource", 230 | }), 231 | { method: "GET" }, 232 | ); 233 | }); 234 | 235 | it("should thrown an error if Get is called with a name and filters are already specified a name", async () => { 236 | await expect(K8s(Pod, { name: "fake" }).Get("fakeResource")).rejects.toThrow( 237 | "Name already specified: fake", 238 | ); 239 | }); 240 | 241 | it("should throw an error if no patch operations provided", async () => { 242 | await expect(K8s(Pod).Patch([])).rejects.toThrow("No operations specified"); 243 | }); 244 | 245 | it("should allow Apply of deep partials", async () => { 246 | const result = await K8s(Pod).Apply({ metadata: { name: "fake" }, spec: { priority: 3 } }); 247 | expect(result).toEqual(fakeResource); 248 | }); 249 | 250 | it("should allow force apply to resolve FieldManagerConflict", async () => { 251 | const result = await K8s(Pod).Apply( 252 | { 253 | metadata: { name: "fake", managedFields: generateFakePodManagedFields("kubectl") }, 254 | spec: { priority: 3 }, 255 | }, 256 | { force: true }, 257 | ); 258 | expect(result).toEqual(fakeResource); 259 | }); 260 | 261 | it("should throw an error if a Delete failed for a reason other than Not Found", async () => { 262 | mockedKubeExec.mockRejectedValueOnce({ status: 500 }); // Internal Server Error on first call 263 | await expect(K8s(Pod).Delete("fakeResource")).rejects.toEqual( 264 | expect.objectContaining({ status: 500 }), 265 | ); 266 | }); 267 | 268 | it("should throw an error if an Evict failed for a reason other than Not Found", async () => { 269 | mockedKubeExec.mockRejectedValueOnce({ status: 500 }); // Internal Server Error on first call 270 | await expect(K8s(Pod).Evict("fakeResource")).rejects.toEqual( 271 | expect.objectContaining({ status: 500 }), 272 | ); 273 | }); 274 | 275 | it("should create a raw api request", async () => { 276 | mockedKubeCfg.mockReturnValue( 277 | new Promise(r => 278 | r({ 279 | serverUrl: "https://localhost:8080", 280 | opts: {}, 281 | }), 282 | ), 283 | ); 284 | const mockResp = { 285 | kind: "APIVersions", 286 | versions: ["v1"], 287 | serverAddressByClientCIDRs: [ 288 | { 289 | serverAddress: "172.27.0.3:6443", 290 | }, 291 | ], 292 | }; 293 | 294 | vi.mocked(fetch).mockResolvedValue({ 295 | ok: true, 296 | data: mockResp, 297 | status: 200, 298 | statusText: "OK", 299 | }); 300 | 301 | const result = await K8s(V1APIGroup).Raw("/api"); 302 | 303 | expect(result).toEqual(mockResp); 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /src/fluent/index.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { KubernetesListObject, KubernetesObject } from "@kubernetes/client-node"; 5 | import { Operation } from "fast-json-patch"; 6 | import { StatusCodes } from "http-status-codes"; 7 | import type { PartialDeep } from "type-fest"; 8 | 9 | import { fetch } from "../fetch.js"; 10 | import { modelToGroupVersionKind } from "../kinds.js"; 11 | import { GenericClass } from "../types.js"; 12 | import { K8sInit, Paths } from "./types.js"; 13 | import { Filters, WatchAction, FetchMethods, ApplyCfg } from "./shared-types.js"; 14 | import { k8sCfg, k8sExec } from "./utils.js"; 15 | import { WatchCfg, Watcher } from "./watch.js"; 16 | import { hasLogs } from "../helpers.js"; 17 | import { Pod, type Service, type ReplicaSet } from "../upstream.js"; 18 | /** 19 | * Kubernetes fluent API inspired by Kubectl. Pass in a model, then call filters and actions on it. 20 | * 21 | * @param model - the model to use for the API 22 | * @param filters - (optional) filter overrides, can also be chained 23 | * @returns a fluent API for the model 24 | */ 25 | export function K8s>( 26 | model: T, 27 | filters: Filters = {}, 28 | ): K8sInit { 29 | const withFilters = { WithField, WithLabel, Get, Delete, Evict, Watch, Logs }; 30 | const matchedKind = filters.kindOverride || modelToGroupVersionKind(model.name); 31 | 32 | /** 33 | * @inheritdoc 34 | * @see {@link K8sInit.InNamespace} 35 | */ 36 | function InNamespace(namespace: string) { 37 | if (filters.namespace) { 38 | throw new Error(`Namespace already specified: ${filters.namespace}`); 39 | } 40 | 41 | filters.namespace = namespace; 42 | return withFilters; 43 | } 44 | 45 | /** 46 | * @inheritdoc 47 | * @see {@link K8sInit.WithField} 48 | */ 49 | function WithField

>(key: P, value: string) { 50 | filters.fields = filters.fields || {}; 51 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 52 | // @ts-ignore 53 | filters.fields[key] = value; 54 | return withFilters; 55 | } 56 | 57 | /** 58 | * @inheritdoc 59 | * @see {@link K8sInit.WithLabel} 60 | */ 61 | function WithLabel(key: string, value = "") { 62 | filters.labels = filters.labels || {}; 63 | filters.labels[key] = value; 64 | return withFilters; 65 | } 66 | 67 | /** 68 | * Sync the filters with the provided payload. 69 | * 70 | * @param payload - the payload to sync with 71 | */ 72 | function syncFilters(payload: K) { 73 | // Ensure the payload has metadata 74 | payload.metadata = payload.metadata || {}; 75 | 76 | if (!filters.namespace) { 77 | filters.namespace = payload.metadata.namespace; 78 | } 79 | 80 | if (!filters.name) { 81 | filters.name = payload.metadata.name; 82 | } 83 | 84 | if (!payload.apiVersion) { 85 | payload.apiVersion = [matchedKind.group, matchedKind.version].filter(Boolean).join("/"); 86 | } 87 | 88 | if (!payload.kind) { 89 | payload.kind = matchedKind.kind; 90 | } 91 | } 92 | async function Logs(name?: string): Promise; 93 | /** 94 | * @inheritdoc 95 | * @see {@link K8sInit.Logs} 96 | */ 97 | async function Logs(name?: string): Promise { 98 | let labels: Record = {}; 99 | const { kind } = matchedKind; 100 | const { namespace } = filters; 101 | const podList: K[] = []; 102 | 103 | if (name) { 104 | if (filters.name) { 105 | throw new Error(`Name already specified: ${filters.name}`); 106 | } 107 | filters.name = name; 108 | } 109 | 110 | if (!namespace) { 111 | throw new Error("Namespace must be defined"); 112 | } 113 | if (!hasLogs(kind)) { 114 | throw new Error("Kind must be Pod or have a selector"); 115 | } 116 | 117 | try { 118 | const object = await k8sExec(model, filters, { method: FetchMethods.GET }); 119 | 120 | if (kind !== "Pod") { 121 | if (kind === "Service") { 122 | const svc: InstanceType = object; 123 | labels = svc.spec!.selector ?? {}; 124 | } else if ( 125 | kind === "ReplicaSet" || 126 | kind === "Deployment" || 127 | kind === "StatefulSet" || 128 | kind === "DaemonSet" 129 | ) { 130 | const rs: InstanceType = object; 131 | labels = rs.spec!.selector.matchLabels ?? {}; 132 | } 133 | 134 | const list = await K8s(Pod, { namespace: filters.namespace, labels }).Get(); 135 | 136 | list.items.forEach(item => { 137 | return podList.push(item as unknown as K); 138 | }); 139 | } else { 140 | podList.push(object); 141 | } 142 | } catch { 143 | throw new Error(`Failed to get logs in KFC Logs function`); 144 | } 145 | 146 | const podModel = { ...model, name: "V1Pod" }; 147 | const logPromises = podList.map(po => 148 | k8sExec( 149 | podModel, 150 | { ...filters, name: po.metadata!.name! }, 151 | { method: FetchMethods.LOG }, 152 | ), 153 | ); 154 | 155 | const responses = await Promise.all(logPromises); 156 | 157 | const combinedString = responses.reduce( 158 | (accumulator: string[], currentString: string, i: number) => { 159 | const prefixedLines = currentString 160 | .split("\n") 161 | .map(line => { 162 | return line !== "" ? `[pod/${podList[i].metadata!.name!}] ${line}` : ""; 163 | }) 164 | .filter(str => str !== ""); 165 | 166 | return [...accumulator, ...prefixedLines]; 167 | }, 168 | [], 169 | ); 170 | 171 | return combinedString; 172 | } 173 | async function Get(): Promise>; 174 | async function Get(name: string): Promise; 175 | /** 176 | * @inheritdoc 177 | * @see {@link K8sInit.Get} 178 | */ 179 | async function Get(name?: string) { 180 | if (name) { 181 | if (filters.name) { 182 | throw new Error(`Name already specified: ${filters.name}`); 183 | } 184 | filters.name = name; 185 | } 186 | 187 | return k8sExec>(model, filters, { method: FetchMethods.GET }); 188 | } 189 | 190 | /** 191 | * @inheritdoc 192 | * @see {@link K8sInit.Delete} 193 | */ 194 | async function Delete(filter?: K | string): Promise { 195 | if (typeof filter === "string") { 196 | filters.name = filter; 197 | } else if (filter) { 198 | syncFilters(filter); 199 | } 200 | 201 | try { 202 | // Try to delete the resource 203 | await k8sExec(model, filters, { method: FetchMethods.DELETE }); 204 | } catch (e) { 205 | // If the resource doesn't exist, ignore the error 206 | if (e.status === StatusCodes.NOT_FOUND) { 207 | return; 208 | } 209 | 210 | throw e; 211 | } 212 | } 213 | 214 | /** 215 | * @inheritdoc 216 | * @see {@link K8sInit.Apply} 217 | */ 218 | async function Apply( 219 | resource: PartialDeep, 220 | applyCfg: ApplyCfg = { force: false }, 221 | ): Promise { 222 | syncFilters(resource as K); 223 | return k8sExec(model, filters, { method: FetchMethods.APPLY, payload: resource }, applyCfg); 224 | } 225 | 226 | /** 227 | * @inheritdoc 228 | * @see {@link K8sInit.Create} 229 | */ 230 | async function Create(resource: K): Promise { 231 | syncFilters(resource); 232 | return k8sExec(model, filters, { method: FetchMethods.POST, payload: resource }); 233 | } 234 | 235 | /** 236 | * @inheritdoc 237 | * @see {@link K8sInit.Evict} 238 | */ 239 | async function Evict(filter?: K | string): Promise { 240 | if (typeof filter === "string") { 241 | filters.name = filter; 242 | } else if (filter) { 243 | syncFilters(filter); 244 | } 245 | 246 | try { 247 | const evictionPayload = { 248 | apiVersion: "policy/v1", 249 | kind: "Eviction", 250 | metadata: { 251 | name: filters.name, 252 | namespace: filters.namespace, 253 | }, 254 | }; 255 | // Try to evict the resource 256 | await k8sExec(model, filters, { 257 | method: FetchMethods.POST, 258 | payload: evictionPayload, 259 | }); 260 | } catch (e) { 261 | // If the resource doesn't exist, ignore the error 262 | if (e.status === StatusCodes.NOT_FOUND) { 263 | return; 264 | } 265 | throw e; 266 | } 267 | } 268 | 269 | /** 270 | * @inheritdoc 271 | * @see {@link K8sInit.Patch} 272 | */ 273 | async function Patch(payload: Operation[]): Promise { 274 | // If there are no operations, throw an error 275 | if (payload.length < 1) { 276 | throw new Error("No operations specified"); 277 | } 278 | 279 | return k8sExec(model, filters, { method: FetchMethods.PATCH, payload }); 280 | } 281 | 282 | /** 283 | * @inheritdoc 284 | * @see {@link K8sInit.PatchStatus} 285 | */ 286 | async function PatchStatus(resource: PartialDeep): Promise { 287 | syncFilters(resource as K); 288 | return k8sExec(model, filters, { method: FetchMethods.PATCH_STATUS, payload: resource }); 289 | } 290 | 291 | /** 292 | * @inheritdoc 293 | * @see {@link K8sInit.Watch} 294 | */ 295 | function Watch(callback: WatchAction, watchCfg?: WatchCfg) { 296 | return new Watcher(model, filters, callback, watchCfg); 297 | } 298 | 299 | /** 300 | * @inheritdoc 301 | * @see {@link K8sInit.Raw} 302 | */ 303 | async function Raw(url: string, method: FetchMethods = FetchMethods.GET) { 304 | const thing = await k8sCfg(method); 305 | const { opts, serverUrl } = thing; 306 | const resp = await fetch(`${serverUrl}${url}`, opts); 307 | 308 | if (resp.ok) { 309 | return resp.data; 310 | } 311 | 312 | throw resp; 313 | } 314 | 315 | return { InNamespace, Apply, Create, Patch, PatchStatus, Raw, ...withFilters }; 316 | } 317 | -------------------------------------------------------------------------------- /src/fluent/shared-types.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | import { GenericClass, GroupVersionKind } from "../types.js"; 4 | import { RequestInit } from "undici"; 5 | import { KubernetesObject } from "@kubernetes/client-node"; 6 | /** 7 | * Fetch options and server URL 8 | */ 9 | export type K8sConfigPromise = Promise<{ opts: RequestInit; serverUrl: string | URL }>; 10 | 11 | /** 12 | * The Phase matched when using the K8s Watch API. 13 | */ 14 | export enum WatchPhase { 15 | Added = "ADDED", 16 | Modified = "MODIFIED", 17 | Deleted = "DELETED", 18 | Bookmark = "BOOKMARK", 19 | Error = "ERROR", 20 | } 21 | 22 | export type WatchAction> = ( 23 | update: K, 24 | phase: WatchPhase, 25 | ) => Promise | void; 26 | 27 | export interface Filters { 28 | kindOverride?: GroupVersionKind; 29 | fields?: Record; 30 | labels?: Record; 31 | name?: string; 32 | namespace?: string; 33 | } 34 | 35 | /** 36 | * Configuration for the apply function. 37 | */ 38 | export type ApplyCfg = { 39 | /** 40 | * Force the apply to be a create. 41 | */ 42 | force?: boolean; 43 | }; 44 | 45 | export enum FetchMethods { 46 | APPLY = "APPLY", 47 | DELETE = "DELETE", 48 | GET = "GET", 49 | LOG = "LOG", 50 | PATCH = "PATCH", 51 | PATCH_STATUS = "PATCH_STATUS", 52 | POST = "POST", 53 | PUT = "PUT", 54 | WATCH = "WATCH", 55 | } 56 | -------------------------------------------------------------------------------- /src/fluent/types.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { KubernetesListObject, KubernetesObject } from "@kubernetes/client-node"; 5 | import { Operation } from "fast-json-patch"; 6 | import type { PartialDeep } from "type-fest"; 7 | import { GenericClass } from "../types.js"; 8 | import { WatchCfg, Watcher } from "./watch.js"; 9 | import https from "https"; 10 | import { SecureClientSessionOptions } from "http2"; 11 | import { WatchAction, FetchMethods, ApplyCfg } from "./shared-types.js"; 12 | /* 13 | * Watch Class Type - Used in Pepr Watch Processor 14 | */ 15 | export type WatcherType = Watcher; 16 | 17 | /** 18 | * Agent options for the the http2Watch 19 | */ 20 | export type AgentOptions = Pick< 21 | SecureClientSessionOptions, 22 | "ca" | "cert" | "key" | "rejectUnauthorized" 23 | >; 24 | 25 | /** 26 | * Options for the http2Watch 27 | */ 28 | export interface Options { 29 | agent?: https.Agent & { options?: AgentOptions }; 30 | } 31 | 32 | /** 33 | * Get the resource or resources matching the filters. 34 | * If no filters are specified, all resources will be returned. 35 | * If a name is specified, only a single resource will be returned. 36 | * 37 | * @param name - (optional) the name of the resource to get 38 | * @returns the resource or list of resources 39 | */ 40 | export type GetFunction = { 41 | (): Promise>; 42 | (name: string): Promise; 43 | }; 44 | 45 | export type K8sFilteredActions = { 46 | /** 47 | * Gets the logs. 48 | * 49 | * @param name - the name of the Object to get logs from 50 | * @returns array of logs 51 | */ 52 | Logs: (name: string) => Promise; 53 | /** 54 | * Get the resource or resources matching the filters. 55 | * If no filters are specified, all resources will be returned. 56 | * If a name is specified, only a single resource will be returned. 57 | */ 58 | Get: GetFunction; 59 | 60 | /** 61 | * Delete the resource matching the filters. 62 | * 63 | * @param filter - the resource or resource name to delete 64 | */ 65 | Delete: (filter?: K | string) => Promise; 66 | 67 | /** 68 | * Evict the resource matching the filters. 69 | * 70 | * @param filter - the resource or resource name to evict 71 | */ 72 | Evict: (filter?: K | string) => Promise; 73 | 74 | /** 75 | * Watch the resource matching the filters. 76 | * 77 | * @param callback - the callback function to call when an event occurs 78 | * @param watchCfg - (optional) watch configuration 79 | * @returns a watch controller 80 | */ 81 | Watch: (callback: WatchAction, watchCfg?: WatchCfg) => Watcher; 82 | }; 83 | 84 | export type K8sUnfilteredActions = { 85 | /** 86 | * Perform a server-side apply of the provided K8s resource. 87 | * 88 | * @param resource - the resource to apply 89 | * @param applyCfg - (optional) apply configuration 90 | * @returns the applied resource 91 | */ 92 | Apply: (resource: PartialDeep, applyCfg?: ApplyCfg) => Promise; 93 | 94 | /** 95 | * Create the provided K8s resource or throw an error if it already exists. 96 | * 97 | * @param resource - the resource to create 98 | * @returns the created resource 99 | */ 100 | Create: (resource: K) => Promise; 101 | 102 | /** 103 | * Advanced JSON Patch operations for when Server Side Apply, K8s().Apply(), is insufficient. 104 | * 105 | * Note: Throws an error on an empty list of patch operations. 106 | * 107 | * @param payload The patch operations to run 108 | * @returns The patched resource 109 | */ 110 | Patch: (payload: Operation[]) => Promise; 111 | 112 | /** 113 | * Patch the status of the provided K8s resource. Note this is a special case of the Patch method that 114 | * only allows patching the status subresource. This can be used in Operator reconciliation loops to 115 | * update the status of a resource without triggering a new Generation of the resource. 116 | * 117 | * See https://stackoverflow.com/q/47100389/467373 for more details. 118 | * 119 | * IMPORTANT: This method will throw a 404 error if the resource does not have a status subresource defined. 120 | * 121 | * @param resource - the resource to patch 122 | * @returns the patched resource 123 | */ 124 | PatchStatus: (resource: PartialDeep) => Promise; 125 | 126 | /** 127 | * Perform a raw GET request to the Kubernetes API. This is useful for calling endpoints that are not supported by the fluent API. 128 | * This command mirrors the `kubectl get --raw` command. 129 | * 130 | * E.g. 131 | * 132 | * ```ts 133 | * import { V1APIGroup } from "@kubernetes/client-node"; 134 | * 135 | * K8s(V1APIGroup).Raw("/api") 136 | * ``` 137 | * 138 | * will call the `/api` endpoint and is equivalent to `kubectl get --raw /api`. 139 | * 140 | * @param url the URL to call (e.g. /api) 141 | * @returns 142 | */ 143 | Raw: (url: string, method?: FetchMethods) => Promise; 144 | }; 145 | 146 | export type K8sWithFilters = K8sFilteredActions< 147 | T, 148 | K 149 | > & { 150 | /** 151 | * Filter the query by the given field. 152 | * Note multiple calls to this method will result in an AND condition. e.g. 153 | * 154 | * ```ts 155 | * K8s(kind.Deployment) 156 | * .WithField("metadata.name", "bar") 157 | * .WithField("metadata.namespace", "qux") 158 | * .Delete(...) 159 | * ``` 160 | * 161 | * Will only delete the Deployment if it has the `metadata.name=bar` and `metadata.namespace=qux` fields. 162 | * Not all fields are supported, see https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/#supported-fields, 163 | * but Typescript will limit to only fields that exist on the resource. 164 | * 165 | * @param key - the field key 166 | * @param value - the field value 167 | * @returns the fluent API 168 | */ 169 | WithField:

>(key: P, value: string) => K8sWithFilters; 170 | 171 | /** 172 | * Filter the query by the given label. If no value is specified, the label simply must exist. 173 | * Note multiple calls to this method will result in an AND condition. e.g. 174 | * 175 | * ```ts 176 | * K8s(kind.Deployment) 177 | * .WithLabel("foo", "bar") 178 | * .WithLabel("baz", "qux") 179 | * .WithLabel("quux") 180 | * .Delete(...) 181 | * ``` 182 | * 183 | * Will only delete the Deployment if it has the`foo=bar` and `baz=qux` labels and the `quux` label exists. 184 | * 185 | * @param key - the label key 186 | * @param value - the label value 187 | * @returns the fluent API 188 | */ 189 | WithLabel: (key: string, value?: string) => K8sWithFilters; 190 | }; 191 | 192 | export type K8sInit = K8sWithFilters & 193 | K8sUnfilteredActions & { 194 | /** 195 | * Set the namespace filter. 196 | * 197 | * @param namespace - the namespace to filter on 198 | * @returns the fluent API 199 | */ 200 | InNamespace: (namespace: string) => K8sWithFilters; 201 | }; 202 | 203 | // Special types to handle the recursive keyof typescript lookup 204 | type Join = K extends string | number 205 | ? P extends string | number 206 | ? `${K}${"" extends P ? "" : "."}${P}` 207 | : never 208 | : never; 209 | 210 | export type Paths = [D] extends [never] 211 | ? never 212 | : T extends object 213 | ? { 214 | [K in keyof T]-?: K extends string | number ? `${K}` | Join> : never; 215 | }[keyof T] 216 | : ""; 217 | -------------------------------------------------------------------------------- /src/fluent/utils.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { KubeConfig, PatchStrategy } from "@kubernetes/client-node"; 5 | import { RequestInit } from "node-fetch"; 6 | import { URL } from "url"; 7 | import { Agent, Dispatcher } from "undici"; 8 | import { Agent as httpsAgent } from "https"; 9 | import { fetch } from "../fetch.js"; 10 | import { modelToGroupVersionKind } from "../kinds.js"; 11 | import { GenericClass } from "../types.js"; 12 | import { ApplyCfg, Filters, K8sConfigPromise, FetchMethods } from "./shared-types.js"; 13 | import fs from "fs"; 14 | import { V1Eviction as Eviction } from "@kubernetes/client-node"; 15 | const SSA_CONTENT_TYPE = "application/apply-patch+yaml"; 16 | const K8S_SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"; 17 | 18 | /** 19 | * Get the headers for a request 20 | * 21 | * @param token - the token from @kubernetes/client-node 22 | * @returns the headers for undici 23 | */ 24 | export async function getHeaders(token?: string | null): Promise> { 25 | let saToken: string | null = ""; 26 | if (!token) { 27 | saToken = await getToken(); 28 | } 29 | 30 | const headers: Record = { 31 | "Content-Type": "application/json", 32 | "User-Agent": "kubernetes-fluent-client", 33 | }; 34 | 35 | if (token) { 36 | headers["Authorization"] = `Bearer ${token}`; 37 | } else if (saToken) { 38 | headers["Authorization"] = `Bearer ${saToken}`; 39 | } 40 | 41 | return headers; 42 | } 43 | 44 | /** 45 | * Get the agent for a request 46 | * 47 | * @param opts - the request options from node-fetch 48 | * @returns the agent for undici 49 | */ 50 | export function getHTTPSAgent(opts: RequestInit): Dispatcher | undefined { 51 | // In cluster there will be agent - testing or dev no 52 | const agentOptions = 53 | opts.agent instanceof httpsAgent 54 | ? { 55 | ca: opts.agent.options.ca, 56 | cert: opts.agent.options.cert, 57 | key: opts.agent.options.key, 58 | } 59 | : { 60 | ca: undefined, 61 | cert: undefined, 62 | key: undefined, 63 | }; 64 | 65 | return new Agent({ 66 | keepAliveMaxTimeout: 600000, 67 | keepAliveTimeout: 600000, 68 | bodyTimeout: 0, 69 | connect: agentOptions, 70 | }); 71 | } 72 | /** 73 | * Read the serviceAccount Token 74 | * 75 | * @returns token or null 76 | */ 77 | export async function getToken(): Promise { 78 | try { 79 | return (await fs.promises.readFile(K8S_SA_TOKEN_PATH, "utf8")).trim(); 80 | } catch { 81 | return null; 82 | } 83 | } 84 | /** 85 | * Generate a path to a Kubernetes resource 86 | * 87 | * @param serverUrl - the URL of the Kubernetes API server 88 | * @param model - the model to use for the API 89 | * @param filters - (optional) filter overrides, can also be chained 90 | * @param excludeName - (optional) exclude the name from the path 91 | * @returns the path to the resource 92 | */ 93 | export function pathBuilder( 94 | serverUrl: string, 95 | model: T, 96 | filters: Filters, 97 | excludeName = false, 98 | ) { 99 | const matchedKind = filters.kindOverride || modelToGroupVersionKind(model.name); 100 | 101 | // If the kind is not specified and the model is not a KubernetesObject, throw an error 102 | if (!matchedKind) { 103 | throw new Error(`Kind not specified for ${model.name}`); 104 | } 105 | 106 | // Use the plural property if it exists, otherwise use lowercase kind + s 107 | const plural = matchedKind.plural || `${matchedKind.kind.toLowerCase()}s`; 108 | 109 | let base = "/api/v1"; 110 | 111 | // If the kind is not in the core group, add the group and version to the path 112 | if (matchedKind.group) { 113 | if (!matchedKind.version) { 114 | throw new Error(`Version not specified for ${model.name}`); 115 | } 116 | 117 | base = `/apis/${matchedKind.group}/${matchedKind.version}`; 118 | } 119 | 120 | // Namespaced paths require a namespace prefix 121 | const namespace = filters.namespace ? `namespaces/${filters.namespace}` : ""; 122 | 123 | // Name should not be included in some paths 124 | const name = excludeName ? "" : filters.name; 125 | 126 | // Build the complete path to the resource 127 | const path = [base, namespace, plural, name].filter(Boolean).join("/"); 128 | 129 | // Generate the URL object 130 | const url = new URL(path, serverUrl); 131 | 132 | // Add field selectors to the query params 133 | if (filters.fields) { 134 | const fieldSelector = Object.entries(filters.fields) 135 | .map(([key, value]) => `${key}=${value}`) 136 | .join(","); 137 | 138 | url.searchParams.set("fieldSelector", fieldSelector); 139 | } 140 | 141 | // Add label selectors to the query params 142 | if (filters.labels) { 143 | const labelSelector = Object.entries(filters.labels) 144 | // Exists set-based operators only include the key 145 | // See https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#set-based-requirement 146 | .map(([key, value]) => (value ? `${key}=${value}` : key)) 147 | .join(","); 148 | 149 | url.searchParams.set("labelSelector", labelSelector); 150 | } 151 | 152 | return url; 153 | } 154 | 155 | /** 156 | * Sets up the kubeconfig and https agent for a request 157 | * 158 | * A few notes: 159 | * - The kubeconfig is loaded from the default location, and can check for in-cluster config 160 | * - We have to create an agent to handle the TLS connection (for the custom CA + mTLS in some cases) 161 | * - The K8s lib uses request instead of node-fetch today so the object is slightly different 162 | * 163 | * @param method - the HTTP method to use 164 | * @returns the fetch options and server URL 165 | */ 166 | export async function k8sCfg(method: FetchMethods): K8sConfigPromise { 167 | const kubeConfig = new KubeConfig(); 168 | kubeConfig.loadFromDefault(); 169 | 170 | const cluster = kubeConfig.getCurrentCluster(); 171 | if (!cluster) { 172 | throw new Error("No currently active cluster"); 173 | } 174 | 175 | // Get TLS Options 176 | const opts = await kubeConfig.applyToFetchOptions({}); 177 | 178 | // Extract the headers from the options object 179 | const symbols = Object.getOwnPropertySymbols(opts.headers); 180 | const headersMap = symbols 181 | .map(symbol => Object.getOwnPropertyDescriptor(opts.headers, symbol)?.value) 182 | .find(value => typeof value === "object" && value !== null) as 183 | | Record 184 | | undefined; 185 | 186 | // Extract the Authorization header 187 | const extractedHeaders: Record = { 188 | Authorization: headersMap?.["Authorization"]?.[0]?.split(" ")[1], 189 | }; 190 | 191 | const undiciRequestUnit = { 192 | headers: await getHeaders(extractedHeaders["Authorization"]), 193 | method, 194 | dispatcher: getHTTPSAgent(opts), 195 | }; 196 | return { opts: undiciRequestUnit, serverUrl: cluster.server }; 197 | } 198 | 199 | const isEvictionPayload = (payload: unknown): payload is Eviction => 200 | payload !== null && 201 | payload !== undefined && 202 | typeof payload === "object" && 203 | "kind" in payload && 204 | (payload as { kind: string }).kind === "Eviction"; 205 | 206 | /** 207 | * Prepares and mutates the request options and URL for Kubernetes PATCH or APPLY operations. 208 | * 209 | * This function modifies the request's HTTP method, headers, and URL based on the operation type. 210 | * It handles the following: 211 | * 212 | * - `PATCH_STATUS`: Converts the method to `PATCH`, appends `/status` to the path, sets merge patch headers, 213 | * and rewrites the payload to contain only the `status` field. 214 | * - `PATCH`: Sets the content type to `application/json-patch+json`. 215 | * - `APPLY`: Converts the method to `PATCH`, sets server-side apply headers, and updates the query string 216 | * with field manager and force options. 217 | * 218 | * @template K 219 | * @param methodPayload - The original method and payload. May be mutated if `PATCH_STATUS` is used. 220 | * @param opts - The request options. 221 | * @param opts.method - The HTTP method (e.g. `PATCH`, `APPLY`, or `PATCH_STATUS`). 222 | * @param opts.headers - The headers to be updated with the correct content type. 223 | * @param url - The URL to mutate with subresource path or query parameters. 224 | * @param applyCfg - Server-side apply options, such as `force`. 225 | */ 226 | export function prepareRequestOptions( 227 | methodPayload: MethodPayload, 228 | opts: { method?: string; headers?: Record }, 229 | url: URL, 230 | applyCfg: ApplyCfg, 231 | ): void { 232 | switch (opts.method) { 233 | // PATCH_STATUS is a special case that uses the PATCH method on status subresources 234 | case "PATCH_STATUS": 235 | opts.method = "PATCH"; 236 | url.pathname = `${url.pathname}/status`; 237 | (opts.headers as Record)["Content-Type"] = PatchStrategy.MergePatch; 238 | methodPayload.payload = { status: (methodPayload.payload as { status: unknown }).status }; 239 | break; 240 | 241 | case "PATCH": 242 | (opts.headers as Record)["Content-Type"] = PatchStrategy.JsonPatch; 243 | break; 244 | 245 | case "APPLY": 246 | (opts.headers as Record)["Content-Type"] = SSA_CONTENT_TYPE; 247 | opts.method = "PATCH"; 248 | url.searchParams.set("fieldManager", "pepr"); 249 | url.searchParams.set("fieldValidation", "Strict"); 250 | url.searchParams.set("force", applyCfg.force ? "true" : "false"); 251 | break; 252 | } 253 | } 254 | 255 | export type MethodPayload = { 256 | method: FetchMethods; 257 | payload?: K | unknown; 258 | }; 259 | 260 | /** 261 | * Execute a request against the Kubernetes API server. 262 | * 263 | * @param model - the model to use for the API 264 | * @param filters - (optional) filter overrides, can also be chained 265 | * @param methodPayload - method and payload for the request 266 | * @param applyCfg - (optional) configuration for the apply method 267 | * 268 | * @returns the parsed JSON response 269 | */ 270 | export async function k8sExec( 271 | model: T, 272 | filters: Filters, 273 | methodPayload: MethodPayload, 274 | applyCfg: ApplyCfg = { force: false }, 275 | ) { 276 | const reconstruct = async (method: FetchMethods): K8sConfigPromise => { 277 | const configMethod = method === FetchMethods.LOG ? FetchMethods.GET : method; 278 | const { opts, serverUrl } = await k8sCfg(configMethod); 279 | 280 | // Build the base path once, using excludeName only for standard POST requests 281 | const shouldExcludeName = 282 | method === FetchMethods.POST && 283 | !(methodPayload.payload && isEvictionPayload(methodPayload.payload)); 284 | const baseUrl = pathBuilder(serverUrl.toString(), model, filters, shouldExcludeName); 285 | 286 | // Append appropriate subresource paths 287 | if (methodPayload.payload && isEvictionPayload(methodPayload.payload)) { 288 | baseUrl.pathname = `${baseUrl.pathname}/eviction`; 289 | } else if (method === FetchMethods.LOG) { 290 | baseUrl.pathname = `${baseUrl.pathname}/log`; 291 | } 292 | 293 | return { 294 | serverUrl: baseUrl, 295 | opts, 296 | }; 297 | }; 298 | 299 | const { opts, serverUrl } = await reconstruct(methodPayload.method); 300 | const url: URL = serverUrl instanceof URL ? serverUrl : new URL(serverUrl); 301 | 302 | prepareRequestOptions( 303 | methodPayload, 304 | opts as { method?: string; headers?: Record }, 305 | url, 306 | applyCfg, 307 | ); 308 | 309 | if (methodPayload.payload) { 310 | opts.body = JSON.stringify(methodPayload.payload); 311 | } 312 | const resp = await fetch(url, opts); 313 | 314 | if (resp.ok) { 315 | return resp.data; 316 | } 317 | 318 | if (resp.status === 404 && methodPayload.method === FetchMethods.PATCH_STATUS) { 319 | resp.statusText = 320 | "Not Found" + " (NOTE: This error is expected if the resource has no status subresource)"; 321 | } 322 | 323 | throw resp; 324 | } 325 | -------------------------------------------------------------------------------- /src/fluent/watch.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 | import { Interceptable, MockAgent, setGlobalDispatcher } from "undici"; 5 | import { PassThrough } from "stream"; 6 | import { K8s } from "./index.js"; 7 | import { WatchEvent, kind } from "../index.js"; 8 | import { WatchPhase } from "./shared-types.js"; 9 | import { Watcher } from "./watch.js"; 10 | 11 | let mockClient: Interceptable; 12 | describe("Watcher", () => { 13 | const evtMock = vi.fn<(update: kind.Pod, phase: WatchPhase) => void>(); 14 | const errMock = vi.fn<(err: Error) => void>(); 15 | 16 | const setupAndStartWatcher = (eventType: WatchEvent, handler: (...args: any[]) => void) => { 17 | watcher.events.on(eventType, handler); 18 | watcher.start().catch(errMock); 19 | }; 20 | 21 | let watcher: Watcher; 22 | let mockAgent: MockAgent; 23 | 24 | beforeEach(() => { 25 | vi.resetAllMocks(); 26 | 27 | // Setup MockAgent from undici 28 | mockAgent = new MockAgent(); 29 | mockAgent.disableNetConnect(); 30 | setGlobalDispatcher(mockAgent); 31 | 32 | mockClient = mockAgent.get("https://jest-test:8080"); 33 | 34 | // Mock list operation 35 | mockClient 36 | .intercept({ 37 | path: "/api/v1/pods", 38 | method: "GET", 39 | }) 40 | .reply(200, { 41 | kind: "PodList", 42 | apiVersion: "v1", 43 | metadata: { 44 | resourceVersion: "10", 45 | }, 46 | items: [createMockPod(`pod-0`, `1`)], 47 | }); 48 | 49 | mockClient 50 | .intercept({ 51 | path: "/api/v1/pods?watch=true&resourceVersion=10", 52 | method: "GET", 53 | }) 54 | // @ts-expect-error - we are using the response.body as Readable stream 55 | .reply(200, (_, res) => { 56 | const stream = new PassThrough(); 57 | 58 | const resources = [ 59 | { type: "ADDED", object: createMockPod(`pod-0`, `1`) }, 60 | { type: "MODIFIED", object: createMockPod(`pod-0`, `2`) }, 61 | ]; 62 | 63 | resources.forEach(resource => { 64 | stream.write(JSON.stringify(resource) + "\n"); 65 | }); 66 | 67 | stream.end(); 68 | res.body = stream; 69 | }); 70 | }); 71 | 72 | afterEach(async () => { 73 | watcher.close(); 74 | try { 75 | await mockAgent.close(); 76 | } catch (error) { 77 | console.error("Error closing mock agent", error); 78 | } 79 | }); 80 | 81 | it("should watch named resources", () => { 82 | mockClient 83 | .intercept({ 84 | path: "/api/v1/namespaces/tester/pods?fieldSelector=metadata.name=demo", 85 | method: "GET", 86 | }) 87 | .reply(200, createMockPod(`demo`, `15`)); 88 | 89 | mockClient 90 | .intercept({ 91 | path: "/api/v1/namespaces/tester/pods?watch=true&fieldSelector=metadata.name=demo&resourceVersion=15", 92 | method: "GET", 93 | }) 94 | .reply(200); 95 | 96 | watcher = K8s(kind.Pod, { name: "demo" }).InNamespace("tester").Watch(evtMock); 97 | 98 | setupAndStartWatcher(WatchEvent.CONNECT, () => {}); 99 | }); 100 | 101 | it("should handle resource version is too old", () => { 102 | mockClient 103 | .intercept({ 104 | path: "/api/v1/pods", 105 | method: "GET", 106 | }) 107 | .reply(200, { 108 | kind: "PodList", 109 | apiVersion: "v1", 110 | metadata: { 111 | resourceVersion: "25", 112 | }, 113 | items: [createMockPod(`pod-0`, `1`)], 114 | }); 115 | 116 | mockClient 117 | .intercept({ 118 | path: "/api/v1/pods?watch=true&resourceVersion=25", 119 | method: "GET", 120 | }) 121 | // @ts-expect-error - need res for the body 122 | .reply(200, (_, res) => { 123 | const stream = new PassThrough(); 124 | stream.write( 125 | JSON.stringify({ 126 | type: "ERROR", 127 | object: { 128 | kind: "Status", 129 | apiVersion: "v1", 130 | metadata: {}, 131 | status: "Failure", 132 | message: "too old resource version: 123 (391079)", 133 | reason: "Gone", 134 | code: 410, 135 | }, 136 | }) + "\n", 137 | ); 138 | 139 | stream.end(); 140 | res.body = stream; 141 | }); 142 | 143 | watcher = K8s(kind.Pod).Watch(evtMock); 144 | 145 | setupAndStartWatcher(WatchEvent.OLD_RESOURCE_VERSION, res => { 146 | expect(res).toEqual("25"); 147 | }); 148 | }); 149 | 150 | it("should call the event handler for each event", () => { 151 | watcher = K8s(kind.Pod).Watch(evt => { 152 | expect(evt.metadata?.name).toEqual(`pod-0`); 153 | }); 154 | 155 | watcher.start().catch(errMock); 156 | }); 157 | 158 | it("should handle the CONNECT event", () => { 159 | watcher = K8s(kind.Pod).Watch(evtMock, { 160 | resyncDelaySec: 1, 161 | }); 162 | setupAndStartWatcher(WatchEvent.CONNECT, () => {}); 163 | }); 164 | 165 | it("should handle the DATA event", () => { 166 | watcher = K8s(kind.Pod).Watch(evtMock, { 167 | resyncDelaySec: 1, 168 | }); 169 | setupAndStartWatcher(WatchEvent.DATA, (pod, phase) => { 170 | expect(pod.metadata?.name).toEqual(`pod-0`); 171 | expect(phase).toEqual(WatchPhase.Added); 172 | }); 173 | }); 174 | 175 | it("should handle the RECONNECT event on an error", () => { 176 | mockClient = mockAgent.get("https://jest-test:8080"); 177 | 178 | mockClient 179 | .intercept({ 180 | path: "/api/v1/pods", 181 | method: "GET", 182 | }) 183 | .reply(200, { 184 | kind: "PodList", 185 | apiVersion: "v1", 186 | metadata: { 187 | resourceVersion: "65", 188 | }, 189 | items: [createMockPod(`pod-0`, `1`)], 190 | }); 191 | 192 | mockClient 193 | .intercept({ 194 | path: "/api/v1/pods?watch=true&resourceVersion=65", 195 | method: "GET", 196 | }) 197 | .replyWithError(new Error("Something bad happened")); 198 | 199 | watcher = K8s(kind.Pod).Watch(evtMock, { 200 | resyncDelaySec: 0.01, 201 | }); 202 | 203 | setupAndStartWatcher(WatchEvent.RECONNECT, count => { 204 | expect(count).toEqual(1); 205 | }); 206 | }); 207 | 208 | it("should perform a resync after the resync interval", () => { 209 | watcher = K8s(kind.Pod).Watch(evtMock, { 210 | resyncDelaySec: 0.01, 211 | lastSeenLimitSeconds: 0.01, 212 | }); 213 | 214 | setupAndStartWatcher(WatchEvent.RECONNECT, count => { 215 | expect(count).toEqual(1); 216 | }); 217 | }); 218 | 219 | it("should handle the GIVE_UP event", () => { 220 | mockClient 221 | .intercept({ 222 | path: "/api/v1/pods", 223 | method: "GET", 224 | }) 225 | .reply(200, { 226 | kind: "PodList", 227 | apiVersion: "v1", 228 | metadata: { 229 | resourceVersion: "75", 230 | }, 231 | items: [createMockPod(`pod-0`, `1`)], 232 | }); 233 | 234 | mockClient 235 | .intercept({ 236 | path: "/api/v1/pods?watch=true&resourceVersion=75", 237 | method: "GET", 238 | }) 239 | .replyWithError(new Error("Something bad happened")); 240 | 241 | watcher = K8s(kind.Pod).Watch(evtMock, { 242 | resyncFailureMax: 1, 243 | resyncDelaySec: 0.01, 244 | lastSeenLimitSeconds: 1, 245 | }); 246 | 247 | setupAndStartWatcher(WatchEvent.GIVE_UP, error => { 248 | expect(error.message).toContain("Retry limit (1) exceeded, giving up"); 249 | }); 250 | }); 251 | 252 | it.skip("should handle the NETWORK_ERROR event", () => { 253 | mockClient 254 | .intercept({ 255 | path: "/api/v1/pods", 256 | method: "GET", 257 | }) 258 | .reply(200, { 259 | kind: "PodList", 260 | apiVersion: "v1", 261 | metadata: { 262 | resourceVersion: "45", 263 | }, 264 | items: [createMockPod(`pod-0`, `1`)], 265 | }); 266 | 267 | mockClient 268 | .intercept({ 269 | path: "/api/v1/pods?watch=true&resourceVersion=45", 270 | method: "GET", 271 | }) 272 | .replyWithError(new Error("Something bad happened")); 273 | 274 | watcher = K8s(kind.Pod).Watch(evtMock, { 275 | resyncDelaySec: 1, 276 | }); 277 | 278 | setupAndStartWatcher(WatchEvent.NETWORK_ERROR, error => { 279 | expect(error.message).toEqual( 280 | "request to https://jest-test:8080/api/v1/pods?watch=true&resourceVersion=45 failed, reason: Something bad happened", 281 | ); 282 | }); 283 | }); 284 | }); 285 | 286 | /** 287 | * Creates a mock pod object 288 | * 289 | * @param name The name of the pod 290 | * @param resourceVersion The resource version of the pod 291 | * @returns A mock pod object 292 | */ 293 | function createMockPod(name: string, resourceVersion: string): kind.Pod { 294 | return { 295 | kind: "Pod", 296 | apiVersion: "v1", 297 | metadata: { 298 | name: name, 299 | resourceVersion: resourceVersion, 300 | uid: "random-uid", 301 | }, 302 | spec: { 303 | containers: [ 304 | { 305 | name: "nginx", 306 | image: "nginx:1.14.2", 307 | ports: [ 308 | { 309 | containerPort: 80, 310 | protocol: "TCP", 311 | }, 312 | ], 313 | }, 314 | ], 315 | }, 316 | status: { 317 | // ... pod status 318 | }, 319 | }; 320 | } 321 | -------------------------------------------------------------------------------- /src/generate.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { loadAllYaml } from "@kubernetes/client-node"; 5 | import * as fs from "fs"; 6 | import * as path from "path"; 7 | import { 8 | FetchingJSONSchemaStore, 9 | InputData, 10 | JSONSchemaInput, 11 | TargetLanguage, 12 | quicktype, 13 | } from "quicktype-core"; 14 | 15 | import { fetch } from "./fetch.js"; 16 | import { K8s } from "./fluent/index.js"; 17 | import { CustomResourceDefinition } from "./upstream.js"; 18 | import { LogFn } from "./types.js"; 19 | export type QuicktypeLang = Parameters[0]["lang"]; 20 | export interface GenerateOptions { 21 | source: string; // URL, file path, or K8s CRD name 22 | directory?: string; // Output directory path 23 | plain?: boolean; // Disable fluent client wrapping 24 | language: QuicktypeLang; // Language for type generation (default: "ts") 25 | npmPackage?: string; // Override NPM package 26 | logFn: LogFn; // Log function callback 27 | noPost?: boolean; // Enable/disable post-processing 28 | } 29 | 30 | /** 31 | * Converts a CustomResourceDefinition to TypeScript types 32 | * 33 | * @param crd - The CustomResourceDefinition object to convert. 34 | * @param opts - The options for generating the TypeScript types. 35 | * @returns A promise that resolves to a record of generated TypeScript types. 36 | */ 37 | export async function convertCRDtoTS( 38 | crd: CustomResourceDefinition, 39 | opts: GenerateOptions, 40 | ): Promise< 41 | { 42 | results: Record; 43 | name: string; 44 | crd: CustomResourceDefinition; 45 | version: string; 46 | }[] 47 | > { 48 | const name = crd.spec.names.kind; 49 | const results: Record = {}; 50 | const output: { 51 | results: Record; 52 | name: string; 53 | crd: CustomResourceDefinition; 54 | version: string; 55 | }[] = []; 56 | 57 | // Check for missing versions or empty schema 58 | if (!crd.spec.versions || crd.spec.versions.length === 0) { 59 | opts.logFn(`Skipping ${crd.metadata?.name}, it does not appear to be a CRD`); 60 | return []; 61 | } 62 | 63 | // Iterate through each version of the CRD 64 | for (const match of crd.spec.versions) { 65 | if (!match.schema?.openAPIV3Schema) { 66 | opts.logFn( 67 | `Skipping ${crd.metadata?.name ?? "unknown"}, it does not appear to have a valid schema`, 68 | ); 69 | continue; 70 | } 71 | 72 | const schema = JSON.stringify(match.schema.openAPIV3Schema); 73 | opts.logFn(`- Generating ${crd.spec.group}/${match.name} types for ${name}`); 74 | 75 | const inputData = await prepareInputData(name, schema); 76 | const generatedTypes = await generateTypes(inputData, opts); 77 | 78 | const fileName = `${name.toLowerCase()}-${match.name.toLowerCase()}`; 79 | writeGeneratedFile(fileName, opts.directory || "", generatedTypes, opts.language || "ts"); 80 | 81 | results[fileName] = generatedTypes; 82 | output.push({ results, name, crd, version: match.name }); 83 | } 84 | 85 | return output; 86 | } 87 | 88 | /** 89 | * Prepares the input data for quicktype from the provided schema. 90 | * 91 | * @param name - The name of the schema. 92 | * @param schema - The JSON schema as a string. 93 | * @returns A promise that resolves to the input data for quicktype. 94 | */ 95 | export async function prepareInputData(name: string, schema: string): Promise { 96 | // Create a new JSONSchemaInput 97 | const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); 98 | 99 | // Add the schema to the input 100 | await schemaInput.addSource({ name, schema }); 101 | 102 | // Create a new InputData object 103 | const inputData = new InputData(); 104 | inputData.addInput(schemaInput); 105 | 106 | return inputData; 107 | } 108 | 109 | /** 110 | * Generates TypeScript types using quicktype. 111 | * 112 | * @param inputData - The input data for quicktype. 113 | * @param opts - The options for generating the TypeScript types. 114 | * @returns A promise that resolves to an array of generated TypeScript type lines. 115 | */ 116 | export async function generateTypes( 117 | inputData: InputData, 118 | opts: GenerateOptions, 119 | ): Promise { 120 | // Generate the types 121 | const out = await quicktype({ 122 | inputData, 123 | lang: opts.language, 124 | rendererOptions: { "just-types": "true" }, 125 | }); 126 | 127 | return out.lines; 128 | } 129 | 130 | /** 131 | * Writes the processed lines to the output file. 132 | * 133 | * @param fileName - The name of the file to write. 134 | * @param directory - The directory where the file will be written. 135 | * @param content - The content to write to the file. 136 | * @param language - The programming language of the file. 137 | */ 138 | export function writeGeneratedFile( 139 | fileName: string, 140 | directory: string, 141 | content: string[], 142 | language: string | TargetLanguage, 143 | ): void { 144 | language = language || "ts"; 145 | if (!directory) return; 146 | 147 | const filePath = path.join(directory, `${fileName}.${language}`); 148 | fs.mkdirSync(directory, { recursive: true }); 149 | fs.writeFileSync(filePath, content.join("\n")); 150 | } 151 | 152 | /** 153 | * Reads or fetches a CustomResourceDefinition from a file, URL, or the cluster. 154 | * 155 | * @param opts - The options for generating the TypeScript types. 156 | * @returns A promise that resolves to an array of CustomResourceDefinition objects. 157 | */ 158 | export async function readOrFetchCrd(opts: GenerateOptions): Promise { 159 | try { 160 | const filePath = resolveFilePath(opts.source); 161 | 162 | if (fs.existsSync(filePath)) { 163 | opts.logFn(`Attempting to load ${opts.source} as a local file`); 164 | const content = fs.readFileSync(filePath, "utf8"); 165 | return loadAllYaml(content) as CustomResourceDefinition[]; 166 | } 167 | 168 | const url = tryParseUrl(opts.source); 169 | if (url) { 170 | opts.logFn(`Attempting to load ${opts.source} as a URL`); 171 | const { ok, data } = await fetch(url.href); 172 | if (ok) { 173 | return loadAllYaml(data) as CustomResourceDefinition[]; 174 | } 175 | } 176 | 177 | // Fallback to Kubernetes cluster 178 | opts.logFn(`Attempting to read ${opts.source} from the Kubernetes cluster`); 179 | return [await K8s(CustomResourceDefinition).Get(opts.source)]; 180 | } catch (error) { 181 | opts.logFn(`Error loading CRD: ${error.message}`); 182 | throw new Error(`Failed to read ${opts.source} as a file, URL, or Kubernetes CRD`); 183 | } 184 | } 185 | 186 | /** 187 | * Resolves the source file path, treating relative paths as local files. 188 | * 189 | * @param source - The source path to resolve. 190 | * @returns The resolved file path. 191 | */ 192 | export function resolveFilePath(source: string): string { 193 | return source.startsWith("/") ? source : path.join(process.cwd(), source); 194 | } 195 | 196 | /** 197 | * Tries to parse the source as a URL. 198 | * 199 | * @param source - The source string to parse as a URL. 200 | * @returns The parsed URL object or null if parsing fails. 201 | */ 202 | export function tryParseUrl(source: string): URL | null { 203 | try { 204 | return new URL(source); 205 | } catch { 206 | return null; 207 | } 208 | } 209 | 210 | /** 211 | * Main generate function to convert CRDs to TypeScript types. 212 | * 213 | * @param opts - The options for generating the TypeScript types. 214 | * @returns A promise that resolves to a record of generated TypeScript types. 215 | */ 216 | export async function generate(opts: GenerateOptions): Promise< 217 | { 218 | results: Record; 219 | name: string; 220 | crd: CustomResourceDefinition; 221 | version: string; 222 | }[] 223 | > { 224 | const crds = (await readOrFetchCrd(opts)).filter(crd => !!crd); 225 | const allResults: { 226 | results: Record; 227 | name: string; 228 | crd: CustomResourceDefinition; 229 | version: string; 230 | }[] = []; 231 | 232 | opts.logFn(""); 233 | 234 | for (const crd of crds) { 235 | if (crd.kind !== "CustomResourceDefinition" || !crd.spec?.versions?.length) { 236 | opts.logFn(`Skipping ${crd?.metadata?.name}, it does not appear to be a CRD`); 237 | // Ignore empty and non-CRD objects 238 | continue; 239 | } 240 | 241 | allResults.push(...(await convertCRDtoTS(crd, opts))); 242 | } 243 | 244 | if (opts.directory) { 245 | // Notify the user that the files have been generated 246 | opts.logFn(`\n✅ Generated ${allResults.length} files in the ${opts.directory} directory`); 247 | } else { 248 | // Log a message about the number of generated files even when no directory is provided 249 | opts.logFn(`\n✅ Generated ${allResults.length} files`); 250 | } 251 | 252 | return allResults; 253 | } 254 | -------------------------------------------------------------------------------- /src/helpers.test.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { describe, expect, it, test, vi, afterEach } from "vitest"; 5 | 6 | import { fromEnv, hasLogs, waitForCluster } from "./helpers.js"; 7 | 8 | describe("helpers", () => { 9 | test("fromEnv for NodeJS", () => { 10 | expect(() => { 11 | fromEnv("MY_MISSING_ENV_VAR"); 12 | }).toThrowError("Environment variable MY_MISSING_ENV_VAR is not set"); 13 | 14 | process.env.MY_ENV_VAR = "my-value"; 15 | expect(fromEnv("MY_ENV_VAR")).toEqual("my-value"); 16 | delete process.env.MY_ENV_VAR; 17 | }); 18 | }); 19 | 20 | describe("Cluster Wait Function", () => { 21 | // Mock the KubeConfig class 22 | vi.mock("@kubernetes/client-node", () => { 23 | return { 24 | KubeConfig: vi.fn().mockImplementation(() => ({ 25 | loadFromDefault: vi.fn(), 26 | getCurrentCluster: vi.fn().mockReturnValue({ 27 | server: "https://jest-test:8080", 28 | }), 29 | })), 30 | }; 31 | }); 32 | 33 | afterEach(() => { 34 | vi.clearAllMocks(); 35 | }); 36 | 37 | it("should resolve if the cluster is already ready", async () => { 38 | const cluster = await waitForCluster(5); 39 | expect(cluster).toEqual({ server: "https://jest-test:8080" }); 40 | }); 41 | }); 42 | 43 | describe("hasLogs function", () => { 44 | it("should return true for known kinds", () => { 45 | expect(hasLogs("Pod")).toBe(true); 46 | expect(hasLogs("DaemonSet")).toBe(true); 47 | expect(hasLogs("ReplicaSet")).toBe(true); 48 | expect(hasLogs("Service")).toBe(true); 49 | expect(hasLogs("StatefulSet")).toBe(true); 50 | expect(hasLogs("Deployment")).toBe(true); 51 | }); 52 | 53 | it("should return false for unknown kinds", () => { 54 | expect(hasLogs("Unknown")).toBe(false); 55 | expect(hasLogs("")).toBe(false); 56 | expect(hasLogs("RandomKind")).toBe(false); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { Cluster, KubeConfig } from "@kubernetes/client-node"; 5 | 6 | declare const Deno: { 7 | env: { 8 | get(name: string): string | undefined; 9 | }; 10 | }; 11 | 12 | /** 13 | * Sleep for a number of seconds. 14 | * 15 | * @param seconds The number of seconds to sleep. 16 | * @returns A promise that resolves after the specified number of seconds. 17 | */ 18 | function sleep(seconds: number): Promise { 19 | return new Promise(resolve => setTimeout(resolve, seconds * 1000)); 20 | } 21 | 22 | /** 23 | * Get an environment variable (Node, Deno or Bun), or throw an error if it's not set. 24 | * 25 | * @example 26 | * const value = fromEnv("MY_ENV_VAR"); 27 | * console.log(value); 28 | * // => "my-value" 29 | * 30 | * @example 31 | * const value = fromEnv("MY_MISSING_ENV_VAR"); 32 | * // => Error: Environment variable MY_MISSING_ENV_VAR is not set 33 | * 34 | * @param name The name of the environment variable to get. 35 | * @returns The value of the environment variable. 36 | * @throws An error if the environment variable is not set. 37 | */ 38 | export function fromEnv(name: string): string { 39 | let envValue: string | undefined; 40 | 41 | // Check for Node.js or Bun 42 | if (typeof process !== "undefined" && typeof process.env !== "undefined") { 43 | envValue = process.env[name]; 44 | } 45 | // Check for Deno 46 | else if (typeof Deno !== "undefined") { 47 | envValue = Deno.env.get(name); 48 | } 49 | // Otherwise, throw an error 50 | else { 51 | throw new Error("Unknown runtime environment"); 52 | } 53 | 54 | if (typeof envValue === "undefined") { 55 | throw new Error(`Environment variable ${name} is not set`); 56 | } 57 | 58 | return envValue; 59 | } 60 | 61 | /** 62 | * Wait for the Kubernetes cluster to be ready. 63 | * 64 | * @param seconds The number of seconds to wait for the cluster to be ready. 65 | * @returns The current cluster. 66 | */ 67 | export async function waitForCluster(seconds = 30): Promise { 68 | const kubeConfig = new KubeConfig(); 69 | kubeConfig.loadFromDefault(); 70 | 71 | const cluster = kubeConfig.getCurrentCluster(); 72 | if (!cluster) { 73 | await sleep(1); 74 | if (seconds > 0) { 75 | return await waitForCluster(seconds - 1); 76 | } else { 77 | throw new Error("Cluster not ready"); 78 | } 79 | } 80 | 81 | return cluster; 82 | } 83 | 84 | /** 85 | * Determines if object has logs. 86 | * 87 | * @param kind The kind of Kubernetes object. 88 | * @returns boolean. 89 | */ 90 | export function hasLogs(kind: string): boolean { 91 | let hasSelector: boolean = false; 92 | switch (kind) { 93 | case "Pod": 94 | hasSelector = true; 95 | break; 96 | case "DaemonSet": 97 | hasSelector = true; 98 | break; 99 | case "ReplicaSet": 100 | hasSelector = true; 101 | break; 102 | case "Service": 103 | hasSelector = true; 104 | break; 105 | case "StatefulSet": 106 | hasSelector = true; 107 | break; 108 | case "Deployment": 109 | hasSelector = true; 110 | break; 111 | } 112 | return hasSelector; 113 | } 114 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import "./patch.js"; 5 | 6 | // Export kinds as a single object 7 | import * as kind from "./upstream.js"; 8 | 9 | /** kind is a collection of K8s types to be used within a K8s call: `K8s(kind.Secret).Apply({})`. */ 10 | export { kind }; 11 | 12 | // Export the node-fetch wrapper 13 | export { fetch } from "./fetch.js"; 14 | 15 | // Export the HTTP status codes 16 | export { StatusCodes as fetchStatus } from "http-status-codes"; 17 | 18 | // Export the Watch Config and Event types 19 | export { WatchCfg, WatchEvent } from "./fluent/watch.js"; 20 | 21 | // Export the fluent API entrypoint 22 | export { K8s } from "./fluent/index.js"; 23 | 24 | // Export helpers for working with K8s types 25 | export { RegisterKind, modelToGroupVersionKind } from "./kinds.js"; 26 | 27 | // Export the GenericKind interface for CRD registration 28 | export { GenericKind } from "./types.js"; 29 | 30 | export * from "./types.js"; 31 | 32 | // Export the upstream raw models 33 | export * as models from "@kubernetes/client-node/dist/gen/models/all.js"; 34 | 35 | export { fromEnv, waitForCluster } from "./helpers.js"; 36 | -------------------------------------------------------------------------------- /src/kinds.test.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { expect, test } from "vitest"; 5 | import { kind, modelToGroupVersionKind } from "./index.js"; 6 | import { RegisterKind } from "./kinds.js"; 7 | import { GroupVersionKind } from "./types.js"; 8 | 9 | const testCases = [ 10 | { 11 | name: kind.Event, 12 | expected: { group: "events.k8s.io", version: "v1", kind: "Event" }, 13 | }, 14 | { 15 | name: kind.CoreEvent, 16 | expected: { group: "", version: "v1", kind: "Event" }, 17 | }, 18 | { 19 | name: kind.ClusterRole, 20 | expected: { group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole" }, 21 | }, 22 | { 23 | name: kind.ClusterRoleBinding, 24 | expected: { group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding" }, 25 | }, 26 | { 27 | name: kind.Role, 28 | expected: { group: "rbac.authorization.k8s.io", version: "v1", kind: "Role" }, 29 | }, 30 | { 31 | name: kind.RoleBinding, 32 | expected: { group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding" }, 33 | }, 34 | { name: kind.Pod, expected: { group: "", version: "v1", kind: "Pod" } }, 35 | { name: kind.Deployment, expected: { group: "apps", version: "v1", kind: "Deployment" } }, 36 | { name: kind.StatefulSet, expected: { group: "apps", version: "v1", kind: "StatefulSet" } }, 37 | { name: kind.DaemonSet, expected: { group: "apps", version: "v1", kind: "DaemonSet" } }, 38 | { name: kind.Job, expected: { group: "batch", version: "v1", kind: "Job" } }, 39 | { name: kind.CronJob, expected: { group: "batch", version: "v1", kind: "CronJob" } }, 40 | { name: kind.ConfigMap, expected: { group: "", version: "v1", kind: "ConfigMap" } }, 41 | { name: kind.Secret, expected: { group: "", version: "v1", kind: "Secret" } }, 42 | { name: kind.Service, expected: { group: "", version: "v1", kind: "Service" } }, 43 | { name: kind.ServiceAccount, expected: { group: "", version: "v1", kind: "ServiceAccount" } }, 44 | { name: kind.Namespace, expected: { group: "", version: "v1", kind: "Namespace" } }, 45 | { 46 | name: kind.HorizontalPodAutoscaler, 47 | expected: { group: "autoscaling", version: "v2", kind: "HorizontalPodAutoscaler" }, 48 | }, 49 | { 50 | name: kind.CustomResourceDefinition, 51 | expected: { group: "apiextensions.k8s.io", version: "v1", kind: "CustomResourceDefinition" }, 52 | }, 53 | { name: kind.Ingress, expected: { group: "networking.k8s.io", version: "v1", kind: "Ingress" } }, 54 | { 55 | name: kind.NetworkPolicy, 56 | expected: { 57 | group: "networking.k8s.io", 58 | version: "v1", 59 | kind: "NetworkPolicy", 60 | plural: "networkpolicies", 61 | }, 62 | }, 63 | { name: kind.Node, expected: { group: "", version: "v1", kind: "Node" } }, 64 | { name: kind.PersistentVolume, expected: { group: "", version: "v1", kind: "PersistentVolume" } }, 65 | { 66 | name: kind.PersistentVolumeClaim, 67 | expected: { group: "", version: "v1", kind: "PersistentVolumeClaim" }, 68 | }, 69 | { name: kind.Pod, expected: { group: "", version: "v1", kind: "Pod" } }, 70 | { 71 | name: kind.PodDisruptionBudget, 72 | expected: { group: "policy", version: "v1", kind: "PodDisruptionBudget" }, 73 | }, 74 | { name: kind.PodTemplate, expected: { group: "", version: "v1", kind: "PodTemplate" } }, 75 | { name: kind.ReplicaSet, expected: { group: "apps", version: "v1", kind: "ReplicaSet" } }, 76 | { 77 | name: kind.ReplicationController, 78 | expected: { group: "", version: "v1", kind: "ReplicationController" }, 79 | }, 80 | { name: kind.ResourceQuota, expected: { group: "", version: "v1", kind: "ResourceQuota" } }, 81 | { 82 | name: kind.RuntimeClass, 83 | expected: { group: "node.k8s.io", version: "v1", kind: "RuntimeClass" }, 84 | }, 85 | { name: kind.Secret, expected: { group: "", version: "v1", kind: "Secret" } }, 86 | { 87 | name: kind.SelfSubjectAccessReview, 88 | expected: { group: "authorization.k8s.io", version: "v1", kind: "SelfSubjectAccessReview" }, 89 | }, 90 | { 91 | name: kind.SelfSubjectRulesReview, 92 | expected: { group: "authorization.k8s.io", version: "v1", kind: "SelfSubjectRulesReview" }, 93 | }, 94 | { name: kind.Service, expected: { group: "", version: "v1", kind: "Service" } }, 95 | { name: kind.ServiceAccount, expected: { group: "", version: "v1", kind: "ServiceAccount" } }, 96 | { name: kind.StatefulSet, expected: { group: "apps", version: "v1", kind: "StatefulSet" } }, 97 | { 98 | name: kind.StorageClass, 99 | expected: { group: "storage.k8s.io", version: "v1", kind: "StorageClass" }, 100 | }, 101 | { 102 | name: kind.SubjectAccessReview, 103 | expected: { group: "authorization.k8s.io", version: "v1", kind: "SubjectAccessReview" }, 104 | }, 105 | { 106 | name: kind.TokenReview, 107 | expected: { group: "authentication.k8s.io", version: "v1", kind: "TokenReview" }, 108 | }, 109 | { 110 | name: kind.ValidatingWebhookConfiguration, 111 | expected: { 112 | group: "admissionregistration.k8s.io", 113 | version: "v1", 114 | kind: "ValidatingWebhookConfiguration", 115 | }, 116 | }, 117 | { 118 | name: kind.VolumeAttachment, 119 | expected: { group: "storage.k8s.io", version: "v1", kind: "VolumeAttachment" }, 120 | }, 121 | { 122 | name: kind.Endpoints, 123 | expected: { group: "", version: "v1", kind: "Endpoints", plural: "endpoints" }, 124 | }, 125 | ]; 126 | 127 | test.each(testCases)( 128 | "should return the correct GroupVersionKind for '%s'", 129 | ({ name, expected }) => { 130 | const { name: modelName } = name; 131 | const gvk = modelToGroupVersionKind(modelName); 132 | try { 133 | expect(gvk.group).toBe(expected.group); 134 | expect(gvk.version).toBe(expected.version); 135 | expect(gvk.kind).toBe(expected.kind); 136 | } catch (error) { 137 | console.error( 138 | `Failed for model ${modelName}: Expected GroupVersionKind to be ${JSON.stringify( 139 | expected, 140 | )}, but got ${JSON.stringify(gvk)}`, 141 | ); 142 | throw error; 143 | } 144 | }, 145 | ); 146 | 147 | test("new registered type", () => { 148 | class foo implements GroupVersionKind { 149 | kind: string; 150 | group: string; 151 | constructor() { 152 | this.kind = "foo"; 153 | this.group = "bar"; 154 | } 155 | } 156 | RegisterKind(foo, new foo()); 157 | }); 158 | 159 | test("throws an error for already registered", () => { 160 | const { name } = kind.VolumeAttachment; 161 | const gvk = modelToGroupVersionKind(name); 162 | expect(() => { 163 | RegisterKind(kind.VolumeAttachment, { 164 | kind: gvk.kind, 165 | version: gvk.version, 166 | group: gvk.group, 167 | }); 168 | }).toThrow(`GVK ${name} already registered`); 169 | }); 170 | -------------------------------------------------------------------------------- /src/normalization.test.ts: -------------------------------------------------------------------------------- 1 | import * as normalization from "./normalization.js"; 2 | import { GenerateOptions } from "./generate.js"; 3 | import { beforeEach, it, expect, describe, afterEach, vi } from "vitest"; 4 | 5 | // Mock the fs module 6 | vi.mock("fs"); 7 | 8 | vi.mock("./types", () => ({ 9 | GenericKind: vi.fn().mockImplementation(() => ({ 10 | kind: "MockKind", 11 | apiVersion: "v1", 12 | })), 13 | })); 14 | 15 | describe("normalizeIndentationAndSpacing", () => { 16 | const mockOpts = { 17 | language: "ts", 18 | source: "", 19 | logFn: vi.fn(), 20 | }; 21 | 22 | beforeEach(() => { 23 | vi.clearAllMocks(); // Clear mocks before each test 24 | }); 25 | 26 | afterEach(() => { 27 | vi.restoreAllMocks(); // Restore all mocks after each test 28 | }); 29 | 30 | it("should normalize indentation to two spaces", () => { 31 | const mockLines = [ 32 | " indentedWithFourSpaces: string;", // Line with 4 spaces, should be normalized 33 | " alreadyTwoSpaces: string;", // Line with 2 spaces, should remain unchanged 34 | " sixSpacesIndent: string;", // Line with 6 spaces, only first 4 should be normalized 35 | "noIndent: string;", // Line with no indentation, should remain unchanged 36 | ]; 37 | 38 | const expectedResult = [ 39 | " indentedWithFourSpaces: string;", // Normalized to 2 spaces 40 | " alreadyTwoSpaces: string;", // No change 41 | " sixSpacesIndent: string;", // Only first 4 spaces should be normalized to 2 42 | "noIndent: string;", // No change 43 | ]; 44 | 45 | const result = normalization.normalizeIndentation(mockLines); 46 | 47 | expect(result).toEqual(expectedResult); 48 | }); 49 | 50 | it("should normalize single line indentation to two spaces", () => { 51 | const cases = [ 52 | { input: " indentedWithFourSpaces;", expected: " indentedWithFourSpaces;" }, // 4 spaces to 2 spaces 53 | { input: " alreadyTwoSpaces;", expected: " alreadyTwoSpaces;" }, // 2 spaces, no change 54 | { input: " sixSpacesIndent;", expected: " sixSpacesIndent;" }, // First 4 spaces to 2 55 | { input: "noIndent;", expected: "noIndent;" }, // No indentation, no change 56 | ]; 57 | 58 | cases.forEach(({ input, expected }) => { 59 | const result = normalization.normalizeLineIndentation(input); 60 | expect(result).toBe(expected); 61 | }); 62 | }); 63 | 64 | it("should normalize property spacing", () => { 65 | const cases = [ 66 | { 67 | input: "optionalProp ? : string;", 68 | expected: "optionalProp?: string;", 69 | }, // Extra spaces around ? and : 70 | { 71 | input: "optionalProp?: string;", 72 | expected: "optionalProp?: string;", 73 | }, // Already normalized 74 | { 75 | input: "optionalProp ? :string;", 76 | expected: "optionalProp?: string;", 77 | }, // No space after colon 78 | { 79 | input: "nonOptionalProp: string;", 80 | expected: "nonOptionalProp: string;", 81 | }, // Non-optional property, should remain unchanged 82 | ]; 83 | 84 | const inputLines = cases.map(c => c.input); 85 | const expectedLines = cases.map(c => c.expected); 86 | 87 | const result = normalization.normalizePropertySpacing(inputLines); 88 | 89 | expect(result).toEqual(expectedLines); 90 | }); 91 | 92 | it('should remove lines containing "[property: string]: any;" when language is "ts" or "typescript"', () => { 93 | const inputLines = [ 94 | "someProp: string;", 95 | "[property: string]: any;", 96 | "anotherProp: number;", 97 | "[property: string]: any;", 98 | ]; 99 | 100 | // Test for TypeScript 101 | const tsOpts: GenerateOptions = { ...mockOpts, language: "ts" }; 102 | const resultTs = normalization.removePropertyStringAny(inputLines, tsOpts); 103 | const expectedTs = ["someProp: string;", "anotherProp: number;"]; 104 | expect(resultTs).toEqual(expectedTs); 105 | 106 | // Test for TypeScript with "typescript" as language 107 | const typescriptOpts: GenerateOptions = { ...mockOpts, language: "typescript" }; 108 | const resultTypescript = normalization.removePropertyStringAny(inputLines, typescriptOpts); 109 | expect(resultTypescript).toEqual(expectedTs); 110 | }); 111 | 112 | describe("processEslintDisable", () => { 113 | beforeEach(() => { 114 | vi.clearAllMocks(); // Clear mocks before each test 115 | }); 116 | 117 | afterEach(() => { 118 | vi.restoreAllMocks(); // Restore all mocks after each test 119 | }); 120 | 121 | it('should add ESLint disable comment if line contains "[key: string]: any" and is not part of genericKindProperties', () => { 122 | const line = "[key: string]: any;"; 123 | const genericKindProperties = ["kind", "apiVersion"]; // No "[key: string]" present 124 | 125 | const result = normalization.processEslintDisable(line, genericKindProperties); 126 | 127 | expect(result).toEqual( 128 | " // eslint-disable-next-line @typescript-eslint/no-explicit-any\n[key: string]: any;", 129 | ); 130 | }); 131 | 132 | it('should not add ESLint disable comment if "[key: string]" is in genericKindProperties', () => { 133 | const line = "[key: string]: any;"; 134 | const genericKindProperties = ["[key: string]", "kind", "apiVersion"]; // "[key: string]" present 135 | 136 | const result = normalization.processEslintDisable(line, genericKindProperties); 137 | 138 | expect(result).toEqual("[key: string]: any;"); // No comment added 139 | }); 140 | 141 | it('should not add ESLint disable comment if line does not contain "[key: string]: any"', () => { 142 | const line = "prop: string;"; 143 | const genericKindProperties = ["kind", "apiVersion"]; // Normal properties 144 | 145 | const result = normalization.processEslintDisable(line, genericKindProperties); 146 | 147 | expect(result).toEqual("prop: string;"); // No change in the line 148 | }); 149 | 150 | it('should not add ESLint disable comment if line contains "[key: string]: any" but is part of genericKindProperties', () => { 151 | const line = "[key: string]: any;"; 152 | const genericKindProperties = ["[key: string]"]; 153 | 154 | const result = normalization.processEslintDisable(line, genericKindProperties); 155 | 156 | expect(result).toEqual("[key: string]: any;"); // No comment added since it's in genericKindProperties 157 | }); 158 | }); 159 | 160 | it('should not remove lines when language is not "ts" or "typescript"', () => { 161 | const inputLines = ["someProp: string;", "[property: string]: any;", "anotherProp: number;"]; 162 | 163 | // Test for other languages 164 | const otherOpts: GenerateOptions = { ...mockOpts, language: "js" }; // Not TypeScript 165 | const resultOther = normalization.removePropertyStringAny(inputLines, otherOpts); 166 | expect(resultOther).toEqual(inputLines); // Should return the original lines 167 | }); 168 | }); 169 | 170 | describe("makePropertiesOptional", () => { 171 | beforeEach(() => { 172 | vi.clearAllMocks(); // Clear mocks before each test 173 | }); 174 | 175 | afterEach(() => { 176 | vi.restoreAllMocks(); // Restore all mocks after each test 177 | }); 178 | 179 | it("should make property optional if type is found in interfaces and not already optional", () => { 180 | const line = "myProp: MyInterface;"; 181 | const foundInterfaces = new Set(["MyInterface"]); // Matching interface 182 | 183 | const result = normalization.makePropertiesOptional(line, foundInterfaces); 184 | 185 | expect(result).toEqual("myProp?: MyInterface;"); // The colon is replaced by `?:` 186 | }); 187 | 188 | it("should not make property optional if type is not found in interfaces", () => { 189 | const line = "myProp: AnotherType;"; 190 | const foundInterfaces = new Set(["MyInterface"]); // No match for this type 191 | 192 | const result = normalization.makePropertiesOptional(line, foundInterfaces); 193 | 194 | expect(result).toEqual("myProp: AnotherType;"); // No change 195 | }); 196 | 197 | it("should not make property optional if already optional", () => { 198 | const line = "myProp?: MyInterface;"; 199 | const foundInterfaces = new Set(["MyInterface"]); // Matching interface, but already optional 200 | 201 | const result = normalization.makePropertiesOptional(line, foundInterfaces); 202 | 203 | expect(result).toEqual("myProp?: MyInterface;"); // No change since it's already optional 204 | }); 205 | 206 | it("should not change line if it does not match the property pattern", () => { 207 | const line = "function test() {}"; 208 | const foundInterfaces = new Set(["MyInterface"]); // Matching interface, but the line is not a property 209 | 210 | const result = normalization.makePropertiesOptional(line, foundInterfaces); 211 | 212 | expect(result).toEqual("function test() {}"); // No change 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /src/normalization.ts: -------------------------------------------------------------------------------- 1 | import { GenerateOptions } from "./generate.js"; 2 | 3 | /** 4 | * Normalizes indentation for TypeScript lines to a consistent format. 5 | * 6 | * @param lines The generated TypeScript lines. 7 | * @returns The lines with normalized indentation. 8 | */ 9 | export function normalizeIndentation(lines: string[]): string[] { 10 | return lines.map(line => line.replace(/^ {4}/, " ")); 11 | } 12 | 13 | /** 14 | * Normalizes the indentation of a single line to use two spaces instead of four. 15 | * 16 | * @param line The line of code to normalize. 17 | * @returns The line with normalized indentation. 18 | */ 19 | export function normalizeLineIndentation(line: string): string { 20 | return line.replace(/^ {4}/, " "); 21 | } 22 | 23 | /** 24 | * Normalizes spacing between property names and types in TypeScript lines. 25 | * 26 | * @param lines The generated TypeScript lines. 27 | * @returns The lines with normalized property spacing. 28 | */ 29 | export function normalizePropertySpacing(lines: string[]): string[] { 30 | // https://regex101.com/r/XEv3pL/1 31 | return lines.map(line => line.replace(/\s*\?\s*:\s*/, "?: ")); 32 | } 33 | 34 | /** 35 | * Processes a single line inside a class extending `GenericKind`. 36 | * 37 | * @param line The current line of code. 38 | * @param genericKindProperties The list of properties from `GenericKind`. 39 | * @param foundInterfaces The set of found interfaces in the file. 40 | * @returns The modified line. 41 | */ 42 | export function modifyAndNormalizeClassProperties( 43 | line: string, 44 | genericKindProperties: string[], 45 | foundInterfaces: Set, 46 | ): string { 47 | line = modifyPropertiesAndAddEslintDirective(line, genericKindProperties, foundInterfaces); 48 | line = normalizeLineIndentation(line); 49 | return line; 50 | } 51 | 52 | /** 53 | * Normalizes lines after processing, including indentation, spacing, and removing unnecessary lines. 54 | * 55 | * @param lines The lines of the file content. 56 | * @param opts The options for processing. 57 | * @returns The normalized lines. 58 | */ 59 | export function normalizeIndentationAndSpacing(lines: string[], opts: GenerateOptions): string[] { 60 | let normalizedLines = normalizeIndentation(lines); 61 | normalizedLines = normalizePropertySpacing(normalizedLines); 62 | return removePropertyStringAny(normalizedLines, opts); 63 | } 64 | 65 | /** 66 | * Removes lines containing `[property: string]: any;` from TypeScript files. 67 | * 68 | * @param lines The generated TypeScript lines. 69 | * @param opts The options for processing. 70 | * @returns The lines with `[property: string]: any;` removed. 71 | */ 72 | export function removePropertyStringAny(lines: string[], opts: GenerateOptions): string[] { 73 | if (opts.language === "ts" || opts.language === "typescript") { 74 | return lines.filter(line => !line.includes("[property: string]: any;")); 75 | } 76 | return lines; 77 | } 78 | 79 | /** 80 | * Applies ESLint and property modifiers to a line of code. 81 | * 82 | * @param line - The current line of code. 83 | * @param genericKindProperties - The list of properties from `GenericKind`. 84 | * @param foundInterfaces - The set of found interfaces in the file. 85 | * @returns The modified line. 86 | */ 87 | export function modifyPropertiesAndAddEslintDirective( 88 | line: string, 89 | genericKindProperties: string[], 90 | foundInterfaces: Set, 91 | ): string { 92 | line = addDeclareAndOptionalModifiersToProperties(line, genericKindProperties, foundInterfaces); 93 | line = processEslintDisable(line, genericKindProperties); 94 | return line; 95 | } 96 | 97 | /** 98 | * Adds an ESLint disable comment for `[key: string]: any` if it's not part of `GenericKind`. 99 | * 100 | * @param line The current line of code. 101 | * @param genericKindProperties The list of properties from `GenericKind`. 102 | * @returns The modified line with the ESLint disable comment. 103 | */ 104 | export function processEslintDisable(line: string, genericKindProperties: string[]): string { 105 | if (line.includes("[key: string]: any") && !genericKindProperties.includes("[key: string]")) { 106 | return ` // eslint-disable-next-line @typescript-eslint/no-explicit-any\n${line}`; 107 | } 108 | return line; 109 | } 110 | 111 | /** 112 | * Applies property modifiers to a line of code. 113 | * 114 | * @param line The current line of code. 115 | * @param genericKindProperties The list of properties from `GenericKind`. 116 | * @param foundInterfaces The set of found interfaces in the file. 117 | * @returns The modified line. 118 | */ 119 | export function addDeclareAndOptionalModifiersToProperties( 120 | line: string, 121 | genericKindProperties: string[], 122 | foundInterfaces: Set, 123 | ): string { 124 | line = addDeclareToGenericKindProperties(line, genericKindProperties); 125 | line = makePropertiesOptional(line, foundInterfaces); 126 | line = normalizeLineIndentation(line); 127 | return line; 128 | } 129 | 130 | /** 131 | * Adds the `declare` keyword to `GenericKind` properties. 132 | * 133 | * @param line The current line of code. 134 | * @param genericKindProperties The list of properties from `GenericKind`. 135 | * @returns The modified line with the `declare` keyword, if applicable. 136 | */ 137 | export function addDeclareToGenericKindProperties( 138 | line: string, 139 | genericKindProperties: string[], 140 | ): string { 141 | for (const prop of genericKindProperties) { 142 | const propertyPattern = getPropertyPattern(prop); 143 | if (propertyPattern.test(line)) { 144 | return line.replace(prop, `declare ${prop}`); 145 | } 146 | } 147 | return line; 148 | } 149 | 150 | /** 151 | * Makes a property optional if its type matches one of the found interfaces and it is not already optional. 152 | * 153 | * @param line The current line of code. 154 | * @param foundInterfaces The set of found interfaces in the file. 155 | * @returns The modified line with the optional `?` symbol. 156 | */ 157 | export function makePropertiesOptional(line: string, foundInterfaces: Set): string { 158 | // https://regex101.com/r/kX8TCj/1 159 | const propertyTypePattern = /:\s*(?\w+)\s*;/; 160 | const match = line.match(propertyTypePattern); 161 | 162 | if (match?.groups?.propertyType) { 163 | const { propertyType } = match.groups; 164 | if (foundInterfaces.has(propertyType) && !line.includes("?")) { 165 | return line.replace(":", "?:"); 166 | } 167 | } 168 | return line; 169 | } 170 | 171 | /** 172 | * Generates a regular expression to match a property pattern in TypeScript. 173 | * 174 | * @param prop The property name to match. 175 | * @returns A regular expression to match the property pattern. 176 | */ 177 | export function getPropertyPattern(prop: string): RegExp { 178 | // For prop="kind", the pattern will match "kind ? :" or "kind :" 179 | // https://regex101.com/r/mF8kXn/1 180 | return new RegExp(`\\b${prop}\\b\\s*\\?\\s*:|\\b${prop}\\b\\s*:`); 181 | } 182 | -------------------------------------------------------------------------------- /src/patch.ts: -------------------------------------------------------------------------------- 1 | import { V1NetworkPolicyPeer } from "@kubernetes/client-node"; 2 | 3 | declare module "@kubernetes/client-node" { 4 | interface V1NetworkPolicyIngressRule { 5 | from?: Array; 6 | // No need to redeclare other unchanged properties 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/postProcessing.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import * as fs from "fs"; 5 | import * as path from "path"; 6 | import { GenerateOptions } from "./generate.js"; 7 | import { GenericKind } from "./types.js"; 8 | import { CustomResourceDefinition } from "./upstream.js"; 9 | import { 10 | modifyAndNormalizeClassProperties, 11 | normalizeIndentationAndSpacing, 12 | } from "./normalization.js"; 13 | 14 | type CRDResult = { 15 | name: string; 16 | crd: CustomResourceDefinition; 17 | version: string; 18 | }; 19 | 20 | type ClassContextResult = { line: string; insideClass: boolean; braceBalance: number }; 21 | 22 | const genericKindProperties = getGenericKindProperties(); 23 | 24 | /** 25 | * Performs post-processing on generated TypeScript files. 26 | * 27 | * @param allResults The array of CRD results. 28 | * @param opts The options for post-processing. 29 | */ 30 | export async function postProcessing(allResults: CRDResult[], opts: GenerateOptions) { 31 | if (!opts.directory) { 32 | opts.logFn("⚠️ Error: Directory is not defined."); 33 | return; 34 | } 35 | 36 | const files = fs.readdirSync(opts.directory); 37 | opts.logFn("\n🔧 Post-processing started..."); 38 | 39 | const fileResultMap = mapFilesToCRD(allResults); 40 | await processFiles(files, fileResultMap, opts); 41 | 42 | opts.logFn("🔧 Post-processing completed.\n"); 43 | } 44 | 45 | /** 46 | * Creates a map linking each file to its corresponding CRD result. 47 | * 48 | * @param allResults - The array of CRD results. 49 | * @returns A map linking file names to their corresponding CRD results. 50 | */ 51 | export function mapFilesToCRD(allResults: CRDResult[]): Record { 52 | const fileResultMap: Record = {}; 53 | 54 | for (const { name, crd, version } of allResults) { 55 | const expectedFileName = `${name.toLowerCase()}-${version.toLowerCase()}.ts`; 56 | fileResultMap[expectedFileName] = { name, crd, version }; 57 | } 58 | 59 | if (Object.keys(fileResultMap).length === 0) { 60 | console.warn("⚠️ Warning: No CRD results were mapped to files."); 61 | } 62 | 63 | return fileResultMap; 64 | } 65 | 66 | /** 67 | * Processes the list of files, applying CRD post-processing to each. 68 | * 69 | * @param files - The list of file names to process. 70 | * @param fileResultMap - A map linking file names to their corresponding CRD results. 71 | * @param opts - Options for the generation process. 72 | */ 73 | export async function processFiles( 74 | files: string[], 75 | fileResultMap: Record, 76 | opts: GenerateOptions, 77 | ) { 78 | for (const file of files) { 79 | if (!opts.directory) { 80 | throw new Error("Directory is not defined."); 81 | } 82 | const filePath = path.join(opts.directory, file); 83 | const fileResult = fileResultMap[file]; 84 | 85 | if (!fileResult) { 86 | opts.logFn(`⚠️ Warning: No matching CRD result found for file: ${filePath}`); 87 | continue; 88 | } 89 | 90 | try { 91 | processAndModifySingleFile(filePath, fileResult, opts); 92 | } catch (error) { 93 | logError(error, filePath, opts.logFn); 94 | } 95 | } 96 | } 97 | 98 | /** 99 | * Handles the processing of a single file: reading, modifying, and writing back to disk. 100 | * 101 | * @param filePath - The path to the file to be processed. 102 | * @param fileResult - The associated CRD result for this file. 103 | * @param fileResult.name - The name of the schema. 104 | * @param fileResult.crd - The CustomResourceDefinition object. 105 | * @param fileResult.version - The version of the CRD. 106 | * @param opts - Options for the generation process. 107 | */ 108 | export function processAndModifySingleFile( 109 | filePath: string, 110 | fileResult: CRDResult, 111 | opts: GenerateOptions, 112 | ) { 113 | opts.logFn(`🔍 Processing file: ${filePath}`); 114 | const { name, crd, version } = fileResult; 115 | 116 | let fileContent; 117 | try { 118 | fileContent = fs.readFileSync(filePath, "utf8"); 119 | } catch (error) { 120 | logError(error, filePath, opts.logFn); 121 | return; 122 | } 123 | 124 | let modifiedContent; 125 | try { 126 | modifiedContent = applyCRDPostProcessing(fileContent, name, crd, version, opts); 127 | } catch (error) { 128 | logError(error, filePath, opts.logFn); 129 | return; 130 | } 131 | 132 | try { 133 | fs.writeFileSync(filePath, modifiedContent); 134 | opts.logFn(`✅ Successfully processed and wrote file: ${filePath}`); 135 | } catch (error) { 136 | logError(error, filePath, opts.logFn); 137 | } 138 | } 139 | 140 | /** 141 | * Processes the TypeScript file content, applying wrapping and property modifications. 142 | * 143 | * @param content The content of the TypeScript file. 144 | * @param name The name of the schema. 145 | * @param crd The CustomResourceDefinition object. 146 | * @param version The version of the CRD. 147 | * @param opts The options for processing. 148 | * @returns The processed TypeScript file content. 149 | */ 150 | export function applyCRDPostProcessing( 151 | content: string, 152 | name: string, 153 | crd: CustomResourceDefinition, 154 | version: string, 155 | opts: GenerateOptions, 156 | ): string { 157 | try { 158 | let lines = content.split("\n"); 159 | 160 | // Wraps with the fluent client if needed 161 | if (opts.language === "ts" && !opts.plain) { 162 | lines = wrapWithFluentClient(lines, name, crd, version, opts.npmPackage); 163 | } 164 | const foundInterfaces = collectInterfaceNames(lines); 165 | 166 | // Process the lines, focusing on classes extending `GenericKind` 167 | const processedLines = processLines(lines, genericKindProperties, foundInterfaces); 168 | 169 | // Normalize the final output 170 | const normalizedLines = normalizeIndentationAndSpacing(processedLines, opts); 171 | 172 | return normalizedLines.join("\n"); 173 | } catch (error) { 174 | throw new Error(`Error while applying post-processing for ${name}: ${error.message}`); 175 | } 176 | } 177 | 178 | /** 179 | * Retrieves the properties of the `GenericKind` class, excluding dynamic properties like `[key: string]: any`. 180 | * 181 | * @returns An array of property names that belong to `GenericKind`. 182 | */ 183 | export function getGenericKindProperties(): string[] { 184 | // Ensure we always include standard Kubernetes resource properties 185 | const standardProperties = ["kind", "apiVersion", "metadata"]; 186 | 187 | // Get actual properties from GenericKind 188 | const instanceProperties = Object.getOwnPropertyNames(new GenericKind()).filter( 189 | prop => prop !== "[key: string]", 190 | ); 191 | 192 | // Combine both sets of properties, removing duplicates 193 | return Array.from(new Set([...standardProperties, ...instanceProperties])); 194 | } 195 | 196 | /** 197 | * Collects interface names from TypeScript file lines. 198 | * 199 | * @param lines The lines of the file content. 200 | * @returns A set of found interface names. 201 | */ 202 | export function collectInterfaceNames(lines: string[]): Set { 203 | // https://regex101.com/r/S6w8pW/1 204 | const interfacePattern = /export interface (?\w+)/; 205 | const foundInterfaces = new Set(); 206 | 207 | for (const line of lines) { 208 | const match = line.match(interfacePattern); 209 | if (match?.groups?.interfaceName) { 210 | foundInterfaces.add(match.groups.interfaceName); 211 | } 212 | } 213 | 214 | return foundInterfaces; 215 | } 216 | 217 | /** 218 | * Identifies whether a line declares a class that extends `GenericKind`. 219 | * 220 | * @param line The current line of code. 221 | * @returns True if the line defines a class that extends `GenericKind`, false otherwise. 222 | */ 223 | export function isClassExtendingGenericKind(line: string): boolean { 224 | return line.includes("class") && line.includes("extends GenericKind"); 225 | } 226 | 227 | /** 228 | * Adjusts the brace balance to determine if the parser is within a class definition. 229 | * 230 | * @param line The current line of code. 231 | * @param braceBalance The current balance of curly braces. 232 | * @returns The updated brace balance. 233 | */ 234 | export function updateBraceBalance(line: string, braceBalance: number): number { 235 | return braceBalance + (line.includes("{") ? 1 : 0) - (line.includes("}") ? 1 : 0); 236 | } 237 | 238 | /** 239 | * Wraps the generated TypeScript file with fluent client elements (`GenericKind` and `RegisterKind`). 240 | * 241 | * @param lines The generated TypeScript lines. 242 | * @param name The name of the schema. 243 | * @param crd The CustomResourceDefinition object. 244 | * @param version The version of the CRD. 245 | * @param npmPackage The NPM package name for the fluent client. 246 | * @returns The processed TypeScript lines. 247 | */ 248 | export function wrapWithFluentClient( 249 | lines: string[], 250 | name: string, 251 | crd: CustomResourceDefinition, 252 | version: string, 253 | npmPackage: string = "kubernetes-fluent-client", 254 | ): string[] { 255 | const autoGenNotice = `// This file is auto-generated by ${npmPackage}, do not edit manually`; 256 | const imports = `import { GenericKind, RegisterKind } from "${npmPackage}";`; 257 | 258 | const classIndex = lines.findIndex(line => line.includes(`export interface ${name} {`)); 259 | if (classIndex !== -1) { 260 | lines[classIndex] = `export class ${name} extends GenericKind {`; 261 | } 262 | 263 | lines.unshift(autoGenNotice, imports); 264 | lines.push( 265 | `RegisterKind(${name}, {`, 266 | ` group: "${crd.spec.group}",`, 267 | ` version: "${version}",`, 268 | ` kind: "${name}",`, 269 | ` plural: "${crd.spec.names.plural}",`, 270 | `});`, 271 | ); 272 | 273 | return lines; 274 | } 275 | 276 | /** 277 | * Processes the lines of the TypeScript file, focusing on classes extending `GenericKind`. 278 | * 279 | * @param lines The lines of the file content. 280 | * @param genericKindProperties The list of properties from `GenericKind`. 281 | * @param foundInterfaces The set of found interfaces in the file. 282 | * @returns The processed lines. 283 | */ 284 | export function processLines( 285 | lines: string[], 286 | genericKindProperties: string[], 287 | foundInterfaces: Set, 288 | ): string[] { 289 | let insideClass = false; 290 | let braceBalance = 0; 291 | 292 | return lines.map(line => { 293 | const result = processClassContext( 294 | line, 295 | insideClass, 296 | braceBalance, 297 | genericKindProperties, 298 | foundInterfaces, 299 | ); 300 | insideClass = result.insideClass; 301 | braceBalance = result.braceBalance; 302 | 303 | return result.line; 304 | }); 305 | } 306 | 307 | /** 308 | * Processes a single line inside a class extending `GenericKind`. 309 | * 310 | * @param line The current line of code. 311 | * @param insideClass Whether we are inside a class context. 312 | * @param braceBalance The current brace balance to detect when we exit the class. 313 | * @param genericKindProperties The list of properties from `GenericKind`. 314 | * @param foundInterfaces The set of found interfaces in the file. 315 | * @returns An object containing the updated line, updated insideClass flag, and braceBalance. 316 | */ 317 | export function processClassContext( 318 | line: string, 319 | insideClass: boolean, 320 | braceBalance: number, 321 | genericKindProperties: string[], 322 | foundInterfaces: Set, 323 | ): ClassContextResult { 324 | if (isClassExtendingGenericKind(line)) { 325 | insideClass = true; 326 | braceBalance = 0; 327 | } 328 | 329 | if (!insideClass) return { line, insideClass, braceBalance }; 330 | 331 | braceBalance = updateBraceBalance(line, braceBalance); 332 | line = modifyAndNormalizeClassProperties(line, genericKindProperties, foundInterfaces); 333 | 334 | if (braceBalance === 0) { 335 | insideClass = false; 336 | } 337 | 338 | return { line, insideClass, braceBalance }; 339 | } 340 | 341 | /** 342 | * Handles logging for errors with stack trace. 343 | * 344 | * @param error The error object to log. 345 | * @param filePath The path of the file being processed. 346 | * @param logFn The logging function. 347 | */ 348 | export function logError(error: Error, filePath: string, logFn: (msg: string) => void) { 349 | logFn(`❌ Error processing file: ${filePath} - ${error.message}`); 350 | logFn(`Stack trace: ${error.stack}`); 351 | } 352 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | import { V1ObjectMeta } from "@kubernetes/client-node"; 5 | import type { KubernetesListObject, KubernetesObject } from "@kubernetes/client-node"; 6 | export type { KubernetesListObject, KubernetesObject }; 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | export type GenericClass = abstract new () => any; 9 | 10 | /** 11 | * GenericKind is a generic Kubernetes object that can be used to represent any Kubernetes object 12 | * that is not explicitly supported. This can be used on its own or as a base class for 13 | * other types. 14 | */ 15 | export class GenericKind implements KubernetesObject { 16 | apiVersion?: string; 17 | kind?: string; 18 | metadata?: V1ObjectMeta; 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | [key: string]: any; 21 | } 22 | 23 | /** 24 | * GroupVersionKind unambiguously identifies a kind. It doesn't anonymously include GroupVersion 25 | * to avoid automatic coercion. It doesn't use a GroupVersion to avoid custom marshalling 26 | */ 27 | export interface GroupVersionKind { 28 | /** The K8s resource kind, e..g "Pod". */ 29 | readonly kind: string; 30 | readonly group: string; 31 | readonly version?: string; 32 | /** Optional, override the plural name for use in Webhook rules generation */ 33 | readonly plural?: string; 34 | } 35 | 36 | export interface LogFn { 37 | /* tslint:disable:no-unnecessary-generics */ 38 | (obj: T, msg?: string, ...args: never[]): void; 39 | (obj: unknown, msg?: string, ...args: never[]): void; 40 | (msg: string, ...args: never[]): void; 41 | } 42 | -------------------------------------------------------------------------------- /src/upstream.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors 3 | 4 | /** a is a collection of K8s types to be used within an action: `When(a.Configmap)` */ 5 | export { 6 | CoreV1Event as CoreEvent, 7 | EventsV1Event as Event, 8 | V1APIService as APIService, 9 | V1CSIDriver as CSIDriver, 10 | V1CertificateSigningRequest as CertificateSigningRequest, 11 | V1ClusterRole as ClusterRole, 12 | V1ClusterRoleBinding as ClusterRoleBinding, 13 | V1ConfigMap as ConfigMap, 14 | V1ControllerRevision as ControllerRevision, 15 | V1CronJob as CronJob, 16 | V1CustomResourceDefinition as CustomResourceDefinition, 17 | V1DaemonSet as DaemonSet, 18 | V1Deployment as Deployment, 19 | V1EndpointSlice as EndpointSlice, 20 | V1Endpoints as Endpoints, 21 | V1HorizontalPodAutoscaler as HorizontalPodAutoscaler, 22 | V1Ingress as Ingress, 23 | V1IngressClass as IngressClass, 24 | V1Job as Job, 25 | V1LimitRange as LimitRange, 26 | V1LocalSubjectAccessReview as LocalSubjectAccessReview, 27 | V1MutatingWebhookConfiguration as MutatingWebhookConfiguration, 28 | V1Namespace as Namespace, 29 | V1NetworkPolicy as NetworkPolicy, 30 | V1Node as Node, 31 | V1PersistentVolume as PersistentVolume, 32 | V1PersistentVolumeClaim as PersistentVolumeClaim, 33 | V1Pod as Pod, 34 | V1PodDisruptionBudget as PodDisruptionBudget, 35 | V1PodTemplate as PodTemplate, 36 | V1ReplicaSet as ReplicaSet, 37 | V1ReplicationController as ReplicationController, 38 | V1ResourceQuota as ResourceQuota, 39 | V1Role as Role, 40 | V1RoleBinding as RoleBinding, 41 | V1RuntimeClass as RuntimeClass, 42 | V1Secret as Secret, 43 | V1SelfSubjectAccessReview as SelfSubjectAccessReview, 44 | V1SelfSubjectRulesReview as SelfSubjectRulesReview, 45 | V1Service as Service, 46 | V1ServiceAccount as ServiceAccount, 47 | V1StatefulSet as StatefulSet, 48 | V1StorageClass as StorageClass, 49 | V1SubjectAccessReview as SubjectAccessReview, 50 | V1TokenReview as TokenReview, 51 | V1ValidatingWebhookConfiguration as ValidatingWebhookConfiguration, 52 | V1VolumeAttachment as VolumeAttachment, 53 | } from "@kubernetes/client-node"; 54 | export { GenericKind } from "./types.js"; 55 | -------------------------------------------------------------------------------- /test/datastore.crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: datastores.pepr.io 5 | spec: 6 | group: pepr.io 7 | names: 8 | plural: datastores 9 | singular: datastore 10 | kind: Datastore 11 | shortNames: 12 | - ds 13 | scope: Namespaced 14 | versions: 15 | - name: v1alpha1 16 | served: true 17 | storage: true 18 | schema: 19 | openAPIV3Schema: 20 | type: object 21 | properties: 22 | spec: 23 | type: object 24 | properties: 25 | kind: 26 | type: string 27 | enum: 28 | - sqlite 29 | - valkey 30 | description: "The type of datastore. Allowed values: sqlite, valkey." 31 | accessModes: 32 | type: array 33 | items: 34 | type: string 35 | description: "The access modes for the datastore (e.g., ReadWriteOnce, ReadOnlyMany)." 36 | capacity: 37 | type: string 38 | description: "The capacity of the datastore (e.g., 10Gi)." 39 | hostPath: 40 | type: string 41 | description: "The host path for the datastore storage." 42 | required: 43 | - kind 44 | - accessModes 45 | - capacity 46 | - hostPath 47 | status: 48 | type: object 49 | properties: 50 | observedGeneration: 51 | type: integer 52 | phase: 53 | type: string 54 | enum: 55 | - "Failed" 56 | - "Pending" 57 | - "Ready" 58 | subresources: 59 | status: {} 60 | -------------------------------------------------------------------------------- /test/webapp.crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: webapps.pepr.io 5 | spec: 6 | group: pepr.io 7 | versions: 8 | - name: v1alpha1 9 | served: true 10 | storage: true 11 | subresources: 12 | status: {} 13 | schema: 14 | openAPIV3Schema: 15 | type: object 16 | properties: 17 | apiVersion: 18 | type: string 19 | kind: 20 | type: string 21 | metadata: 22 | type: object 23 | spec: 24 | required: 25 | - theme 26 | - language 27 | - replicas 28 | type: object 29 | properties: 30 | theme: 31 | type: string 32 | description: "Theme defines the theme of the web application, either dark or light." 33 | enum: 34 | - "dark" 35 | - "light" 36 | language: 37 | type: string 38 | description: "Language defines the language of the web application, either English (en) or Spanish (es)." 39 | enum: 40 | - "en" 41 | - "es" 42 | replicas: 43 | type: integer 44 | description: "Replicas is the number of desired replicas." 45 | status: 46 | type: object 47 | properties: 48 | observedGeneration: 49 | type: integer 50 | phase: 51 | type: string 52 | enum: 53 | - "Failed" 54 | - "Pending" 55 | - "Ready" 56 | scope: Namespaced 57 | names: 58 | plural: webapps 59 | singular: webapp 60 | kind: WebApp 61 | shortNames: 62 | - wa 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "esModuleInterop": true, 6 | "lib": ["ES2022", "WebWorker"], 7 | "module": "nodenext", 8 | "moduleResolution": "nodenext", 9 | "outDir": "dist", 10 | "resolveJsonModule": true, 11 | "rootDir": "src", 12 | "strict": true, 13 | "target": "ES2022", 14 | "useUnknownInCatchVariables": false 15 | }, 16 | "include": ["src/**/*.ts"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | --------------------------------------------------------------------------------