├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── config.yml ├── pull_request_template.md └── workflows │ ├── auto-approve.yml │ ├── auto-queue.yml │ ├── build.yml │ ├── pull-request-lint.yml │ ├── release.yml │ ├── security.yml │ ├── stale.yml │ ├── triage.yml │ ├── upgrade-compiler-dependencies-2.x.yml │ ├── upgrade-configuration-2.x.yml │ ├── upgrade-dev-dependencies-2.x.yml │ └── upgrade-runtime-dependencies-2.x.yml ├── .gitignore ├── .npmignore ├── .projen ├── deps.json ├── files.json └── tasks.json ├── .projenrc.js ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DCO ├── LICENSE ├── NOTICE ├── README.md ├── SECURITY.md ├── docs ├── java.md ├── python.md └── typescript.md ├── git-hooks ├── README.md ├── prepare-commit-msg └── setup.sh ├── package.json ├── src ├── _child_process.ts ├── _loadurl.mjs ├── _util.ts ├── api-object.ts ├── app.ts ├── chart.ts ├── cron.ts ├── dependency.ts ├── duration.ts ├── helm.ts ├── include.ts ├── index.ts ├── json-patch.ts ├── lazy.ts ├── metadata.ts ├── names.ts ├── resolve.ts ├── size.ts ├── testing.ts └── yaml.ts ├── test ├── __snapshots__ │ ├── api-object.test.ts.snap │ ├── app.test.ts.snap │ ├── chart.test.ts.snap │ ├── helm.test.ts.snap │ ├── util.test.ts.snap │ └── yaml.test.ts.snap ├── api-object.test.ts ├── app.test.ts ├── chart.test.ts ├── cron.test.ts ├── dependency.test.ts ├── duration.test.ts ├── fixtures │ ├── guestbook-all-in-one.yaml │ ├── helm-sample │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── templates │ │ │ ├── NOTES.txt │ │ │ ├── _helpers.tpl │ │ │ ├── deployment.yaml │ │ │ ├── hpa.yaml │ │ │ ├── ingress.yaml │ │ │ ├── service.yaml │ │ │ └── serviceaccount.yaml │ │ └── values.yaml │ └── sample.yaml ├── helm.test.ts ├── include.test.ts ├── json-patch.test.ts ├── metadata.test.ts ├── names-legacy.test.ts ├── names.test.ts ├── size.test.ts ├── tokens.test.ts ├── util.test.ts ├── util.ts └── yaml.test.ts ├── tsconfig.dev.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | { 3 | "env": { 4 | "jest": true, 5 | "node": true 6 | }, 7 | "root": true, 8 | "plugins": [ 9 | "@typescript-eslint", 10 | "import", 11 | "@stylistic" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": 2018, 16 | "sourceType": "module", 17 | "project": "./tsconfig.dev.json" 18 | }, 19 | "extends": [ 20 | "plugin:import/typescript" 21 | ], 22 | "settings": { 23 | "import/parsers": { 24 | "@typescript-eslint/parser": [ 25 | ".ts", 26 | ".tsx" 27 | ] 28 | }, 29 | "import/resolver": { 30 | "node": {}, 31 | "typescript": { 32 | "project": "./tsconfig.dev.json", 33 | "alwaysTryTypes": true 34 | } 35 | } 36 | }, 37 | "ignorePatterns": [ 38 | "*.js", 39 | "*.d.ts", 40 | "node_modules/", 41 | "*.generated.ts", 42 | "coverage", 43 | "!.projenrc.js" 44 | ], 45 | "rules": { 46 | "@stylistic/indent": [ 47 | "error", 48 | 2 49 | ], 50 | "@stylistic/quotes": [ 51 | "error", 52 | "single", 53 | { 54 | "avoidEscape": true 55 | } 56 | ], 57 | "@stylistic/comma-dangle": [ 58 | "error", 59 | "always-multiline" 60 | ], 61 | "@stylistic/comma-spacing": [ 62 | "error", 63 | { 64 | "before": false, 65 | "after": true 66 | } 67 | ], 68 | "@stylistic/no-multi-spaces": [ 69 | "error", 70 | { 71 | "ignoreEOLComments": false 72 | } 73 | ], 74 | "@stylistic/array-bracket-spacing": [ 75 | "error", 76 | "never" 77 | ], 78 | "@stylistic/array-bracket-newline": [ 79 | "error", 80 | "consistent" 81 | ], 82 | "@stylistic/object-curly-spacing": [ 83 | "error", 84 | "always" 85 | ], 86 | "@stylistic/object-curly-newline": [ 87 | "error", 88 | { 89 | "multiline": true, 90 | "consistent": true 91 | } 92 | ], 93 | "@stylistic/object-property-newline": [ 94 | "error", 95 | { 96 | "allowAllPropertiesOnSameLine": true 97 | } 98 | ], 99 | "@stylistic/keyword-spacing": [ 100 | "error" 101 | ], 102 | "@stylistic/brace-style": [ 103 | "error", 104 | "1tbs", 105 | { 106 | "allowSingleLine": true 107 | } 108 | ], 109 | "@stylistic/space-before-blocks": [ 110 | "error" 111 | ], 112 | "@stylistic/member-delimiter-style": [ 113 | "error" 114 | ], 115 | "@stylistic/semi": [ 116 | "error", 117 | "always" 118 | ], 119 | "@stylistic/max-len": [ 120 | "error", 121 | { 122 | "code": 150, 123 | "ignoreUrls": true, 124 | "ignoreStrings": true, 125 | "ignoreTemplateLiterals": true, 126 | "ignoreComments": true, 127 | "ignoreRegExpLiterals": true 128 | } 129 | ], 130 | "@stylistic/quote-props": [ 131 | "error", 132 | "consistent-as-needed" 133 | ], 134 | "@stylistic/key-spacing": [ 135 | "error" 136 | ], 137 | "@stylistic/no-multiple-empty-lines": [ 138 | "error" 139 | ], 140 | "@stylistic/no-trailing-spaces": [ 141 | "error" 142 | ], 143 | "curly": [ 144 | "error", 145 | "multi-line", 146 | "consistent" 147 | ], 148 | "@typescript-eslint/no-require-imports": "error", 149 | "import/no-extraneous-dependencies": [ 150 | "error", 151 | { 152 | "devDependencies": [ 153 | "**/test/**", 154 | "**/build-tools/**" 155 | ], 156 | "optionalDependencies": false, 157 | "peerDependencies": true 158 | } 159 | ], 160 | "import/no-unresolved": [ 161 | "error" 162 | ], 163 | "import/order": [ 164 | "warn", 165 | { 166 | "groups": [ 167 | "builtin", 168 | "external" 169 | ], 170 | "alphabetize": { 171 | "order": "asc", 172 | "caseInsensitive": true 173 | } 174 | } 175 | ], 176 | "import/no-duplicates": [ 177 | "error" 178 | ], 179 | "no-shadow": [ 180 | "off" 181 | ], 182 | "@typescript-eslint/no-shadow": "error", 183 | "@typescript-eslint/no-floating-promises": "error", 184 | "no-return-await": [ 185 | "off" 186 | ], 187 | "@typescript-eslint/return-await": "error", 188 | "dot-notation": [ 189 | "error" 190 | ], 191 | "no-bitwise": [ 192 | "error" 193 | ], 194 | "@typescript-eslint/member-ordering": [ 195 | "error", 196 | { 197 | "default": [ 198 | "public-static-field", 199 | "public-static-method", 200 | "protected-static-field", 201 | "protected-static-method", 202 | "private-static-field", 203 | "private-static-method", 204 | "field", 205 | "constructor", 206 | "method" 207 | ] 208 | } 209 | ] 210 | }, 211 | "overrides": [ 212 | { 213 | "files": [ 214 | ".projenrc.js" 215 | ], 216 | "rules": { 217 | "@typescript-eslint/no-require-imports": "off", 218 | "import/no-extraneous-dependencies": "off" 219 | } 220 | } 221 | ] 222 | } 223 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | * text=auto eol=lf 4 | *.snap linguist-generated 5 | /.eslintrc.json linguist-generated 6 | /.gitattributes linguist-generated 7 | /.github/ISSUE_TEMPLATE/config.yml linguist-generated 8 | /.github/pull_request_template.md linguist-generated 9 | /.github/workflows/auto-approve.yml linguist-generated 10 | /.github/workflows/auto-queue.yml linguist-generated 11 | /.github/workflows/build.yml linguist-generated 12 | /.github/workflows/pull-request-lint.yml linguist-generated 13 | /.github/workflows/release.yml linguist-generated 14 | /.github/workflows/security.yml linguist-generated 15 | /.github/workflows/stale.yml linguist-generated 16 | /.github/workflows/triage.yml linguist-generated 17 | /.github/workflows/upgrade-compiler-dependencies-2.x.yml linguist-generated 18 | /.github/workflows/upgrade-configuration-2.x.yml linguist-generated 19 | /.github/workflows/upgrade-dev-dependencies-2.x.yml linguist-generated 20 | /.github/workflows/upgrade-runtime-dependencies-2.x.yml linguist-generated 21 | /.gitignore linguist-generated 22 | /.npmignore linguist-generated 23 | /.projen/** linguist-generated 24 | /.projen/deps.json linguist-generated 25 | /.projen/files.json linguist-generated 26 | /.projen/tasks.json linguist-generated 27 | /API.md linguist-generated 28 | /CODE_OF_CONDUCT.md linguist-generated 29 | /DCO linguist-generated 30 | /git-hooks/prepare-commit-msg linguist-generated 31 | /git-hooks/README.md linguist-generated 32 | /git-hooks/setup.sh linguist-generated 33 | /LICENSE linguist-generated 34 | /package.json linguist-generated 35 | /SECURITY.md linguist-generated 36 | /tsconfig.dev.json linguist-generated 37 | /yarn.lock linguist-generated -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | blank_issues_enabled: false 4 | contact_links: 5 | - name: Slack Community Support @ cdk.dev 6 | url: https://cdk-dev.slack.com/archives/C0184GCBY4X 7 | about: Please ask and answer questions here. 8 | - name: Slack Community Support @ cncf.io 9 | url: https://cloud-native.slack.com/archives/C02KCDACGTT 10 | about: Please ask and answer questions here. 11 | - name: GitHub Community Support 12 | url: https://github.com/cdk8s-team/cdk8s-core/discussions 13 | about: Please ask and answer questions here. 14 | - name: Open a cdk8s issue 15 | url: https://github.com/cdk8s-team/cdk8s/issues/new/choose 16 | about: All cdk8s issues are tracked in the cdk8s repository. 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Fixes # -------------------------------------------------------------------------------- /.github/workflows/auto-approve.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: auto-approve 4 | on: 5 | pull_request_target: 6 | types: 7 | - labeled 8 | - opened 9 | - synchronize 10 | - reopened 11 | - ready_for_review 12 | jobs: 13 | approve: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | pull-requests: write 17 | if: contains(github.event.pull_request.labels.*.name, 'auto-approve') && (github.event.pull_request.user.login == 'cdk8s-automation') 18 | steps: 19 | - uses: hmarr/auto-approve-action@v2.2.1 20 | with: 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/auto-queue.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: auto-queue 4 | on: 5 | pull_request_target: 6 | types: 7 | - opened 8 | - reopened 9 | - ready_for_review 10 | jobs: 11 | enableAutoQueue: 12 | name: "Set AutoQueue on PR #${{ github.event.number }}" 13 | runs-on: ubuntu-latest 14 | permissions: 15 | pull-requests: write 16 | contents: write 17 | steps: 18 | - uses: peter-evans/enable-pull-request-automerge@v3 19 | with: 20 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 21 | pull-request-number: ${{ github.event.number }} 22 | merge-method: squash 23 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-lint.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: pull-request-lint 4 | on: 5 | pull_request_target: 6 | types: 7 | - labeled 8 | - opened 9 | - synchronize 10 | - reopened 11 | - ready_for_review 12 | - edited 13 | merge_group: {} 14 | jobs: 15 | validate: 16 | name: Validate PR title 17 | runs-on: ubuntu-latest 18 | permissions: 19 | pull-requests: write 20 | if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') 21 | steps: 22 | - uses: amannn/action-semantic-pull-request@v5.4.0 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | with: 26 | types: |- 27 | feat 28 | fix 29 | chore 30 | requireScope: false 31 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: security 4 | on: 5 | schedule: 6 | - cron: 0 0 * * * 7 | workflow_dispatch: {} 8 | jobs: 9 | scan: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | security-events: read 13 | issues: write 14 | steps: 15 | - name: scan 16 | uses: cdk8s-team/cdk8s-dependabot-security-alerts@main 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.PROJEN_GITHUB_TOKEN }} 19 | REPO_ROOT: ${{ github.workspace }} 20 | REPO_NAME: ${{ github.repository }} 21 | OWNER_NAME: ${{ github.repository_owner }} 22 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: stale 4 | on: 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: 0 */4 * * * 8 | jobs: 9 | scan: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | contents: read 15 | steps: 16 | - uses: aws-actions/stale-issue-cleanup@v6 17 | with: 18 | ancient-issue-message: This issue has not received any attention in 1 year and will be closed soon. If you want to keep it open, please leave a comment below @mentioning a maintainer. 19 | stale-issue-message: This issue has not received a response in a while and will be closed soon. If you want to keep it open, please leave a comment below @mentioning a maintainer. 20 | stale-pr-message: This PR has not received a response in a while and will be closed soon. If you want to keep it open, please leave a comment below @mentioning a maintainer. 21 | stale-issue-label: closing-soon 22 | exempt-issue-labels: no-autoclose 23 | stale-pr-label: closing-soon 24 | exempt-pr-labels: no-autoclose 25 | response-requested-label: response-requested 26 | closed-for-staleness-label: closed-for-staleness 27 | days-before-stale: 30 28 | days-before-close: 7 29 | days-before-ancient: 365 30 | minimum-upvotes-to-exempt: 10 31 | repo-token: ${{ secrets.GITHUB_TOKEN }} 32 | loglevel: DEBUG 33 | dry-run: false 34 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: triage 4 | on: 5 | issues: 6 | types: 7 | - opened 8 | pull_request: 9 | types: 10 | - opened 11 | jobs: 12 | assign-to-project: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | issues: write 16 | pull-requests: write 17 | if: (github.repository == 'cdk8s-team/cdk8s-core') && (github.event.issue || (github.event.pull_request.user.login != 'cdk8s-automation' && github.event.pull_request.head.repo.full_name == github.repository)) 18 | steps: 19 | - uses: actions/add-to-project@v0.4.0 20 | with: 21 | project-url: https://github.com/orgs/cdk8s-team/projects/12 22 | github-token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-compiler-dependencies-2.x.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: upgrade-compiler-dependencies-2.x 4 | on: 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: 0 12 * * * 8 | jobs: 9 | upgrade: 10 | name: Upgrade 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | outputs: 15 | patch_created: ${{ steps.create_patch.outputs.patch_created }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | ref: 2.x 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | - name: Install dependencies 26 | run: yarn install --check-files --frozen-lockfile 27 | - name: Upgrade dependencies 28 | run: npx projen upgrade-compiler-dependencies 29 | - name: Find mutations 30 | id: create_patch 31 | run: |- 32 | git add . 33 | git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT 34 | working-directory: ./ 35 | - name: Upload patch 36 | if: steps.create_patch.outputs.patch_created 37 | uses: actions/upload-artifact@v4.4.0 38 | with: 39 | name: repo.patch 40 | path: repo.patch 41 | overwrite: true 42 | pr: 43 | name: Create Pull Request 44 | needs: upgrade 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | if: ${{ needs.upgrade.outputs.patch_created }} 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | with: 53 | ref: 2.x 54 | - name: Download patch 55 | uses: actions/download-artifact@v4 56 | with: 57 | name: repo.patch 58 | path: ${{ runner.temp }} 59 | - name: Apply patch 60 | run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."' 61 | - name: Set git identity 62 | run: |- 63 | git config user.name "github-actions" 64 | git config user.email "github-actions@github.com" 65 | - name: Create Pull Request 66 | id: create-pr 67 | uses: peter-evans/create-pull-request@v6 68 | with: 69 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 70 | commit-message: |- 71 | chore(deps): upgrade compiler dependencies 72 | 73 | Upgrades project dependencies. See details in [workflow run]. 74 | 75 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 76 | 77 | ------ 78 | 79 | *Automatically created by projen via the "upgrade-compiler-dependencies-2.x" workflow* 80 | branch: github-actions/upgrade-compiler-dependencies-2.x 81 | title: "chore(deps): upgrade compiler dependencies" 82 | labels: auto-approve 83 | body: |- 84 | Upgrades project dependencies. See details in [workflow run]. 85 | 86 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 87 | 88 | ------ 89 | 90 | *Automatically created by projen via the "upgrade-compiler-dependencies-2.x" workflow* 91 | author: github-actions 92 | committer: github-actions 93 | signoff: true 94 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-configuration-2.x.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: upgrade-configuration-2.x 4 | on: 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: 0 15 * * * 8 | jobs: 9 | upgrade: 10 | name: Upgrade 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | outputs: 15 | patch_created: ${{ steps.create_patch.outputs.patch_created }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | ref: 2.x 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | - name: Install dependencies 26 | run: yarn install --check-files --frozen-lockfile 27 | - name: Upgrade dependencies 28 | run: npx projen upgrade-configuration 29 | - name: Find mutations 30 | id: create_patch 31 | run: |- 32 | git add . 33 | git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT 34 | working-directory: ./ 35 | - name: Upload patch 36 | if: steps.create_patch.outputs.patch_created 37 | uses: actions/upload-artifact@v4.4.0 38 | with: 39 | name: repo.patch 40 | path: repo.patch 41 | overwrite: true 42 | pr: 43 | name: Create Pull Request 44 | needs: upgrade 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | if: ${{ needs.upgrade.outputs.patch_created }} 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | with: 53 | ref: 2.x 54 | - name: Download patch 55 | uses: actions/download-artifact@v4 56 | with: 57 | name: repo.patch 58 | path: ${{ runner.temp }} 59 | - name: Apply patch 60 | run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."' 61 | - name: Set git identity 62 | run: |- 63 | git config user.name "github-actions" 64 | git config user.email "github-actions@github.com" 65 | - name: Create Pull Request 66 | id: create-pr 67 | uses: peter-evans/create-pull-request@v6 68 | with: 69 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 70 | commit-message: |- 71 | chore(deps): upgrade configuration 72 | 73 | Upgrades project dependencies. See details in [workflow run]. 74 | 75 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 76 | 77 | ------ 78 | 79 | *Automatically created by projen via the "upgrade-configuration-2.x" workflow* 80 | branch: github-actions/upgrade-configuration-2.x 81 | title: "chore(deps): upgrade configuration" 82 | labels: auto-approve 83 | body: |- 84 | Upgrades project dependencies. See details in [workflow run]. 85 | 86 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 87 | 88 | ------ 89 | 90 | *Automatically created by projen via the "upgrade-configuration-2.x" workflow* 91 | author: github-actions 92 | committer: github-actions 93 | signoff: true 94 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-dev-dependencies-2.x.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: upgrade-dev-dependencies-2.x 4 | on: 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: 0 9 * * * 8 | jobs: 9 | upgrade: 10 | name: Upgrade 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | outputs: 15 | patch_created: ${{ steps.create_patch.outputs.patch_created }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | ref: 2.x 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | - name: Install dependencies 26 | run: yarn install --check-files --frozen-lockfile 27 | - name: Upgrade dependencies 28 | run: npx projen upgrade-dev-dependencies 29 | - name: Find mutations 30 | id: create_patch 31 | run: |- 32 | git add . 33 | git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT 34 | working-directory: ./ 35 | - name: Upload patch 36 | if: steps.create_patch.outputs.patch_created 37 | uses: actions/upload-artifact@v4.4.0 38 | with: 39 | name: repo.patch 40 | path: repo.patch 41 | overwrite: true 42 | pr: 43 | name: Create Pull Request 44 | needs: upgrade 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | if: ${{ needs.upgrade.outputs.patch_created }} 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | with: 53 | ref: 2.x 54 | - name: Download patch 55 | uses: actions/download-artifact@v4 56 | with: 57 | name: repo.patch 58 | path: ${{ runner.temp }} 59 | - name: Apply patch 60 | run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."' 61 | - name: Set git identity 62 | run: |- 63 | git config user.name "github-actions" 64 | git config user.email "github-actions@github.com" 65 | - name: Create Pull Request 66 | id: create-pr 67 | uses: peter-evans/create-pull-request@v6 68 | with: 69 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 70 | commit-message: |- 71 | chore(deps): upgrade dev dependencies 72 | 73 | Upgrades project dependencies. See details in [workflow run]. 74 | 75 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 76 | 77 | ------ 78 | 79 | *Automatically created by projen via the "upgrade-dev-dependencies-2.x" workflow* 80 | branch: github-actions/upgrade-dev-dependencies-2.x 81 | title: "chore(deps): upgrade dev dependencies" 82 | labels: auto-approve 83 | body: |- 84 | Upgrades project dependencies. See details in [workflow run]. 85 | 86 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 87 | 88 | ------ 89 | 90 | *Automatically created by projen via the "upgrade-dev-dependencies-2.x" workflow* 91 | author: github-actions 92 | committer: github-actions 93 | signoff: true 94 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-runtime-dependencies-2.x.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: upgrade-runtime-dependencies-2.x 4 | on: 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: 0 6 * * * 8 | jobs: 9 | upgrade: 10 | name: Upgrade 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | outputs: 15 | patch_created: ${{ steps.create_patch.outputs.patch_created }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | ref: 2.x 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | - name: Install dependencies 26 | run: yarn install --check-files --frozen-lockfile 27 | - name: Upgrade dependencies 28 | run: npx projen upgrade-runtime-dependencies 29 | - name: Find mutations 30 | id: create_patch 31 | run: |- 32 | git add . 33 | git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT 34 | working-directory: ./ 35 | - name: Upload patch 36 | if: steps.create_patch.outputs.patch_created 37 | uses: actions/upload-artifact@v4.4.0 38 | with: 39 | name: repo.patch 40 | path: repo.patch 41 | overwrite: true 42 | pr: 43 | name: Create Pull Request 44 | needs: upgrade 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | if: ${{ needs.upgrade.outputs.patch_created }} 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | with: 53 | ref: 2.x 54 | - name: Download patch 55 | uses: actions/download-artifact@v4 56 | with: 57 | name: repo.patch 58 | path: ${{ runner.temp }} 59 | - name: Apply patch 60 | run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."' 61 | - name: Set git identity 62 | run: |- 63 | git config user.name "github-actions" 64 | git config user.email "github-actions@github.com" 65 | - name: Create Pull Request 66 | id: create-pr 67 | uses: peter-evans/create-pull-request@v6 68 | with: 69 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 70 | commit-message: |- 71 | chore(deps): upgrade runtime dependencies 72 | 73 | Upgrades project dependencies. See details in [workflow run]. 74 | 75 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 76 | 77 | ------ 78 | 79 | *Automatically created by projen via the "upgrade-runtime-dependencies-2.x" workflow* 80 | branch: github-actions/upgrade-runtime-dependencies-2.x 81 | title: "chore(deps): upgrade runtime dependencies" 82 | labels: auto-approve 83 | body: |- 84 | Upgrades project dependencies. See details in [workflow run]. 85 | 86 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 87 | 88 | ------ 89 | 90 | *Automatically created by projen via the "upgrade-runtime-dependencies-2.x" workflow* 91 | author: github-actions 92 | committer: github-actions 93 | signoff: true 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | !/.gitattributes 3 | !/.projen/tasks.json 4 | !/.projen/deps.json 5 | !/.projen/files.json 6 | !/.github/workflows/auto-queue.yml 7 | !/.github/workflows/pull-request-lint.yml 8 | !/.github/workflows/auto-approve.yml 9 | !/package.json 10 | !/LICENSE 11 | !/.npmignore 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | lerna-debug.log* 18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | lib-cov 24 | coverage 25 | *.lcov 26 | .nyc_output 27 | build/Release 28 | node_modules/ 29 | jspm_packages/ 30 | *.tsbuildinfo 31 | .eslintcache 32 | *.tgz 33 | .yarn-integrity 34 | .cache 35 | /test-reports/ 36 | junit.xml 37 | /coverage/ 38 | !/.github/workflows/build.yml 39 | /dist/changelog.md 40 | /dist/version.txt 41 | !/.github/workflows/release.yml 42 | !/.github/workflows/upgrade-runtime-dependencies-2.x.yml 43 | !/.github/pull_request_template.md 44 | !/test/ 45 | !/tsconfig.dev.json 46 | !/src/ 47 | /lib 48 | /dist/ 49 | !/.eslintrc.json 50 | .jsii 51 | tsconfig.json 52 | !/API.md 53 | !/CODE_OF_CONDUCT.md 54 | !/DCO 55 | !/git-hooks/prepare-commit-msg 56 | !/git-hooks/README.md 57 | !/git-hooks/setup.sh 58 | !/.github/ISSUE_TEMPLATE/config.yml 59 | !/SECURITY.md 60 | !/.github/workflows/security.yml 61 | !/.github/workflows/triage.yml 62 | !/.github/workflows/stale.yml 63 | !/.github/workflows/upgrade-configuration-2.x.yml 64 | !/.github/workflows/upgrade-dev-dependencies-2.x.yml 65 | !/.github/workflows/upgrade-compiler-dependencies-2.x.yml 66 | !/src/_loadurl.mjs 67 | !/.projenrc.js 68 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | /.projen/ 3 | /test-reports/ 4 | junit.xml 5 | /coverage/ 6 | permissions-backup.acl 7 | /dist/changelog.md 8 | /dist/version.txt 9 | /test/ 10 | /tsconfig.dev.json 11 | /src/ 12 | !/lib/ 13 | !/lib/**/*.js 14 | !/lib/**/*.d.ts 15 | dist 16 | /tsconfig.json 17 | /.github/ 18 | /.vscode/ 19 | /.idea/ 20 | /.projenrc.js 21 | tsconfig.tsbuildinfo 22 | /.eslintrc.json 23 | !.jsii 24 | /.gitattributes 25 | -------------------------------------------------------------------------------- /.projen/deps.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | { 4 | "name": "@cdk8s/projen-common", 5 | "type": "build" 6 | }, 7 | { 8 | "name": "@stylistic/eslint-plugin", 9 | "version": "^2", 10 | "type": "build" 11 | }, 12 | { 13 | "name": "@types/follow-redirects", 14 | "type": "build" 15 | }, 16 | { 17 | "name": "@types/jest", 18 | "type": "build" 19 | }, 20 | { 21 | "name": "@types/node", 22 | "version": "16.18.78", 23 | "type": "build" 24 | }, 25 | { 26 | "name": "@typescript-eslint/eslint-plugin", 27 | "version": "^8", 28 | "type": "build" 29 | }, 30 | { 31 | "name": "@typescript-eslint/parser", 32 | "version": "^8", 33 | "type": "build" 34 | }, 35 | { 36 | "name": "commit-and-tag-version", 37 | "version": "^12", 38 | "type": "build" 39 | }, 40 | { 41 | "name": "constructs", 42 | "version": "^10.0.0", 43 | "type": "build" 44 | }, 45 | { 46 | "name": "eslint-import-resolver-typescript", 47 | "type": "build" 48 | }, 49 | { 50 | "name": "eslint-plugin-import", 51 | "type": "build" 52 | }, 53 | { 54 | "name": "eslint", 55 | "version": "^9", 56 | "type": "build" 57 | }, 58 | { 59 | "name": "jest", 60 | "type": "build" 61 | }, 62 | { 63 | "name": "jest-junit", 64 | "version": "^16", 65 | "type": "build" 66 | }, 67 | { 68 | "name": "jsii-diff", 69 | "type": "build" 70 | }, 71 | { 72 | "name": "jsii-docgen", 73 | "version": "^10.5.0", 74 | "type": "build" 75 | }, 76 | { 77 | "name": "jsii-pacmak", 78 | "type": "build" 79 | }, 80 | { 81 | "name": "jsii-rosetta", 82 | "version": "^5", 83 | "type": "build" 84 | }, 85 | { 86 | "name": "jsii", 87 | "version": "^5", 88 | "type": "build" 89 | }, 90 | { 91 | "name": "json-schema-to-typescript", 92 | "type": "build" 93 | }, 94 | { 95 | "name": "projen", 96 | "type": "build" 97 | }, 98 | { 99 | "name": "ts-jest", 100 | "type": "build" 101 | }, 102 | { 103 | "name": "typescript", 104 | "type": "build" 105 | }, 106 | { 107 | "name": "fast-json-patch", 108 | "type": "bundled" 109 | }, 110 | { 111 | "name": "follow-redirects", 112 | "type": "bundled" 113 | }, 114 | { 115 | "name": "yaml", 116 | "type": "bundled" 117 | }, 118 | { 119 | "name": "@types/lodash", 120 | "version": "4.14.192", 121 | "type": "override" 122 | }, 123 | { 124 | "name": "**/downlevel-dts/**/typescript", 125 | "version": "~5.2.2", 126 | "type": "override" 127 | }, 128 | { 129 | "name": "wrap-ansi", 130 | "version": "7.0.0", 131 | "type": "override" 132 | }, 133 | { 134 | "name": "constructs", 135 | "version": "^10", 136 | "type": "peer" 137 | } 138 | ], 139 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 140 | } 141 | -------------------------------------------------------------------------------- /.projen/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | ".eslintrc.json", 4 | ".gitattributes", 5 | ".github/ISSUE_TEMPLATE/config.yml", 6 | ".github/pull_request_template.md", 7 | ".github/workflows/auto-approve.yml", 8 | ".github/workflows/auto-queue.yml", 9 | ".github/workflows/build.yml", 10 | ".github/workflows/pull-request-lint.yml", 11 | ".github/workflows/release.yml", 12 | ".github/workflows/security.yml", 13 | ".github/workflows/stale.yml", 14 | ".github/workflows/triage.yml", 15 | ".github/workflows/upgrade-compiler-dependencies-2.x.yml", 16 | ".github/workflows/upgrade-configuration-2.x.yml", 17 | ".github/workflows/upgrade-dev-dependencies-2.x.yml", 18 | ".github/workflows/upgrade-runtime-dependencies-2.x.yml", 19 | ".gitignore", 20 | ".projen/deps.json", 21 | ".projen/files.json", 22 | ".projen/tasks.json", 23 | "CODE_OF_CONDUCT.md", 24 | "DCO", 25 | "git-hooks/prepare-commit-msg", 26 | "git-hooks/README.md", 27 | "git-hooks/setup.sh", 28 | "LICENSE", 29 | "SECURITY.md", 30 | "tsconfig.dev.json" 31 | ], 32 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 33 | } 34 | -------------------------------------------------------------------------------- /.projenrc.js: -------------------------------------------------------------------------------- 1 | const { Cdk8sTeamJsiiProject } = require('@cdk8s/projen-common'); 2 | const { JsonFile, github } = require('projen'); 3 | 4 | const project = new Cdk8sTeamJsiiProject({ 5 | name: 'cdk8s', 6 | repoName: 'cdk8s-core', 7 | description: 'This is the core library of Cloud Development Kit (CDK) for Kubernetes (cdk8s). cdk8s apps synthesize into standard Kubernetes manifests which can be applied to any Kubernetes cluster.', 8 | projenUpgradeSecret: 'PROJEN_GITHUB_TOKEN', 9 | 10 | peerDeps: [ 11 | 'constructs@^10', 12 | ], 13 | 14 | bundledDeps: [ 15 | 'yaml', 16 | 'follow-redirects', 17 | 'fast-json-patch', 18 | ], 19 | devDeps: [ 20 | 'constructs', 21 | '@types/follow-redirects', 22 | 'json-schema-to-typescript', 23 | '@cdk8s/projen-common', 24 | ], 25 | 26 | keywords: [ 27 | 'cdk', 28 | 'kubernetes', 29 | 'k8s', 30 | 'constructs', 31 | 'containers', 32 | 'configuration', 33 | 'microservices', 34 | ], 35 | 36 | defaultReleaseBranch: '2.x', 37 | majorVersion: 2, 38 | golangBranch: '2.x', 39 | jsiiVersion: '^5', 40 | }); 41 | 42 | // _loadurl.mjs is written in javascript so we need to commit it and also copy it 43 | // after compilation to the `lib/` directory. 44 | project.gitignore.include('/src/_loadurl.mjs'); 45 | project.compileTask.exec('cp src/_loadurl.mjs lib/'); 46 | 47 | const installHelm = project.addTask('install-helm', { 48 | exec: 'curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash', 49 | description: 'Install helm3', 50 | 51 | // will exit with non-zero if helm is not installed or has the wrong version 52 | condition: '! (helm version | grep "v3.")', 53 | }); 54 | 55 | project.testTask.prependSpawn(installHelm); 56 | 57 | const docgenTask = project.tasks.tryFind('docgen'); 58 | docgenTask.reset(); 59 | docgenTask.exec('jsii-docgen -l typescript -o docs/typescript.md'); 60 | docgenTask.exec('jsii-docgen -l python -o docs/python.md'); 61 | docgenTask.exec('jsii-docgen -l java -o docs/java.md'); 62 | 63 | // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/64924 64 | project.package.addPackageResolutions('@types/lodash@4.14.192'); 65 | 66 | // not sure why this is needed, but some dependencies have a transient dependency 67 | // on wrap-ansi@8 which is an ESM module. When performing `yarn upgrade npm-check-updates` 68 | // yarn gets confused somehow and uses the @8 one which causes things to break 69 | project.package.addPackageResolutions('wrap-ansi@7.0.0'); 70 | 71 | project.synth(); 72 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | The cdk8s project follows the [CNCF Community Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | ### Developer Certificate Of Origin (DCO) 43 | 44 | Every commit should be signed-off in compliance with the [Developer Certificate Of Origin](./DCO). 45 | You can sign your commits by using the `git commit -s` command. 46 | 47 | > To configure automatic signoff, see [git-hooks](./git-hooks/README.md). 48 | 49 | ## Finding contributions to work on 50 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 51 | 52 | 53 | ## Code of Conduct 54 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 55 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 56 | opensource-codeofconduct@amazon.com with any additional questions or comments. 57 | 58 | 59 | ## Security issue notifications 60 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 61 | 62 | 63 | ## Licensing 64 | 65 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 66 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 6 | Everyone is permitted to copy and distribute verbatim copies of this 7 | license document, but changing it is not allowed. 8 | 9 | 10 | Developer's Certificate of Origin 1.1 11 | 12 | By making a contribution to this project, I certify that: 13 | 14 | (a) The contribution was created in whole or in part by me and I 15 | have the right to submit it under the open source license 16 | indicated in the file; or 17 | 18 | (b) The contribution is based upon previous work that, to the best 19 | of my knowledge, is covered under an appropriate open source 20 | license and I have the right under that license to submit that 21 | work with modifications, whether created in whole or in part 22 | by me, under the same open source license (unless I am 23 | permitted to submit under a different license), as indicated 24 | in the file; or 25 | 26 | (c) The contribution was provided directly to me by some other 27 | person who certified (a), (b) or (c) and I have not modified 28 | it. 29 | 30 | (d) I understand and agree that this project and the contribution 31 | are public and that a record of the contribution (including all 32 | personal information I submit with it, including my sign-off) is 33 | maintained indefinitely and may be redistributed consistent with 34 | this project or the open source license(s) involved. -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cdk8s 2 | 3 | ### Cloud Development Kit for Kubernetes 4 | 5 | [![build](https://github.com/cdk8s-team/cdk8s-core/workflows/release/badge.svg)](https://github.com/cdk8s-team/cdk8s-core/actions/workflows/release.yml) 6 | [![npm version](https://badge.fury.io/js/cdk8s.svg)](https://badge.fury.io/js/cdk8s) 7 | [![PyPI version](https://badge.fury.io/py/cdk8s.svg)](https://badge.fury.io/py/cdk8s) 8 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.cdk8s/cdk8s/badge.svg)](https://maven-badges.herokuapp.com/maven-central/org.cdk8s/cdk8s) 9 | 10 | **cdk8s** is a software development framework for defining Kubernetes 11 | applications using rich object-oriented APIs. It allows developers to leverage 12 | the full power of software in order to define abstract components called 13 | "constructs" which compose Kubernetes resources or other constructs into 14 | higher-level abstractions. 15 | 16 | > **Note:** This repository is the "core library" of cdk8s, with logic for synthesizing Kubernetes manifests using the [constructs framework](https://github.com/aws/constructs). It is published to NPM as [`cdk8s`](https://www.npmjs.com/package/cdk8s) and should not be confused with the cdk8s command-line tool [`cdk8s-cli`](https://www.npmjs.com/package/cdk8s-cli). For more general information about cdk8s, please see [cdk8s.io](https://cdk8s.io), or visit the umbrella repository located at [cdk8s-team/cdk8s](https://github.com/cdk8s-team/cdk8s). 17 | 18 | ## Documentation 19 | 20 | See [cdk8s.io](https://cdk8s.io). 21 | 22 | ## License 23 | 24 | This project is distributed under the [Apache License, Version 2.0](./LICENSE). 25 | 26 | This module is part of the [cdk8s project](https://github.com/cdk8s-team/cdk8s). 27 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting a Vulnerability 2 | 3 | If you discover a potential security issue in this project we ask that you notify the cdk8s team directly via email to cncf-cdk8s-security@lists.cncf.io. 4 | 5 | **Please do not create a public GitHub issue.** -------------------------------------------------------------------------------- /git-hooks/README.md: -------------------------------------------------------------------------------- 1 | # Git Hooks 2 | 3 | This directory contains git hooks that the core team uses for various tasks. 4 | 5 | - Commit signoff for automatic compliance of the [DCO](../CONTRIBUTING.md#developer-certificate-of-origin-dco). 6 | 7 | ## Setup 8 | 9 | To setup these git hooks, run `./git-hooks/setup.sh` from the root directory of the project. 10 | -------------------------------------------------------------------------------- /git-hooks/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | NAME=$(git config user.name) 4 | EMAIL=$(git config user.email) 5 | 6 | if [ -z "$NAME" ]; then 7 | echo "empty git config user.name" 8 | exit 1 9 | fi 10 | 11 | if [ -z "$EMAIL" ]; then 12 | echo "empty git config user.email" 13 | exit 1 14 | fi 15 | 16 | git interpret-trailers --if-exists doNothing --trailer \ 17 | "Signed-off-by: $NAME <$EMAIL>" \ 18 | --in-place "$1" 19 | -------------------------------------------------------------------------------- /git-hooks/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ############################################## 4 | # Setup shared .git hooks for this project. 5 | # 6 | 7 | hooksdir="$(cd $(dirname $0) && pwd)" 8 | 9 | git config core.hooksPath ${hooksdir} 10 | echo "Configured core.hooksPath to ${hooksdir}" 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk8s", 3 | "description": "This is the core library of Cloud Development Kit (CDK) for Kubernetes (cdk8s). cdk8s apps synthesize into standard Kubernetes manifests which can be applied to any Kubernetes cluster.", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/cdk8s-team/cdk8s-core.git" 7 | }, 8 | "scripts": { 9 | "build": "npx projen build", 10 | "bump": "npx projen bump", 11 | "clobber": "npx projen clobber", 12 | "compat": "npx projen compat", 13 | "compile": "npx projen compile", 14 | "default": "npx projen default", 15 | "docgen": "npx projen docgen", 16 | "eject": "npx projen eject", 17 | "eslint": "npx projen eslint", 18 | "install-helm": "npx projen install-helm", 19 | "package": "npx projen package", 20 | "package-all": "npx projen package-all", 21 | "package:dotnet": "npx projen package:dotnet", 22 | "package:go": "npx projen package:go", 23 | "package:java": "npx projen package:java", 24 | "package:js": "npx projen package:js", 25 | "package:python": "npx projen package:python", 26 | "post-compile": "npx projen post-compile", 27 | "post-upgrade": "npx projen post-upgrade", 28 | "pre-compile": "npx projen pre-compile", 29 | "release:2.x": "npx projen release:2.x", 30 | "test": "npx projen test", 31 | "test:watch": "npx projen test:watch", 32 | "unbump": "npx projen unbump", 33 | "upgrade-compiler-dependencies": "npx projen upgrade-compiler-dependencies", 34 | "upgrade-configuration": "npx projen upgrade-configuration", 35 | "upgrade-dev-dependencies": "npx projen upgrade-dev-dependencies", 36 | "upgrade-runtime-dependencies": "npx projen upgrade-runtime-dependencies", 37 | "watch": "npx projen watch", 38 | "projen": "npx projen" 39 | }, 40 | "author": { 41 | "name": "Amazon Web Services", 42 | "url": "https://aws.amazon.com", 43 | "organization": false 44 | }, 45 | "devDependencies": { 46 | "@cdk8s/projen-common": "^0.0.606", 47 | "@stylistic/eslint-plugin": "^2", 48 | "@types/follow-redirects": "^1.14.4", 49 | "@types/jest": "^27", 50 | "@types/node": "16.18.78", 51 | "@typescript-eslint/eslint-plugin": "^8", 52 | "@typescript-eslint/parser": "^8", 53 | "commit-and-tag-version": "^12", 54 | "constructs": "10.0.0", 55 | "eslint": "^9", 56 | "eslint-import-resolver-typescript": "^2.7.1", 57 | "eslint-plugin-import": "^2.31.0", 58 | "jest": "^27", 59 | "jest-junit": "^16", 60 | "jsii": "^5", 61 | "jsii-diff": "^1.112.0", 62 | "jsii-docgen": "^10.5.0", 63 | "jsii-pacmak": "^1.112.0", 64 | "jsii-rosetta": "^5", 65 | "json-schema-to-typescript": "^10.1.5", 66 | "projen": "^0.92.9", 67 | "ts-jest": "^27", 68 | "typescript": "~5.8.3" 69 | }, 70 | "peerDependencies": { 71 | "constructs": "^10" 72 | }, 73 | "dependencies": { 74 | "fast-json-patch": "^3.1.1", 75 | "follow-redirects": "^1.15.9", 76 | "yaml": "2.8.0" 77 | }, 78 | "bundledDependencies": [ 79 | "fast-json-patch", 80 | "follow-redirects", 81 | "yaml" 82 | ], 83 | "resolutions": { 84 | "@types/lodash": "4.14.192", 85 | "**/downlevel-dts/**/typescript": "~5.2.2", 86 | "wrap-ansi": "7.0.0" 87 | }, 88 | "keywords": [ 89 | "cdk", 90 | "configuration", 91 | "constructs", 92 | "containers", 93 | "k8s", 94 | "kubernetes", 95 | "microservices" 96 | ], 97 | "engines": { 98 | "node": ">= 16.20.0" 99 | }, 100 | "main": "lib/index.js", 101 | "license": "Apache-2.0", 102 | "publishConfig": { 103 | "access": "public" 104 | }, 105 | "version": "0.0.0", 106 | "jest": { 107 | "coverageProvider": "v8", 108 | "testMatch": [ 109 | "/@(src|test)/**/*(*.)@(spec|test).ts?(x)", 110 | "/@(src|test)/**/__tests__/**/*.ts?(x)" 111 | ], 112 | "clearMocks": true, 113 | "collectCoverage": true, 114 | "coverageReporters": [ 115 | "json", 116 | "lcov", 117 | "clover", 118 | "cobertura", 119 | "text" 120 | ], 121 | "coverageDirectory": "coverage", 122 | "coveragePathIgnorePatterns": [ 123 | "/node_modules/" 124 | ], 125 | "testPathIgnorePatterns": [ 126 | "/node_modules/" 127 | ], 128 | "watchPathIgnorePatterns": [ 129 | "/node_modules/" 130 | ], 131 | "reporters": [ 132 | "default", 133 | [ 134 | "jest-junit", 135 | { 136 | "outputDirectory": "test-reports" 137 | } 138 | ] 139 | ], 140 | "preset": "ts-jest", 141 | "globals": { 142 | "ts-jest": { 143 | "tsconfig": "tsconfig.dev.json" 144 | } 145 | } 146 | }, 147 | "types": "lib/index.d.ts", 148 | "stability": "stable", 149 | "jsii": { 150 | "outdir": "dist", 151 | "targets": { 152 | "java": { 153 | "package": "org.cdk8s", 154 | "maven": { 155 | "groupId": "org.cdk8s", 156 | "artifactId": "cdk8s" 157 | } 158 | }, 159 | "python": { 160 | "distName": "cdk8s", 161 | "module": "cdk8s" 162 | }, 163 | "dotnet": { 164 | "namespace": "Org.Cdk8s", 165 | "packageId": "Org.Cdk8s" 166 | }, 167 | "go": { 168 | "moduleName": "github.com/cdk8s-team/cdk8s-core-go" 169 | } 170 | }, 171 | "tsc": { 172 | "outDir": "lib", 173 | "rootDir": "src" 174 | } 175 | }, 176 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 177 | } 178 | -------------------------------------------------------------------------------- /src/_child_process.ts: -------------------------------------------------------------------------------- 1 | /**************************************************************************************** 2 | * Expose `child_process` via our own object that can be easily patched by jest for tests. 3 | * Consumers of the `child_process` module should add functions to this object and import it 4 | * wherever needed. 5 | */ 6 | import { spawnSync } from 'child_process'; 7 | 8 | export const _child_process = { 9 | spawnSync: spawnSync, 10 | }; 11 | -------------------------------------------------------------------------------- /src/_loadurl.mjs: -------------------------------------------------------------------------------- 1 | // this is an executable program that downloads a file from a url (with 2 | // redirects) and prints it's output to STDOUT. it is implemented as a program 3 | // because node.js does not support issuing network requests synchronously and 4 | // in the cdk we need everything to be synchronous (it all happens in the 5 | // ctors). so we utilize `spawnSync` to spawn this program as a child process. 6 | // alternatively we could have use `curl` but this is more portable. 7 | 8 | import followRedirects from 'follow-redirects'; 9 | import { parse } from 'node:url'; 10 | import { lstatSync, createReadStream } from 'node:fs'; 11 | import process from 'node:process' 12 | 13 | const { http, https } = followRedirects; 14 | 15 | const url = process.argv[2]; 16 | if (!url) { 17 | console.error(`Usage: ${process.argv[1]} URL`); 18 | process.exit(1); 19 | } 20 | 21 | try { 22 | if (lstatSync(url).isFile()) { 23 | createReadStream(url).pipe(process.stdout); 24 | } 25 | } catch (err) { 26 | const purl = parse(url); 27 | 28 | if (!purl.protocol) { 29 | throw new Error(`unable to determine protocol from url: ${url}`); 30 | } 31 | 32 | const client = getHttpClient(purl.protocol); 33 | const get = client.get(url, response => { 34 | if (response.statusCode !== 200) { 35 | throw new Error(`${response.statusCode} response from http get: ${response.statusMessage}`); 36 | } 37 | 38 | response.on('data', chunk => process.stdout.write(chunk)); 39 | }); 40 | 41 | get.once('error', err => { 42 | throw new Error(`http error: ${err.message}`); 43 | }); 44 | } 45 | 46 | function getHttpClient(protocol) { 47 | switch (protocol) { 48 | case 'http:': return http; 49 | case 'https:': return https; 50 | default: 51 | throw new Error(`unsupported protocol "${protocol}" in url: ${url}`); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/_util.ts: -------------------------------------------------------------------------------- 1 | export interface SanitizeOptions { 2 | /** 3 | * Do not include empty objects (no keys). 4 | * @default false 5 | */ 6 | readonly filterEmptyObjects?: boolean; 7 | 8 | /** 9 | * Do not include arrays with no items. 10 | * @default false 11 | */ 12 | readonly filterEmptyArrays?: boolean; 13 | 14 | /** 15 | * Sort dictionary keys. 16 | * @default true 17 | */ 18 | readonly sortKeys?: boolean; 19 | } 20 | 21 | export function sanitizeValue(obj: any, options: SanitizeOptions = { }): any { 22 | if (obj == null) { 23 | return undefined; 24 | } 25 | 26 | if (typeof(obj) !== 'object') { 27 | return obj; 28 | } 29 | 30 | if (Array.isArray(obj)) { 31 | 32 | if (options.filterEmptyArrays && obj.length === 0) { 33 | return undefined; 34 | } 35 | 36 | return obj.map(x => sanitizeValue(x, options)); 37 | } 38 | 39 | if (obj.constructor.name !== 'Object') { 40 | throw new Error(`can't render non-simple object of type '${obj.constructor.name}'`); 41 | } 42 | 43 | const newObj: { [key: string]: any } = { }; 44 | 45 | const sortKeys = options.sortKeys ?? true; 46 | const keys = sortKeys ? Object.keys(obj).sort() : Object.keys(obj); 47 | for (const key of keys) { 48 | const value = obj[key]; 49 | const newValue = sanitizeValue(value, options); 50 | if (newValue != null) { 51 | newObj[key] = newValue; 52 | } 53 | } 54 | 55 | if (options.filterEmptyObjects && Object.keys(newObj).length === 0) { 56 | return undefined; 57 | } 58 | 59 | return newObj; 60 | } 61 | -------------------------------------------------------------------------------- /src/api-object.ts: -------------------------------------------------------------------------------- 1 | import { Construct, IConstruct } from 'constructs'; 2 | import { sanitizeValue } from './_util'; 3 | import { Chart } from './chart'; 4 | import { JsonPatch } from './json-patch'; 5 | import { ApiObjectMetadata, ApiObjectMetadataDefinition } from './metadata'; 6 | import { resolve } from './resolve'; 7 | 8 | /** 9 | * Options for defining API objects. 10 | */ 11 | export interface ApiObjectProps { 12 | /** 13 | * Object metadata. 14 | * 15 | * If `name` is not specified, an app-unique name will be allocated by the 16 | * framework based on the path of the construct within thes construct tree. 17 | */ 18 | readonly metadata?: ApiObjectMetadata; 19 | 20 | /** 21 | * API version. 22 | */ 23 | readonly apiVersion: string; 24 | 25 | /** 26 | * Resource kind. 27 | */ 28 | readonly kind: string; 29 | 30 | /** 31 | * Additional attributes for this API object. 32 | * @jsii ignore 33 | * @see https://github.com/cdk8s-team/cdk8s-core/issues/1297 34 | */ 35 | readonly [key: string]: any; 36 | } 37 | 38 | export interface GroupVersionKind { 39 | /** 40 | * The object's API version (e.g. `authorization.k8s.io/v1`) 41 | */ 42 | readonly apiVersion: string; 43 | 44 | /** 45 | * The object kind. 46 | */ 47 | readonly kind: string; 48 | } 49 | 50 | const API_OBJECT_SYMBOL = Symbol.for('cdk8s.ApiObject'); 51 | 52 | export class ApiObject extends Construct { 53 | 54 | /** 55 | * Return whether the given object is an `ApiObject`. 56 | * 57 | * We do attribute detection since we can't reliably use 'instanceof'. 58 | 59 | * @param o The object to check 60 | */ 61 | static isApiObject(o: any): o is ApiObject { 62 | return o !== null && typeof o === 'object' && API_OBJECT_SYMBOL in o; 63 | } 64 | 65 | /** 66 | * Implements `instanceof ApiObject` using the more reliable `ApiObject.isApiObject` static method 67 | * 68 | * @param o The object to check 69 | * @internal 70 | */ 71 | static [Symbol.hasInstance](o: unknown) { 72 | return ApiObject.isApiObject(o); 73 | } 74 | /** 75 | * Returns the `ApiObject` named `Resource` which is a child of the given 76 | * construct. If `c` is an `ApiObject`, it is returned directly. Throws an 77 | * exception if the construct does not have a child named `Default` _or_ if 78 | * this child is not an `ApiObject`. 79 | * 80 | * @param c The higher-level construct 81 | */ 82 | public static of(c: IConstruct): ApiObject { 83 | if (c instanceof ApiObject) { 84 | return c; 85 | } 86 | 87 | const child = c.node.defaultChild; 88 | if (!child) { 89 | throw new Error(`cannot find a (direct or indirect) child of type ApiObject for construct ${c.node.path}`); 90 | } 91 | 92 | return ApiObject.of(child); 93 | } 94 | 95 | /** 96 | * The name of the API object. 97 | * 98 | * If a name is specified in `metadata.name` this will be the name returned. 99 | * Otherwise, a name will be generated by calling 100 | * `Chart.of(this).generatedObjectName(this)`, which by default uses the 101 | * construct path to generate a DNS-compatible name for the resource. 102 | */ 103 | public readonly name: string; 104 | 105 | /** 106 | * The object's API version (e.g. `authorization.k8s.io/v1`) 107 | */ 108 | public readonly apiVersion: string; 109 | 110 | /** 111 | * The group portion of the API version (e.g. `authorization.k8s.io`) 112 | */ 113 | public readonly apiGroup: string; 114 | 115 | /** 116 | * The object kind. 117 | */ 118 | public readonly kind: string; 119 | 120 | /** 121 | * The chart in which this object is defined. 122 | */ 123 | public readonly chart: Chart; 124 | 125 | /** 126 | * Metadata associated with this API object. 127 | */ 128 | public readonly metadata: ApiObjectMetadataDefinition; 129 | 130 | /** 131 | * A set of JSON patch operations to apply to the document after synthesis. 132 | */ 133 | private readonly patches: Array; 134 | 135 | /** 136 | * Defines an API object. 137 | * 138 | * @param scope the construct scope 139 | * @param id namespace 140 | * @param props options 141 | */ 142 | constructor(scope: Construct, id: string, private readonly props: ApiObjectProps) { 143 | super(scope, id); 144 | this.patches = new Array(); 145 | this.chart = Chart.of(this); 146 | this.kind = props.kind; 147 | this.apiVersion = props.apiVersion; 148 | this.apiGroup = parseApiGroup(this.apiVersion); 149 | 150 | this.name = props.metadata?.name ?? this.chart.generateObjectName(this); 151 | 152 | this.metadata = new ApiObjectMetadataDefinition({ 153 | name: this.name, 154 | 155 | // user defined values 156 | ...props.metadata, 157 | 158 | namespace: props.metadata?.namespace ?? this.chart.namespace, 159 | labels: { 160 | ...this.chart.labels, 161 | ...props.metadata?.labels, 162 | }, 163 | apiObject: this, 164 | }); 165 | 166 | Object.defineProperty(this, API_OBJECT_SYMBOL, { value: true }); 167 | } 168 | 169 | /** 170 | * Create a dependency between this ApiObject and other constructs. 171 | * These can be other ApiObjects, Charts, or custom. 172 | * 173 | * @param dependencies the dependencies to add. 174 | */ 175 | public addDependency(...dependencies: IConstruct[]) { 176 | this.node.addDependency(...dependencies); 177 | } 178 | 179 | /** 180 | * Applies a set of RFC-6902 JSON-Patch operations to the manifest 181 | * synthesized for this API object. 182 | * 183 | * @param ops The JSON-Patch operations to apply. 184 | * 185 | * @example 186 | * 187 | * kubePod.addJsonPatch(JsonPatch.replace('/spec/enableServiceLinks', true)); 188 | * 189 | */ 190 | public addJsonPatch(...ops: JsonPatch[]) { 191 | this.patches.push(...ops); 192 | } 193 | 194 | /** 195 | * Renders the object to Kubernetes JSON. 196 | * 197 | * To disable sorting of dictionary keys in output object set the 198 | * `CDK8S_DISABLE_SORT` environment variable to any non-empty value. 199 | */ 200 | public toJson(): any { 201 | 202 | try { 203 | const data: any = { 204 | ...this.props, 205 | metadata: this.metadata.toJson(), 206 | }; 207 | 208 | const sortKeys = process.env.CDK8S_DISABLE_SORT ? false : true; 209 | const json = sanitizeValue(resolve([], data, this), { sortKeys }); 210 | const patched = JsonPatch.apply(json, ...this.patches); 211 | 212 | // reorder top-level keys so that we first have "apiVersion", "kind" and 213 | // "metadata" and then all the rest 214 | const result: any = {}; 215 | const orderedKeys = ['apiVersion', 'kind', 'metadata', ...Object.keys(patched)]; 216 | for (const k of orderedKeys) { 217 | if (k in patched) { 218 | result[k] = patched[k]; 219 | } 220 | } 221 | 222 | return result; 223 | } catch (e) { 224 | throw new Error(`Failed serializing construct at path '${this.node.path}' with name '${this.name}': ${e}`); 225 | } 226 | } 227 | } 228 | 229 | function parseApiGroup(apiVersion: string) { 230 | const v = apiVersion.split('/'); 231 | 232 | // no group means "core" 233 | // https://kubernetes.io/docs/reference/using-api/api-overview/#api-groups 234 | if (v.length === 1) { 235 | return 'core'; 236 | } 237 | 238 | if (v.length === 2) { 239 | return v[0]; 240 | } 241 | 242 | throw new Error(`invalid apiVersion ${apiVersion}, expecting GROUP/VERSION. See https://kubernetes.io/docs/reference/using-api/api-overview/#api-groups`); 243 | } 244 | -------------------------------------------------------------------------------- /src/chart.ts: -------------------------------------------------------------------------------- 1 | import { Construct, IConstruct } from 'constructs'; 2 | import { ApiObject } from './api-object'; 3 | import { App } from './app'; 4 | import { Names } from './names'; 5 | 6 | const CHART_SYMBOL = Symbol.for('cdk8s.Chart'); 7 | const CRONJOB = 'CronJob'; 8 | 9 | export interface ChartProps { 10 | /** 11 | * The default namespace for all objects defined in this chart (directly or 12 | * indirectly). This namespace will only apply to objects that don't have a 13 | * `namespace` explicitly defined for them. 14 | * 15 | * @default - no namespace is synthesized (usually this implies "default") 16 | */ 17 | readonly namespace?: string; 18 | 19 | /** 20 | * Labels to apply to all resources in this chart. 21 | * 22 | * @default - no common labels 23 | */ 24 | readonly labels?: { [name: string]: string }; 25 | 26 | /** 27 | * The autogenerated resource name by default is suffixed with a stable hash 28 | * of the construct path. Setting this property to true drops the hash suffix. 29 | * 30 | * @default false 31 | */ 32 | readonly disableResourceNameHashes?: boolean; 33 | 34 | } 35 | 36 | export class Chart extends Construct { 37 | /** 38 | * Return whether the given object is a Chart. 39 | * 40 | * We do attribute detection since we can't reliably use 'instanceof'. 41 | */ 42 | public static isChart(x: any): x is Chart { 43 | return x !== null && typeof(x) === 'object' && CHART_SYMBOL in x; 44 | } 45 | 46 | /** 47 | * Implements `instanceof Chart` using the more reliable `Chart.isChart` static method 48 | * 49 | * @param o The object to check 50 | * @internal 51 | */ 52 | static [Symbol.hasInstance](o: unknown) { 53 | return Chart.isChart(o); 54 | } 55 | 56 | /** 57 | * Finds the chart in which a node is defined. 58 | * @param c a construct node 59 | */ 60 | public static of(c: IConstruct): Chart { 61 | if (Chart.isChart(c)) { 62 | return c; 63 | } 64 | 65 | const parent = c.node.scope as Construct; 66 | if (!parent) { 67 | throw new Error('cannot find a parent chart (directly or indirectly)'); 68 | } 69 | 70 | return Chart.of(parent); 71 | } 72 | 73 | /** 74 | * The default namespace for all objects in this chart. 75 | */ 76 | public readonly namespace?: string; 77 | 78 | /** 79 | * Chart-level labels. 80 | */ 81 | private readonly _labels?: { [name: string]: string }; 82 | 83 | /** 84 | * Determines if resource names in the chart have the suffixed hash. 85 | */ 86 | private readonly _disableResourceNameHashes?: boolean; 87 | 88 | constructor(scope: Construct, id: string, props: ChartProps = { }) { 89 | super(scope, id); 90 | this.namespace = props.namespace; 91 | this._labels = props.labels ?? {}; 92 | this._disableResourceNameHashes = props.disableResourceNameHashes ?? false; 93 | 94 | Object.defineProperty(this, CHART_SYMBOL, { value: true }); 95 | } 96 | 97 | /** 98 | * Labels applied to all resources in this chart. 99 | * 100 | * This is an immutable copy. 101 | */ 102 | public get labels(): { [name: string]: string } { 103 | return { ...this._labels }; 104 | } 105 | 106 | /** 107 | * Generates a app-unique name for an object given it's construct node path. 108 | * 109 | * Different resource types may have different constraints on names 110 | * (`metadata.name`). The previous version of the name generator was 111 | * compatible with DNS_SUBDOMAIN but not with DNS_LABEL. 112 | * 113 | * For example, `Deployment` names must comply with DNS_SUBDOMAIN while 114 | * `Service` names must comply with DNS_LABEL. 115 | * 116 | * Since there is no formal specification for this, the default name 117 | * generation scheme for kubernetes objects in cdk8s was changed to DNS_LABEL, 118 | * since it’s the common denominator for all kubernetes resources 119 | * (supposedly). 120 | * 121 | * You can override this method if you wish to customize object names at the 122 | * chart level. 123 | * 124 | * @param apiObject The API object to generate a name for. 125 | */ 126 | public generateObjectName(apiObject: ApiObject) { 127 | return Names.toDnsLabel(apiObject, { 128 | includeHash: !this._disableResourceNameHashes, 129 | maxLen: apiObject.kind == CRONJOB ? 52 : undefined, 130 | }); 131 | } 132 | 133 | /** 134 | * Create a dependency between this Chart and other constructs. 135 | * These can be other ApiObjects, Charts, or custom. 136 | * 137 | * @param dependencies the dependencies to add. 138 | */ 139 | public addDependency(...dependencies: IConstruct[]) { 140 | this.node.addDependency(...dependencies); 141 | } 142 | 143 | /** 144 | * Renders this chart to a set of Kubernetes JSON resources. 145 | * @returns array of resource manifests 146 | */ 147 | public toJson(): any[] { 148 | return App._synthChart(this); 149 | } 150 | 151 | /** 152 | * Returns all the included API objects. 153 | */ 154 | get apiObjects(): ApiObject[] { 155 | return this.node.children.filter((o): o is ApiObject => o instanceof ApiObject); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/cron.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a cron schedule 3 | */ 4 | export class Cron { 5 | /** 6 | * Create a cron schedule which runs every minute 7 | */ 8 | public static everyMinute(): Cron { 9 | return Cron.schedule({ minute: '*', hour: '*', day: '*', month: '*', weekDay: '*' }); 10 | } 11 | 12 | /** 13 | * Create a cron schedule which runs every hour 14 | */ 15 | public static hourly(): Cron { 16 | return Cron.schedule({ minute: '0', hour: '*', day: '*', month: '*', weekDay: '*' }); 17 | } 18 | 19 | /** 20 | * Create a cron schedule which runs every day at midnight 21 | */ 22 | public static daily(): Cron { 23 | return Cron.schedule({ minute: '0', hour: '0', day: '*', month: '*', weekDay: '*' }); 24 | } 25 | 26 | /** 27 | * Create a cron schedule which runs every week on Sunday 28 | */ 29 | public static weekly(): Cron { 30 | return Cron.schedule({ minute: '0', hour: '0', day: '*', month: '*', weekDay: '0' }); 31 | } 32 | 33 | /** 34 | * Create a cron schedule which runs first day of every month 35 | */ 36 | public static monthly(): Cron { 37 | return Cron.schedule({ minute: '0', hour: '0', day: '1', month: '*', weekDay: '*' }); 38 | } 39 | 40 | /** 41 | * Create a cron schedule which runs first day of January every year 42 | */ 43 | public static annually(): Cron { 44 | return Cron.schedule({ minute: '0', hour: '0', day: '1', month: '1', weekDay: '*' }); 45 | } 46 | 47 | /** 48 | * Create a custom cron schedule from a set of cron fields 49 | */ 50 | public static schedule(options: CronOptions): Cron { 51 | return new Cron(options); 52 | }; 53 | 54 | /** 55 | * Retrieve the expression for this schedule 56 | */ 57 | public readonly expressionString: string; 58 | 59 | constructor(cronOptions: CronOptions = {}) { 60 | const minute = fallback(cronOptions.minute, '*'); 61 | const hour = fallback(cronOptions.hour, '*'); 62 | const month = fallback(cronOptions.month, '*'); 63 | const day = fallback(cronOptions.day, '*'); 64 | const weekDay = fallback(cronOptions.weekDay, '*'); 65 | 66 | this.expressionString = `${minute} ${hour} ${day} ${month} ${weekDay}`; 67 | } 68 | } 69 | 70 | /** 71 | * Options to configure a cron expression 72 | * 73 | * All fields are strings so you can use complex expressions. Absence of 74 | * a field implies '*' 75 | */ 76 | export interface CronOptions { 77 | /** 78 | * The minute to run this rule at 79 | * 80 | * @default - Every minute 81 | */ 82 | readonly minute?: string; 83 | 84 | /** 85 | * The hour to run this rule at 86 | * 87 | * @default - Every hour 88 | */ 89 | readonly hour?: string; 90 | 91 | /** 92 | * The day of the month to run this rule at 93 | * 94 | * @default - Every day of the month 95 | */ 96 | readonly day?: string; 97 | 98 | /** 99 | * The month to run this rule at 100 | * 101 | * @default - Every month 102 | */ 103 | readonly month?: string; 104 | 105 | /** 106 | * The day of the week to run this rule at 107 | * 108 | * @default - Any day of the week 109 | */ 110 | readonly weekDay?: string; 111 | } 112 | 113 | function fallback(x: T | undefined, def: T): T { 114 | return x ?? def; 115 | } -------------------------------------------------------------------------------- /src/dependency.ts: -------------------------------------------------------------------------------- 1 | import { Node, IConstruct } from 'constructs'; 2 | 3 | 4 | /** 5 | * Represents the dependency graph for a given Node. 6 | * 7 | * This graph includes the dependency relationships between all nodes in the 8 | * node (construct) sub-tree who's root is this Node. 9 | * 10 | * Note that this means that lonely nodes (no dependencies and no dependants) are also included in this graph as 11 | * childless children of the root node of the graph. 12 | * 13 | * The graph does not include cross-scope dependencies. That is, if a child on the current scope depends on a node 14 | * from a different scope, that relationship is not represented in this graph. 15 | * 16 | */ 17 | export class DependencyGraph { 18 | 19 | private readonly _fosterParent: DependencyVertex; 20 | 21 | constructor(node: Node) { 22 | 23 | this._fosterParent = new DependencyVertex(); 24 | 25 | const nodes: Record = {}; 26 | 27 | function putVertex(construct: IConstruct) { 28 | nodes[construct.node.path] = new DependencyVertex(construct); 29 | } 30 | 31 | function getVertex(construct: IConstruct): DependencyVertex { 32 | return nodes[construct.node.path]; 33 | } 34 | 35 | // create all vertices of the graph. 36 | for (const n of node.findAll()) { 37 | putVertex(n); 38 | } 39 | 40 | const deps = []; 41 | for (const child of node.findAll()) { 42 | for (const dep of child.node.dependencies) { 43 | deps.push({ source: child, target: dep }); 44 | } 45 | } 46 | 47 | // create all the edges of the graph. 48 | for (const dep of deps) { 49 | 50 | if (!getVertex(dep.target)) { 51 | // dont cross scope boundaries. 52 | // since charts only renders its own children, this is ok and 53 | // has the benefit of simplifying the graph. we should reconsider this behavior when moving 54 | // to a more general purpose use-case. 55 | continue; 56 | } 57 | 58 | const sourceDepNode = getVertex(dep.source); 59 | const targetDepNode = getVertex(dep.target); 60 | 61 | sourceDepNode.addChild(targetDepNode); 62 | 63 | } 64 | 65 | // create the root. 66 | for (const n of Object.values(nodes)) { 67 | if (n.inbound.length === 0) { 68 | // orphans are dependency roots. lets adopt them! 69 | this._fosterParent.addChild(n); 70 | } 71 | } 72 | 73 | } 74 | 75 | /** 76 | * Returns the root of the graph. 77 | * 78 | * Note that this vertex will always have `null` as its `.value` since it is an artifical root 79 | * that binds all the connected spaces of the graph. 80 | */ 81 | public get root(): DependencyVertex { 82 | return this._fosterParent; 83 | } 84 | 85 | /** 86 | * @see Vertex.topology() 87 | */ 88 | public topology(): IConstruct[] { 89 | return this._fosterParent.topology(); 90 | } 91 | } 92 | 93 | /** 94 | * Represents a vertex in the graph. 95 | * 96 | * The value of each vertex is an `IConstruct` that is accessible via the `.value` getter. 97 | */ 98 | export class DependencyVertex { 99 | 100 | private readonly _value: IConstruct | undefined; 101 | private readonly _children: Set = new Set(); 102 | private readonly _parents: Set = new Set(); 103 | 104 | constructor(value: IConstruct | undefined = undefined) { 105 | this._value = value; 106 | } 107 | 108 | /** 109 | * Returns the IConstruct this graph vertex represents. 110 | * 111 | * `null` in case this is the root of the graph. 112 | */ 113 | public get value(): IConstruct | undefined { 114 | return this._value; 115 | } 116 | 117 | /** 118 | * Returns the children of the vertex (i.e dependencies) 119 | */ 120 | public get outbound(): Array { 121 | return Array.from(this._children); 122 | } 123 | 124 | /** 125 | * Returns the parents of the vertex (i.e dependants) 126 | */ 127 | public get inbound(): Array { 128 | return Array.from(this._parents); 129 | } 130 | 131 | /** 132 | * Returns a topologically sorted array of the constructs in the sub-graph. 133 | */ 134 | public topology(): IConstruct[] { 135 | 136 | const found = new Set(); 137 | const topology: DependencyVertex[] = []; 138 | 139 | function visit(n: DependencyVertex) { 140 | for (const c of n.outbound) { 141 | visit(c); 142 | } 143 | if (!found.has(n)) { 144 | topology.push(n); 145 | found.add(n); 146 | } 147 | } 148 | 149 | visit(this); 150 | 151 | return topology.filter(d => d.value).map(d => d.value!); 152 | 153 | } 154 | 155 | /** 156 | * Adds a vertex as a dependency of the current node. 157 | * Also updates the parents of `dep`, so that it contains this node as a parent. 158 | * 159 | * This operation will fail in case it creates a cycle in the graph. 160 | * 161 | * @param dep The dependency 162 | */ 163 | public addChild(dep: DependencyVertex) { 164 | 165 | const cycle: DependencyVertex[] = dep.findRoute(this); 166 | if (cycle.length !== 0) { 167 | cycle.push(dep); 168 | throw new Error(`Dependency cycle detected: ${cycle.filter(d => d.value).map(d => d.value!.node.path).join(' => ')}`); 169 | } 170 | 171 | this._children.add(dep); 172 | dep.addParent(this); 173 | } 174 | 175 | private addParent(dep: DependencyVertex) { 176 | this._parents.add(dep); 177 | } 178 | 179 | private findRoute(dst: DependencyVertex): DependencyVertex[] { 180 | 181 | const route: DependencyVertex[] = []; 182 | visit(this); 183 | return route; 184 | 185 | function visit(n: DependencyVertex): boolean { 186 | route.push(n); 187 | let found = false; 188 | for (const c of n.outbound) { 189 | if (c === dst) { 190 | route.push(c); 191 | return true; 192 | } 193 | found = visit(c); 194 | } 195 | if (!found) { 196 | route.pop(); 197 | } 198 | return found; 199 | 200 | } 201 | 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/duration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a length of time. 3 | * 4 | * The amount can be specified either as a literal value (e.g: `10`) which 5 | * cannot be negative. 6 | * 7 | */ 8 | export class Duration { 9 | /** 10 | * Create a Duration representing an amount of milliseconds 11 | * 12 | * @param amount the amount of Milliseconds the `Duration` will represent. 13 | * @returns a new `Duration` representing `amount` ms. 14 | */ 15 | public static millis(amount: number): Duration { 16 | return new Duration(amount, TimeUnit.Milliseconds); 17 | } 18 | 19 | /** 20 | * Create a Duration representing an amount of seconds 21 | * 22 | * @param amount the amount of Seconds the `Duration` will represent. 23 | * @returns a new `Duration` representing `amount` Seconds. 24 | */ 25 | public static seconds(amount: number): Duration { 26 | return new Duration(amount, TimeUnit.Seconds); 27 | } 28 | 29 | /** 30 | * Create a Duration representing an amount of minutes 31 | * 32 | * @param amount the amount of Minutes the `Duration` will represent. 33 | * @returns a new `Duration` representing `amount` Minutes. 34 | */ 35 | public static minutes(amount: number): Duration { 36 | return new Duration(amount, TimeUnit.Minutes); 37 | } 38 | 39 | /** 40 | * Create a Duration representing an amount of hours 41 | * 42 | * @param amount the amount of Hours the `Duration` will represent. 43 | * @returns a new `Duration` representing `amount` Hours. 44 | */ 45 | public static hours(amount: number): Duration { 46 | return new Duration(amount, TimeUnit.Hours); 47 | } 48 | 49 | /** 50 | * Create a Duration representing an amount of days 51 | * 52 | * @param amount the amount of Days the `Duration` will represent. 53 | * @returns a new `Duration` representing `amount` Days. 54 | */ 55 | public static days(amount: number): Duration { 56 | return new Duration(amount, TimeUnit.Days); 57 | } 58 | 59 | /** 60 | * Parse a period formatted according to the ISO 8601 standard 61 | * 62 | * @see https://www.iso.org/fr/standard/70907.html 63 | * @param duration an ISO-formtted duration to be parsed. 64 | * @returns the parsed `Duration`. 65 | */ 66 | public static parse(duration: string): Duration { 67 | const matches = duration.match(/^PT(?:(\d+)D)?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/); 68 | if (!matches) { 69 | throw new Error(`Not a valid ISO duration: ${duration}`); 70 | } 71 | const [, days, hours, minutes, seconds] = matches; 72 | if (!days && !hours && !minutes && !seconds) { 73 | throw new Error(`Not a valid ISO duration: ${duration}`); 74 | } 75 | return Duration.millis( 76 | _toInt(seconds) * TimeUnit.Seconds.inMillis 77 | + (_toInt(minutes) * TimeUnit.Minutes.inMillis) 78 | + (_toInt(hours) * TimeUnit.Hours.inMillis) 79 | + (_toInt(days) * TimeUnit.Days.inMillis), 80 | ); 81 | 82 | function _toInt(str: string): number { 83 | if (!str) { return 0; } 84 | return Number(str); 85 | } 86 | } 87 | 88 | private readonly amount: number; 89 | private readonly unit: TimeUnit; 90 | 91 | private constructor(amount: number, unit: TimeUnit) { 92 | if (amount < 0) { 93 | throw new Error(`Duration amounts cannot be negative. Received: ${amount}`); 94 | } 95 | 96 | this.amount = amount; 97 | this.unit = unit; 98 | } 99 | 100 | /** 101 | * Return the total number of milliseconds in this Duration 102 | * 103 | * @returns the value of this `Duration` expressed in Milliseconds. 104 | */ 105 | public toMilliseconds(opts: TimeConversionOptions = {}): number { 106 | return convert(this.amount, this.unit, TimeUnit.Milliseconds, opts); 107 | } 108 | 109 | /** 110 | * Return the total number of seconds in this Duration 111 | * 112 | * @returns the value of this `Duration` expressed in Seconds. 113 | */ 114 | public toSeconds(opts: TimeConversionOptions = {}): number { 115 | return convert(this.amount, this.unit, TimeUnit.Seconds, opts); 116 | } 117 | 118 | /** 119 | * Return the total number of minutes in this Duration 120 | * 121 | * @returns the value of this `Duration` expressed in Minutes. 122 | */ 123 | public toMinutes(opts: TimeConversionOptions = {}): number { 124 | return convert(this.amount, this.unit, TimeUnit.Minutes, opts); 125 | } 126 | 127 | /** 128 | * Return the total number of hours in this Duration 129 | * 130 | * @returns the value of this `Duration` expressed in Hours. 131 | */ 132 | public toHours(opts: TimeConversionOptions = {}): number { 133 | return convert(this.amount, this.unit, TimeUnit.Hours, opts); 134 | } 135 | 136 | /** 137 | * Return the total number of days in this Duration 138 | * 139 | * @returns the value of this `Duration` expressed in Days. 140 | */ 141 | public toDays(opts: TimeConversionOptions = {}): number { 142 | return convert(this.amount, this.unit, TimeUnit.Days, opts); 143 | } 144 | 145 | /** 146 | * Return an ISO 8601 representation of this period 147 | * 148 | * @returns a string starting with 'PT' describing the period 149 | * @see https://www.iso.org/fr/standard/70907.html 150 | */ 151 | public toIsoString(): string { 152 | if (this.amount === 0) { return 'PT0S'; } 153 | switch (this.unit) { 154 | case TimeUnit.Seconds: return `PT${this.fractionDuration('S', 60, Duration.minutes)}`; 155 | case TimeUnit.Minutes: return `PT${this.fractionDuration('M', 60, Duration.hours)}`; 156 | case TimeUnit.Hours: return `PT${this.fractionDuration('H', 24, Duration.days)}`; 157 | case TimeUnit.Days: return `PT${this.amount}D`; 158 | default: 159 | throw new Error(`Unexpected time unit: ${this.unit}`); 160 | } 161 | } 162 | 163 | /** 164 | * Turn this duration into a human-readable string 165 | */ 166 | public toHumanString(): string { 167 | if (this.amount === 0) { return fmtUnit(0, this.unit); } 168 | 169 | let millis = convert(this.amount, this.unit, TimeUnit.Milliseconds, { integral: false }); 170 | const parts = new Array(); 171 | 172 | for (const unit of [TimeUnit.Days, TimeUnit.Hours, TimeUnit.Hours, TimeUnit.Minutes, TimeUnit.Seconds]) { 173 | const wholeCount = Math.floor(convert(millis, TimeUnit.Milliseconds, unit, { integral: false })); 174 | if (wholeCount > 0) { 175 | parts.push(fmtUnit(wholeCount, unit)); 176 | millis -= wholeCount * unit.inMillis; 177 | } 178 | } 179 | 180 | // Remainder in millis 181 | if (millis > 0) { 182 | parts.push(fmtUnit(millis, TimeUnit.Milliseconds)); 183 | } 184 | 185 | // 2 significant parts, that's totally enough for humans 186 | return parts.slice(0, 2).join(' '); 187 | 188 | function fmtUnit(amount: number, unit: TimeUnit) { 189 | if (amount === 1) { 190 | // All of the labels end in 's' 191 | return `${amount} ${unit.label.substring(0, unit.label.length - 1)}`; 192 | } 193 | return `${amount} ${unit.label}`; 194 | } 195 | } 196 | 197 | /** 198 | * Return unit of Duration 199 | */ 200 | public unitLabel(): string { 201 | return this.unit.toString(); 202 | } 203 | 204 | private fractionDuration(symbol: string, modulus: number, next: (amount: number) => Duration): string { 205 | if (this.amount < modulus) { 206 | return `${this.amount}${symbol}`; 207 | } 208 | const remainder = this.amount % modulus; 209 | const quotient = next((this.amount - remainder) / modulus).toIsoString().slice(2); 210 | return remainder > 0 211 | ? `${quotient}${remainder}${symbol}` 212 | : quotient; 213 | } 214 | } 215 | 216 | /** 217 | * Options for how to convert time to a different unit. 218 | */ 219 | export interface TimeConversionOptions { 220 | /** 221 | * If `true`, conversions into a larger time unit (e.g. `Seconds` to `Minutes`) will fail if the result is not an 222 | * integer. 223 | * 224 | * @default true 225 | */ 226 | readonly integral?: boolean; 227 | } 228 | 229 | class TimeUnit { 230 | public static readonly Milliseconds = new TimeUnit('millis', 1); 231 | public static readonly Seconds = new TimeUnit('seconds', 1_000); 232 | public static readonly Minutes = new TimeUnit('minutes', 60_000); 233 | public static readonly Hours = new TimeUnit('hours', 3_600_000); 234 | public static readonly Days = new TimeUnit('days', 86_400_000); 235 | 236 | private constructor(public readonly label: string, public readonly inMillis: number) { 237 | // MAX_SAFE_INTEGER is 2^53, so by representing our duration in millis (the lowest 238 | // common unit) the highest duration we can represent is 239 | // 2^53 / 86*10^6 ~= 104 * 10^6 days (about 100 million days). 240 | } 241 | 242 | public toString() { 243 | return this.label; 244 | } 245 | } 246 | 247 | function convert(amount: number, fromUnit: TimeUnit, toUnit: TimeUnit, { integral = true }: TimeConversionOptions) { 248 | if (fromUnit.inMillis === toUnit.inMillis) { 249 | if (integral && !Number.isInteger(amount)) { 250 | throw new Error(`${amount} must be a whole number of ${toUnit}.`); 251 | } 252 | return amount; 253 | } 254 | const multiplier = fromUnit.inMillis / toUnit.inMillis; 255 | 256 | const value = amount * multiplier; 257 | if (!Number.isInteger(value) && integral) { 258 | throw new Error(`'${amount} ${fromUnit}' cannot be converted into a whole number of ${toUnit}.`); 259 | } 260 | return value; 261 | } -------------------------------------------------------------------------------- /src/helm.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | import { Construct } from 'constructs'; 5 | import { _child_process } from './_child_process'; 6 | import { Include } from './include'; 7 | import { Names } from './names'; 8 | import { Yaml } from './yaml'; 9 | 10 | const MAX_HELM_BUFFER = 10 * 1024 * 1024; 11 | 12 | /** 13 | * Options for `Helm`. 14 | */ 15 | export interface HelmProps { 16 | /** 17 | * The chart name to use. It can be a chart from a helm repository or a local directory. 18 | * 19 | * This name is passed to `helm template` and has all the relevant semantics. 20 | * 21 | * @example "./mysql" 22 | * @example "bitnami/redis" 23 | */ 24 | readonly chart: string; 25 | 26 | /** 27 | * Chart repository url where to locate the requested chart 28 | */ 29 | readonly repo?: string; 30 | 31 | /** 32 | * Version constraint for the chart version to use. 33 | * This constraint can be a specific tag (e.g. 1.1.1) 34 | * or it may reference a valid range (e.g. ^2.0.0). 35 | * If this is not specified, the latest version is used 36 | * 37 | * This name is passed to `helm template --version` and has all the relevant semantics. 38 | * 39 | * @example "1.1.1" 40 | * @example "^2.0.0" 41 | */ 42 | readonly version?: string; 43 | 44 | /** 45 | * Scope all resources in to a given namespace. 46 | */ 47 | readonly namespace?: string; 48 | 49 | /** 50 | * The release name. 51 | * 52 | * @see https://helm.sh/docs/intro/using_helm/#three-big-concepts 53 | * @default - if unspecified, a name will be allocated based on the construct path 54 | */ 55 | readonly releaseName?: string; 56 | 57 | /** 58 | * Values to pass to the chart. 59 | * 60 | * @default - If no values are specified, chart will use the defaults. 61 | */ 62 | readonly values?: { [key: string]: any }; 63 | 64 | /** 65 | * The local helm executable to use in order to create the manifest the chart. 66 | * 67 | * @default "helm" 68 | */ 69 | readonly helmExecutable?: string; 70 | 71 | /** 72 | * Additional flags to add to the `helm` execution. 73 | * 74 | * @default [] 75 | */ 76 | readonly helmFlags?: string[]; 77 | } 78 | 79 | /** 80 | * Represents a Helm deployment. 81 | * 82 | * Use this construct to import an existing Helm chart and incorporate it into your constructs. 83 | */ 84 | export class Helm extends Include { 85 | /** 86 | * The helm release name. 87 | */ 88 | public readonly releaseName: string; 89 | 90 | constructor(scope: Construct, id: string, props: HelmProps) { 91 | const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk8s-helm-')); 92 | 93 | const args = new Array(); 94 | args.push('template'); 95 | 96 | // values 97 | if (props.values && Object.keys(props.values).length > 0) { 98 | const valuesPath = path.join(workdir, 'overrides.yaml'); 99 | fs.writeFileSync(valuesPath, Yaml.stringify(props.values)); 100 | args.push('-f', valuesPath); 101 | } 102 | 103 | if (props.repo) { 104 | args.push('--repo', props.repo); 105 | } 106 | 107 | if (props.version) { 108 | args.push('--version', props.version); 109 | } 110 | 111 | if (props.namespace) { 112 | args.push('--namespace', props.namespace); 113 | } 114 | 115 | // custom flags 116 | if (props.helmFlags) { 117 | args.push(...props.helmFlags); 118 | } 119 | 120 | // release name 121 | // constraints: https://github.com/helm/helm/issues/6006 122 | const releaseName = props.releaseName ?? Names.toDnsLabel(scope, { maxLen: 53, extra: [id] }); 123 | args.push(releaseName); 124 | 125 | // chart 126 | args.push(props.chart); 127 | 128 | const prog = props.helmExecutable ?? 'helm'; 129 | const outputFile = renderTemplate(workdir, prog, args); 130 | 131 | super(scope, id, { url: outputFile }); 132 | 133 | this.releaseName = releaseName; 134 | } 135 | } 136 | 137 | function renderTemplate(workdir: string, prog: string, args: string[]) { 138 | const helm = _child_process.spawnSync(prog, args, { 139 | maxBuffer: MAX_HELM_BUFFER, 140 | }); 141 | 142 | if (helm.error) { 143 | const err = helm.error.message; 144 | if (err.includes('ENOENT')) { 145 | throw new Error(`unable to execute '${prog}' to render Helm chart. Is it installed on your system?`); 146 | } 147 | 148 | throw new Error(`error while rendering a helm chart: ${err}`); 149 | } 150 | 151 | if (helm.status !== 0) { 152 | throw new Error(helm.stderr.toString()); 153 | } 154 | 155 | const outputFile = path.join(workdir, 'chart.yaml'); 156 | const stdout = helm.stdout; 157 | fs.writeFileSync(outputFile, stdout); 158 | return outputFile; 159 | } 160 | -------------------------------------------------------------------------------- /src/include.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import { ApiObject } from './api-object'; 3 | import { Yaml } from './yaml'; 4 | 5 | export interface IncludeProps { 6 | /** 7 | * Local file path or URL which includes a Kubernetes YAML manifest. 8 | * 9 | * @example mymanifest.yaml 10 | */ 11 | readonly url: string; 12 | } 13 | 14 | /** 15 | * Reads a YAML manifest from a file or a URL and defines all resources as API 16 | * objects within the defined scope. 17 | * 18 | * The names (`metadata.name`) of imported resources will be preserved as-is 19 | * from the manifest. 20 | */ 21 | export class Include extends Construct { 22 | constructor(scope: Construct, id: string, props: IncludeProps) { 23 | super(scope, id); 24 | 25 | const objects = Yaml.load(props.url); 26 | 27 | let order = 0; 28 | for (const obj of objects) { 29 | const objname = obj.metadata?.name ?? `object${order++}`; 30 | 31 | // render an id: name[-kind][-namespace] 32 | const objid = [objname, obj.kind?.toLowerCase(), obj.metadata?.namespace].filter(x => x).join('-'); 33 | new ApiObject(this, objid, obj); 34 | } 35 | } 36 | 37 | /** 38 | * Returns all the included API objects. 39 | */ 40 | public get apiObjects(): ApiObject[] { 41 | return this.node.children.filter((o): o is ApiObject => o instanceof ApiObject); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api-object'; 2 | export * from './chart'; 3 | export * from './dependency'; 4 | export * from './testing'; 5 | export * from './app'; 6 | export * from './include'; 7 | export * from './yaml'; 8 | export * from './metadata'; 9 | export * from './lazy'; 10 | export * from './names'; 11 | export * from './helm'; 12 | export * from './json-patch'; 13 | export * from './duration'; 14 | export * from './cron'; 15 | export * from './size'; 16 | export * from './resolve'; -------------------------------------------------------------------------------- /src/json-patch.ts: -------------------------------------------------------------------------------- 1 | import { applyPatch, deepClone, Operation } from 'fast-json-patch'; 2 | 3 | /** 4 | * Utility for applying RFC-6902 JSON-Patch to a document. 5 | * 6 | * Use the the `JsonPatch.apply(doc, ...ops)` function to apply a set of 7 | * operations to a JSON document and return the result. 8 | * 9 | * Operations can be created using the factory methods `JsonPatch.add()`, 10 | * `JsonPatch.remove()`, etc. 11 | * 12 | * @example 13 | * 14 | *const output = JsonPatch.apply(input, 15 | * JsonPatch.replace('/world/hi/there', 'goodbye'), 16 | * JsonPatch.add('/world/foo/', 'boom'), 17 | * JsonPatch.remove('/hello')); 18 | * 19 | */ 20 | export class JsonPatch { 21 | /** 22 | * Applies a set of JSON-Patch (RFC-6902) operations to `document` and returns the result. 23 | * @param document The document to patch 24 | * @param ops The operations to apply 25 | * @returns The result document 26 | */ 27 | public static apply(document: any, ...ops: JsonPatch[]): any { 28 | const result = applyPatch(document, deepClone(ops.map(o => o._toJson()))); 29 | return result.newDocument; 30 | } 31 | 32 | /** 33 | * Adds a value to an object or inserts it into an array. In the case of an 34 | * array, the value is inserted before the given index. The - character can be 35 | * used instead of an index to insert at the end of an array. 36 | * 37 | * @example JsonPatch.add('/biscuits/1', { "name": "Ginger Nut" }) 38 | */ 39 | public static add(path: string, value: any) { return new JsonPatch({ op: 'add', path, value }); } 40 | 41 | /** 42 | * Removes a value from an object or array. 43 | * 44 | * @example JsonPatch.remove('/biscuits') 45 | * @example JsonPatch.remove('/biscuits/0') 46 | */ 47 | public static remove(path: string) { return new JsonPatch({ op: 'remove', path }); } 48 | 49 | /** 50 | * Replaces a value. Equivalent to a “remove” followed by an “add”. 51 | * 52 | * @example JsonPatch.replace('/biscuits/0/name', 'Chocolate Digestive') 53 | */ 54 | public static replace(path: string, value: any) { return new JsonPatch({ op: 'replace', path, value }); } 55 | 56 | /** 57 | * Copies a value from one location to another within the JSON document. Both 58 | * from and path are JSON Pointers. 59 | * 60 | * @example JsonPatch.copy('/biscuits/0', '/best_biscuit') 61 | */ 62 | public static copy(from: string, path: string) { return new JsonPatch({ op: 'copy', from, path }); } 63 | 64 | /** 65 | * Moves a value from one location to the other. Both from and path are JSON Pointers. 66 | * 67 | * @example JsonPatch.move('/biscuits', '/cookies') 68 | */ 69 | public static move(from: string, path: string) { return new JsonPatch({ op: 'move', from, path }); } 70 | 71 | /** 72 | * Tests that the specified value is set in the document. If the test fails, 73 | * then the patch as a whole should not apply. 74 | * 75 | * @example JsonPatch.test('/best_biscuit/name', 'Choco Leibniz') 76 | */ 77 | public static test(path: string, value: any) { return new JsonPatch({ op: 'test', path, value }); } 78 | 79 | private constructor(private readonly operation: Operation) {} 80 | 81 | /** 82 | * Returns the JSON representation of this JSON patch operation. 83 | * 84 | * @internal 85 | */ 86 | public _toJson(): any { 87 | return this.operation; 88 | } 89 | } -------------------------------------------------------------------------------- /src/lazy.ts: -------------------------------------------------------------------------------- 1 | export class Lazy { 2 | public static any(producer: IAnyProducer): any { 3 | return new Lazy(producer) as unknown as any; 4 | } 5 | 6 | private constructor(private readonly producer: IAnyProducer) {} 7 | 8 | public produce(): any { 9 | return this.producer.produce(); 10 | } 11 | } 12 | 13 | export interface IAnyProducer { 14 | produce(): any; 15 | } 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/names.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | import { Construct, Node } from 'constructs'; 3 | 4 | const MAX_LEN = 63; 5 | const VALIDATE = /^[0-9a-z-]+$/; 6 | const VALIDATE_LABEL_VALUE = /^(([0-9a-zA-Z][0-9a-zA-Z-_.]*)?[0-9a-zA-Z])?$/; 7 | const HASH_LEN = 8; 8 | 9 | /** 10 | * Options for name generation. 11 | */ 12 | export interface NameOptions { 13 | /** 14 | * Maximum allowed length for the name. 15 | * @default 63 16 | */ 17 | readonly maxLen?: number; 18 | 19 | /** 20 | * Extra components to include in the name. 21 | * @default [] use the construct path components 22 | */ 23 | readonly extra?: string[]; 24 | 25 | /** 26 | * Delimiter to use between components. 27 | * @default "-" 28 | */ 29 | readonly delimiter?: string; 30 | 31 | /** 32 | * Include a short hash as last part of the name. 33 | * @default true 34 | */ 35 | readonly includeHash?: boolean; 36 | } 37 | 38 | /** 39 | * Utilities for generating unique and stable names. 40 | */ 41 | export class Names { 42 | /** 43 | * Generates a unique and stable name compatible DNS_LABEL from RFC-1123 from 44 | * a path. 45 | * 46 | * The generated name will: 47 | * - contain at most 63 characters 48 | * - contain only lowercase alphanumeric characters or ‘-’ 49 | * - start with an alphanumeric character 50 | * - end with an alphanumeric character 51 | * 52 | * The generated name will have the form: 53 | * --..-- 54 | * 55 | * Where are the path components (assuming they are is separated by 56 | * "/"). 57 | * 58 | * Note that if the total length is longer than 63 characters, we will trim 59 | * the first components since the last components usually encode more meaning. 60 | * 61 | * @link https://tools.ietf.org/html/rfc1123 62 | * 63 | * @param scope The construct for which to render the DNS label 64 | * @param options Name options 65 | * @throws if any of the components do not adhere to naming constraints or 66 | * length. 67 | */ 68 | public static toDnsLabel(scope: Construct, options: NameOptions = { }) { 69 | const maxLen = options.maxLen ?? MAX_LEN; 70 | const delim = options.delimiter ?? '-'; 71 | const include_hash = options.includeHash ?? true; 72 | 73 | if (maxLen < HASH_LEN && include_hash) { 74 | throw new Error(`minimum max length for object names is ${HASH_LEN} (required for hash)`); 75 | } 76 | 77 | const node = scope.node; 78 | 79 | let components = node.path.split('/'); 80 | components.push(...options.extra ?? []); 81 | 82 | // special case: if we only have one component in our path and it adheres to DNS_NAME, we don't decorate it 83 | if (components.length === 1 && VALIDATE.test(components[0]) && components[0].length <= maxLen) { 84 | return components[0]; 85 | } 86 | 87 | // okay, now we need to normalize all components to adhere to DNS_NAME and append the hash of the full path. 88 | components = components.map(c => normalizeToDnsName(c, maxLen)); 89 | if (include_hash) { 90 | components.push(calcHash(node, HASH_LEN)); 91 | } 92 | 93 | return toHumanForm(components, delim, maxLen); 94 | } 95 | 96 | /** 97 | * Generates a unique and stable name compatible label key name segment and 98 | * label value from a path. 99 | * 100 | * The name segment is required and must be 63 characters or less, beginning 101 | * and ending with an alphanumeric character ([a-z0-9A-Z]) with dashes (-), 102 | * underscores (_), dots (.), and alphanumerics between. 103 | * 104 | * Valid label values must be 63 characters or less and must be empty or 105 | * begin and end with an alphanumeric character ([a-z0-9A-Z]) with dashes 106 | * (-), underscores (_), dots (.), and alphanumerics between. 107 | * 108 | * The generated name will have the form: 109 | * .. 110 | * 111 | * Where are the path components (assuming they are is separated by 112 | * "/"). 113 | * 114 | * Note that if the total length is longer than 63 characters, we will trim 115 | * the first components since the last components usually encode more meaning. 116 | * 117 | * @link https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set 118 | * 119 | * @param scope The construct for which to render the DNS label 120 | * @param options Name options 121 | * @throws if any of the components do not adhere to naming constraints or 122 | * length. 123 | */ 124 | public static toLabelValue(scope: Construct, options: NameOptions = {}) { 125 | const maxLen = options.maxLen ?? MAX_LEN; 126 | const delim = options.delimiter ?? '-'; 127 | const include_hash = options.includeHash ?? true; 128 | 129 | if (maxLen < HASH_LEN && include_hash) { 130 | throw new Error(`minimum max length for label is ${HASH_LEN} (required for hash)`); 131 | } 132 | 133 | if (/[^0-9a-zA-Z-_.]/.test(delim)) { 134 | throw new Error('delim should not contain "[^0-9a-zA-Z-_.]"'); 135 | } 136 | 137 | const node = scope.node; 138 | let components = node.path.split('/'); 139 | components.push(...options.extra ?? []); 140 | 141 | // special case: if we only have one component in our path and it adheres to DNS_NAME, we don't decorate it 142 | if (components.length === 1 && VALIDATE_LABEL_VALUE.test(components[0]) && components[0].length <= maxLen) { 143 | return components[0]; 144 | } 145 | 146 | // okay, now we need to normalize all components to adhere to label and append the hash of the full path. 147 | components = components.map(c => normalizeToLabelValue(c, maxLen)); 148 | if (include_hash) { 149 | components.push(calcHash(node, HASH_LEN)); 150 | } 151 | 152 | const result = toHumanForm(components, delim, maxLen); 153 | 154 | // slicing might let '-', '_', '.' be in the start of the result. 155 | return result.replace(/^[^0-9a-zA-Z]+/, ''); 156 | } 157 | 158 | /* istanbul ignore next */ 159 | private constructor() { 160 | return; 161 | } 162 | } 163 | 164 | function omitDuplicates(value: string, index: number, components: string[]) { 165 | return value !== components[index-1]; 166 | } 167 | 168 | function omitDefaultChild(value: string, _: number, __: string[]) { 169 | return value.toLowerCase() !== 'resource' && value.toLowerCase() !== 'default'; 170 | } 171 | 172 | function toHumanForm(components: string[], delim: string, maxLen: number) { 173 | return components.reverse() 174 | .filter(omitDuplicates) 175 | .join('/') 176 | .slice(0, maxLen) 177 | .split('/') 178 | .reverse() 179 | .filter(x => x) 180 | .join(delim) 181 | .split(delim) 182 | .filter(x => x) 183 | .filter(omitDefaultChild) 184 | .join(delim); 185 | 186 | } 187 | 188 | function normalizeToDnsName(c: string, maxLen: number) { 189 | return c 190 | .toLocaleLowerCase() // lower case 191 | .replace(/[^0-9a-zA-Z-_.]/g, '') // remove non-allowed characters 192 | .substr(0, maxLen); // trim to maxLength 193 | } 194 | 195 | function calcHash(node: Node, maxLen: number) { 196 | if (process.env.CDK8S_LEGACY_HASH) { 197 | const hash = crypto.createHash('sha256'); 198 | hash.update(node.path); 199 | return hash.digest('hex').slice(0, maxLen); 200 | } 201 | 202 | return node.addr.substring(0, HASH_LEN); 203 | } 204 | 205 | function normalizeToLabelValue(c: string, maxLen: number) { 206 | return c 207 | .replace(/[^0-9a-zA-Z-_.]/g, '') // remove non-allowed characters 208 | .substr(0, maxLen); // trim to maxLength 209 | } 210 | -------------------------------------------------------------------------------- /src/resolve.ts: -------------------------------------------------------------------------------- 1 | import { ApiObject } from './api-object'; 2 | import { App } from './app'; 3 | import { Lazy } from './lazy'; 4 | 5 | /** 6 | * Context object for a specific resolution process. 7 | */ 8 | export class ResolutionContext { 9 | 10 | /** 11 | * The replaced value that was set via the `replaceValue` method. 12 | */ 13 | public replacedValue: any; 14 | 15 | /** 16 | * Whether or not the value was replaced by invoking the `replaceValue` method. 17 | */ 18 | public replaced: boolean = false; 19 | 20 | constructor( 21 | /** 22 | * Which ApiObject is currently being resolved. 23 | */ 24 | public readonly obj: ApiObject, 25 | /** 26 | * Which key is currently being resolved. 27 | */ 28 | public readonly key: string[], 29 | /** 30 | * The value associated to the key currently being resolved. 31 | */ 32 | public readonly value: any) { 33 | } 34 | 35 | /** 36 | * Replaces the original value in this resolution context 37 | * with a new value. The new value is what will end up in the manifest. 38 | */ 39 | public replaceValue(newValue: any) { 40 | this.replacedValue = newValue; 41 | this.replaced = true; 42 | } 43 | 44 | } 45 | 46 | /** 47 | * Contract for resolver objects. 48 | */ 49 | export interface IResolver { 50 | 51 | /** 52 | * This function is invoked on every property during cdk8s synthesis. 53 | * To replace a value, implementations must invoke `context.replaceValue`. 54 | */ 55 | resolve(context: ResolutionContext): void; 56 | } 57 | 58 | /** 59 | * Resolvers instanecs of `Lazy`. 60 | */ 61 | export class LazyResolver implements IResolver { 62 | 63 | public resolve(context: ResolutionContext): void { 64 | if (context.value instanceof Lazy) { 65 | const resolved = context.value.produce(); 66 | context.replaceValue(resolved); 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Resolves implicit tokens. 73 | */ 74 | export class ImplicitTokenResolver implements IResolver { 75 | 76 | public resolve(context: ResolutionContext): void { 77 | 78 | if (typeof (context.value.resolve) === 'function') { 79 | const resolved = context.value.resolve(); 80 | context.replaceValue(resolved); 81 | } 82 | 83 | } 84 | 85 | } 86 | 87 | /** 88 | * Resolves union types that allow using either number or string (as generated by the CLI). 89 | * 90 | * E.g IntOrString, Quantity, ... 91 | */ 92 | export class NumberStringUnionResolver implements IResolver { 93 | 94 | private static readonly TYPES = ['number', 'string']; 95 | 96 | public resolve(context: ResolutionContext): void { 97 | 98 | if (context.value.constructor === Object) { 99 | // we only want to resolve union classes, not plain dictionaries 100 | return; 101 | } 102 | 103 | if (NumberStringUnionResolver.TYPES.includes(typeof(context.value.value))) { 104 | // replace with a dictionary because the L1 proceeds to access the .value 105 | // property after resolution. 106 | context.replaceValue({ value: context.value.value }); 107 | } 108 | 109 | } 110 | 111 | } 112 | 113 | /** 114 | * Resolves any value attached to a specific ApiObject. 115 | */ 116 | export function resolve(key: string[], value: any, apiObject: ApiObject): any { 117 | 118 | const resolvers = App.of(apiObject).resolvers; 119 | 120 | if (value == null) { 121 | return value; 122 | } 123 | 124 | // give dibs to the resolvers as they are more specific 125 | const context = new ResolutionContext(apiObject, key, value); 126 | for (const resolver of resolvers) { 127 | resolver.resolve(context); 128 | if (context.replaced) { 129 | // stop when the first resolver replaces the value. 130 | return resolve(key, context.replacedValue, apiObject); 131 | } 132 | } 133 | 134 | // array - resolve each element 135 | if (Array.isArray(value)) { 136 | return value.map((x, i) => resolve([...key, `${i}`], x, apiObject)); 137 | } 138 | 139 | // dictionrary - resolve leafs 140 | if (value.constructor == Object) { 141 | const result: any = {}; 142 | for (const [k, v] of Object.entries(value)) { 143 | result[k] = resolve([...key, k], v, apiObject); 144 | } 145 | return result; 146 | } 147 | 148 | return value; 149 | 150 | } 151 | -------------------------------------------------------------------------------- /src/size.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the amount of digital storage. 3 | * 4 | * The amount can be specified either as a literal value (e.g: `10`) which 5 | * cannot be negative. 6 | * 7 | * When the amount is passed as a token, unit conversion is not possible. 8 | */ 9 | export class Size { 10 | /** 11 | * Create a Storage representing an amount kibibytes. 12 | * 1 KiB = 1024 bytes 13 | */ 14 | public static kibibytes(amount: number): Size { 15 | return new Size(amount, StorageUnit.Kibibytes); 16 | } 17 | 18 | /** 19 | * Create a Storage representing an amount mebibytes. 20 | * 1 MiB = 1024 KiB 21 | */ 22 | public static mebibytes(amount: number): Size { 23 | return new Size(amount, StorageUnit.Mebibytes); 24 | } 25 | 26 | /** 27 | * Create a Storage representing an amount gibibytes. 28 | * 1 GiB = 1024 MiB 29 | */ 30 | public static gibibytes(amount: number): Size { 31 | return new Size(amount, StorageUnit.Gibibytes); 32 | } 33 | 34 | /** 35 | * Create a Storage representing an amount tebibytes. 36 | * 1 TiB = 1024 GiB 37 | */ 38 | public static tebibytes(amount: number): Size { 39 | return new Size(amount, StorageUnit.Tebibytes); 40 | } 41 | 42 | /** 43 | * Create a Storage representing an amount pebibytes. 44 | * 1 PiB = 1024 TiB 45 | */ 46 | public static pebibyte(amount: number): Size { 47 | return new Size(amount, StorageUnit.Pebibytes); 48 | } 49 | 50 | private readonly amount: number; 51 | private readonly unit: StorageUnit; 52 | 53 | private constructor(amount: number, unit: StorageUnit) { 54 | if (amount < 0) { 55 | throw new Error(`Storage amounts cannot be negative. Received: ${amount}`); 56 | } 57 | this.amount = amount; 58 | this.unit = unit; 59 | } 60 | 61 | /** 62 | * Returns amount with abbreviated storage unit 63 | */ 64 | public asString(): string { 65 | return `${this.amount}${this.unit.abbr}`; 66 | } 67 | 68 | /** 69 | * Return this storage as a total number of kibibytes. 70 | */ 71 | public toKibibytes(opts: SizeConversionOptions = {}): number { 72 | return convert(this.amount, this.unit, StorageUnit.Kibibytes, opts); 73 | } 74 | 75 | /** 76 | * Return this storage as a total number of mebibytes. 77 | */ 78 | public toMebibytes(opts: SizeConversionOptions = {}): number { 79 | return convert(this.amount, this.unit, StorageUnit.Mebibytes, opts); 80 | } 81 | 82 | /** 83 | * Return this storage as a total number of gibibytes. 84 | */ 85 | public toGibibytes(opts: SizeConversionOptions = {}): number { 86 | return convert(this.amount, this.unit, StorageUnit.Gibibytes, opts); 87 | } 88 | 89 | /** 90 | * Return this storage as a total number of tebibytes. 91 | */ 92 | public toTebibytes(opts: SizeConversionOptions = {}): number { 93 | return convert(this.amount, this.unit, StorageUnit.Tebibytes, opts); 94 | } 95 | 96 | /** 97 | * Return this storage as a total number of pebibytes. 98 | */ 99 | public toPebibytes(opts: SizeConversionOptions = {}): number { 100 | return convert(this.amount, this.unit, StorageUnit.Pebibytes, opts); 101 | } 102 | } 103 | 104 | /** 105 | * Rounding behaviour when converting between units of `Size`. 106 | */ 107 | export enum SizeRoundingBehavior { 108 | /** Fail the conversion if the result is not an integer. */ 109 | FAIL, 110 | /** If the result is not an integer, round it to the closest integer less than the result */ 111 | FLOOR, 112 | /** Don't round. Return even if the result is a fraction. */ 113 | NONE, 114 | } 115 | 116 | /** 117 | * Options for how to convert size to a different unit. 118 | */ 119 | export interface SizeConversionOptions { 120 | /** 121 | * How conversions should behave when it encounters a non-integer result 122 | * @default SizeRoundingBehavior.FAIL 123 | */ 124 | readonly rounding?: SizeRoundingBehavior; 125 | } 126 | 127 | class StorageUnit { 128 | public static readonly Kibibytes = new StorageUnit('kibibytes', 1, 'Ki'); 129 | public static readonly Mebibytes = new StorageUnit('mebibytes', 1024, 'Mi'); 130 | public static readonly Gibibytes = new StorageUnit('gibibytes', 1024 * 1024, 'Gi'); 131 | public static readonly Tebibytes = new StorageUnit('tebibytes', 1024 * 1024 * 1024, 'Ti'); 132 | public static readonly Pebibytes = new StorageUnit('pebibytes', 1024 * 1024 * 1024 * 1024, 'Pi'); 133 | 134 | private constructor(public readonly label: string, public readonly inKibiBytes: number, public readonly abbr: string) { 135 | // MAX_SAFE_INTEGER is 2^53, so by representing storage in kibibytes, 136 | // the highest storage we can represent is 8 exbibytes. 137 | } 138 | 139 | public toString() { 140 | return this.label; 141 | } 142 | } 143 | 144 | function convert(amount: number, fromUnit: StorageUnit, toUnit: StorageUnit, options: SizeConversionOptions = {}) { 145 | const rounding = options.rounding ?? SizeRoundingBehavior.FAIL; 146 | if (fromUnit.inKibiBytes === toUnit.inKibiBytes) { 147 | return amount; 148 | } 149 | 150 | const multiplier = fromUnit.inKibiBytes / toUnit.inKibiBytes; 151 | const value = amount * multiplier; 152 | switch (rounding) { 153 | case SizeRoundingBehavior.NONE: 154 | return value; 155 | case SizeRoundingBehavior.FLOOR: 156 | return Math.floor(value); 157 | default: 158 | case SizeRoundingBehavior.FAIL: 159 | if (!Number.isInteger(value)) { 160 | throw new Error(`'${amount} ${fromUnit}' cannot be converted into a whole number of ${toUnit}.`); 161 | } 162 | return value; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/testing.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | import { App, AppProps } from './app'; 5 | import { Chart } from './chart'; 6 | 7 | /** 8 | * Testing utilities for cdk8s applications. 9 | */ 10 | export class Testing { 11 | /** 12 | * Returns an app for testing with the following properties: 13 | * - Output directory is a temp dir. 14 | */ 15 | public static app(props?: AppProps) { 16 | let outdir: string; 17 | if (props) { 18 | outdir = props.outdir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'cdk8s.outdir.')); 19 | } else { 20 | outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk8s.outdir.')); 21 | } 22 | return new App({ outdir, ...props }); 23 | } 24 | 25 | /** 26 | * @returns a Chart that can be used for tests 27 | */ 28 | public static chart() { 29 | return new Chart(this.app(), 'test'); 30 | } 31 | 32 | /** 33 | * Returns the Kubernetes manifest synthesized from this chart. 34 | */ 35 | public static synth(chart: Chart): any[] { 36 | return chart.toJson(); 37 | } 38 | 39 | /* istanbul ignore next */ 40 | private constructor() { 41 | return; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/yaml.ts: -------------------------------------------------------------------------------- 1 | import { execFileSync } from 'child_process'; 2 | import * as fs from 'fs'; 3 | import * as os from 'os'; 4 | import * as path from 'path'; 5 | import * as YAML from 'yaml'; 6 | 7 | const MAX_DOWNLOAD_BUFFER = 10 * 1024 * 1024; 8 | 9 | // Set default YAML schema to 1.1. This ensures saved YAML is backward compatible with other parsers, such as PyYAML 10 | // It also ensures that octal numbers in the form `0775` will be parsed 11 | // correctly on YAML load. (see https://github.com/eemeli/yaml/issues/205) 12 | const yamlSchemaVersion = '1.1'; 13 | 14 | /** 15 | * YAML utilities. 16 | */ 17 | export class Yaml { 18 | /** 19 | * @deprecated use `stringify(doc[, doc, ...])` 20 | */ 21 | public static formatObjects(docs: any[]): string { 22 | return this.stringify(...docs); 23 | } 24 | 25 | /** 26 | * Saves a set of objects as a multi-document YAML file. 27 | * @param filePath The output path 28 | * @param docs The set of objects 29 | */ 30 | public static save(filePath: string, docs: any[]) { 31 | const data = this.stringify(...docs); 32 | fs.writeFileSync(filePath, data, { encoding: 'utf8' }); 33 | } 34 | 35 | /** 36 | * Stringify a document (or multiple documents) into YAML 37 | * 38 | * We convert undefined values to null, but ignore any documents that are 39 | * undefined. 40 | * 41 | * @param docs A set of objects to convert to YAML 42 | * @returns a YAML string. Multiple docs are separated by `---`. 43 | */ 44 | public static stringify(...docs: any[]) { 45 | return docs.map( 46 | r => r === undefined ? '\n' : YAML.stringify(r, { keepUndefined: true, lineWidth: 0, version: yamlSchemaVersion }), 47 | ).join('---\n'); 48 | } 49 | 50 | /** 51 | * Saves a set of YAML documents into a temp file (in /tmp) 52 | * 53 | * @returns the path to the temporary file 54 | * @param docs the set of documents to save 55 | */ 56 | public static tmp(docs: any[]): string { 57 | const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk8s-')); 58 | const filePath = path.join(tmpdir, 'temp.yaml'); 59 | Yaml.save(filePath, docs); 60 | return filePath; 61 | } 62 | 63 | /** 64 | * Downloads a set of YAML documents (k8s manifest for example) from a URL or 65 | * a file and returns them as javascript objects. 66 | * 67 | * Empty documents are filtered out. 68 | * 69 | * @param urlOrFile a URL of a file path to load from 70 | * @returns an array of objects, each represents a document inside the YAML 71 | */ 72 | public static load(urlOrFile: string): any[] { 73 | const body = loadurl(urlOrFile); 74 | 75 | const objects = YAML.parseAllDocuments(body, { 76 | version: yamlSchemaVersion, 77 | }); 78 | const result = new Array(); 79 | 80 | for (const obj of objects.map(x => x.toJSON())) { 81 | // skip empty documents 82 | if (obj === undefined) { continue; } 83 | if (obj === null) { continue; } 84 | if (Array.isArray(obj) && obj.length === 0) { continue; } 85 | if (typeof (obj) === 'object' && Object.keys(obj).length === 0) { continue; } 86 | 87 | result.push(obj); 88 | } 89 | 90 | return result; 91 | } 92 | 93 | /** 94 | * Utility class. 95 | */ 96 | private constructor() { 97 | return; 98 | } 99 | } 100 | 101 | /** 102 | * Loads a url (or file) and returns the contents. 103 | * This method spawns a child process in order to perform an http call synchronously. 104 | */ 105 | function loadurl(url: string): string { 106 | const script = path.join(__dirname, '_loadurl.mjs'); 107 | return execFileSync(process.execPath, [script, url], { 108 | encoding: 'utf-8', 109 | maxBuffer: MAX_DOWNLOAD_BUFFER, 110 | }).toString(); 111 | } 112 | -------------------------------------------------------------------------------- /test/__snapshots__/api-object.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`"data" can be used to specify resource data 1`] = ` 4 | Array [ 5 | Object { 6 | "apiVersion": "v1", 7 | "data": Object { 8 | "boom": 123, 9 | }, 10 | "kind": "ResourceType", 11 | "metadata": Object { 12 | "name": "test-c8c5facf", 13 | }, 14 | }, 15 | ] 16 | `; 17 | 18 | exports[`"spec" is synthesized as-is 1`] = ` 19 | Array [ 20 | Object { 21 | "apiVersion": "v1", 22 | "kind": "ResourceType", 23 | "metadata": Object { 24 | "name": "test-c8c5facf", 25 | }, 26 | "spec": Object { 27 | "prop1": "hello", 28 | "prop2": Object { 29 | "world": 123, 30 | }, 31 | }, 32 | }, 33 | ] 34 | `; 35 | 36 | exports[`if name is explicitly specified it will be respected 1`] = ` 37 | Array [ 38 | Object { 39 | "apiVersion": "v1", 40 | "kind": "MyResource", 41 | "metadata": Object { 42 | "name": "boom", 43 | }, 44 | }, 45 | ] 46 | `; 47 | 48 | exports[`minimal configuration 1`] = ` 49 | Array [ 50 | Object { 51 | "apiVersion": "v1", 52 | "kind": "MyResource", 53 | "metadata": Object { 54 | "name": "test-my-c8487bf7", 55 | }, 56 | }, 57 | ] 58 | `; 59 | 60 | exports[`printed yaml is alphabetical 1`] = ` 61 | "[ 62 | { 63 | \\"apiVersion\\": \\"v1\\", 64 | \\"kind\\": \\"MyResource\\", 65 | \\"metadata\\": { 66 | \\"meta\\": { 67 | \\"aaa\\": 123, 68 | \\"zzz\\": \\"hello\\" 69 | }, 70 | \\"name\\": \\"test-my-c8487bf7\\" 71 | }, 72 | \\"spec\\": { 73 | \\"firstProperty\\": \\"hello\\", 74 | \\"secondProperty\\": { 75 | \\"beforeThirdProperty\\": \\"world\\", 76 | \\"innerThirdProperty\\": \\"!\\" 77 | } 78 | } 79 | } 80 | ]" 81 | `; 82 | 83 | exports[`synthesized resource name is based on path 1`] = ` 84 | Array [ 85 | Object { 86 | "apiVersion": "v1", 87 | "kind": "MyResource", 88 | "metadata": Object { 89 | "name": "test-my-c8487bf7", 90 | }, 91 | }, 92 | Object { 93 | "apiVersion": "v1", 94 | "kind": "MyResource", 95 | "metadata": Object { 96 | "name": "test-scope-my-c8fafaf7", 97 | }, 98 | }, 99 | ] 100 | `; 101 | -------------------------------------------------------------------------------- /test/__snapshots__/app.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`app with nested charts will deduplicate api objects (using custom classes) 1`] = ` 4 | "apiVersion: v1 5 | kind: Namespace 6 | metadata: 7 | name: parent-child1-namespace1-c871643e 8 | " 9 | `; 10 | 11 | exports[`app with nested charts will deduplicate api objects (using custom classes) 2`] = ` 12 | "apiVersion: v1 13 | kind: Namespace 14 | metadata: 15 | name: parent-child2-namespace2-c806260b 16 | " 17 | `; 18 | 19 | exports[`app with nested charts will deduplicate api objects (using custom classes) 3`] = ` 20 | "apiVersion: v1 21 | kind: Namespace 22 | metadata: 23 | name: parent-namespace3-c8bf842a 24 | " 25 | `; 26 | 27 | exports[`app with nested charts will deduplicate api objects 1`] = ` 28 | "apiVersion: v1 29 | kind: CustomConstruct 30 | metadata: 31 | name: chart1-chart2-child2-child2obj-c828dca6 32 | " 33 | `; 34 | 35 | exports[`app with nested charts will deduplicate api objects 2`] = ` 36 | "apiVersion: v1 37 | kind: CustomConstruct 38 | metadata: 39 | name: chart1-child1-child1obj-c868628e 40 | " 41 | `; 42 | 43 | exports[`can hook into chart synthesis with during synth 1`] = ` 44 | "apiVersion: v1 45 | kind: Kind2 46 | metadata: 47 | name: chart-apiobject2-c81f1ca2 48 | " 49 | `; 50 | 51 | exports[`return app as yaml string 1`] = ` 52 | "apiVersion: v1 53 | kind: Kind1 54 | metadata: 55 | name: chart1-obj1-c818e77f 56 | --- 57 | apiVersion: v1 58 | kind: Kind2 59 | metadata: 60 | name: chart1-obj2-c87a5a2e 61 | --- 62 | apiVersion: v1 63 | kind: Kind3 64 | metadata: 65 | name: chart3-obj3-c8abbfb5 66 | --- 67 | apiVersion: v1 68 | kind: Kind4 69 | metadata: 70 | name: chart3-obj4-c8da728e 71 | " 72 | `; 73 | 74 | exports[`synthYaml considers dependencies 1`] = ` 75 | "apiVersion: v1 76 | kind: Kind2 77 | metadata: 78 | name: chart-c2-apiobject2-c8a49d62 79 | --- 80 | apiVersion: v1 81 | kind: Kind1 82 | metadata: 83 | name: chart-c1-apiobject1-c8f49fa2 84 | " 85 | `; 86 | -------------------------------------------------------------------------------- /test/__snapshots__/chart.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`disabling resource name hashes at chart level 1`] = ` 4 | Array [ 5 | Object { 6 | "apiVersion": "v1", 7 | "kind": "Resource1", 8 | "metadata": Object { 9 | "name": "test-resource1", 10 | }, 11 | }, 12 | Object { 13 | "apiVersion": "v1", 14 | "kind": "Resource3", 15 | "metadata": Object { 16 | "name": "test-resource2", 17 | }, 18 | }, 19 | ] 20 | `; 21 | 22 | exports[`empty stack 1`] = `Array []`; 23 | 24 | exports[`output includes all synthesized resources 1`] = ` 25 | Array [ 26 | Object { 27 | "apiVersion": "v1", 28 | "kind": "Resource1", 29 | "metadata": Object { 30 | "name": "test-resource1-c85cb0fc", 31 | }, 32 | }, 33 | Object { 34 | "apiVersion": "v1", 35 | "kind": "Resource2", 36 | "metadata": Object { 37 | "name": "test-resource2-c8c6bd27", 38 | }, 39 | }, 40 | Object { 41 | "apiVersion": "v1", 42 | "kind": "Resource3", 43 | "metadata": Object { 44 | "name": "test-resource3-c8ccc739", 45 | }, 46 | }, 47 | Object { 48 | "apiVersion": "v1", 49 | "kind": "Resource1", 50 | "metadata": Object { 51 | "name": "test-scope-resource1-c84ac5c2", 52 | }, 53 | }, 54 | Object { 55 | "apiVersion": "v1", 56 | "kind": "Resource2", 57 | "metadata": Object { 58 | "name": "test-scope-resource2-c889750d", 59 | }, 60 | }, 61 | ] 62 | `; 63 | 64 | exports[`resource name hashes work by default 1`] = ` 65 | Array [ 66 | Object { 67 | "apiVersion": "v1", 68 | "kind": "Resource1", 69 | "metadata": Object { 70 | "name": "test-resource1-c85cb0fc", 71 | }, 72 | }, 73 | Object { 74 | "apiVersion": "v1", 75 | "kind": "Resource3", 76 | "metadata": Object { 77 | "name": "test-resource2-c8c6bd27", 78 | }, 79 | }, 80 | ] 81 | `; 82 | 83 | exports[`synthesizeManifest() can be used to synthesize a specific chart 1`] = ` 84 | Array [ 85 | Object { 86 | "apiVersion": "v1", 87 | "kind": "Kind1", 88 | "metadata": Object { 89 | "name": "chart-obj1-c80aa35c", 90 | }, 91 | }, 92 | Object { 93 | "apiVersion": "v1", 94 | "kind": "Kind2", 95 | "metadata": Object { 96 | "name": "chart-obj2-c8016fab", 97 | }, 98 | }, 99 | ] 100 | `; 101 | 102 | exports[`tokens are resolved during synth 1`] = ` 103 | Array [ 104 | Object { 105 | "apiVersion": "v1", 106 | "kind": "Resource1", 107 | "metadata": Object { 108 | "name": "test-resource1-c85cb0fc", 109 | }, 110 | "spec": Object { 111 | "foo": 123, 112 | "implicitToken": Object { 113 | "foo": "bar", 114 | }, 115 | }, 116 | }, 117 | ] 118 | `; 119 | -------------------------------------------------------------------------------- /test/__snapshots__/helm.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`basic usage 1`] = ` 4 | Array [ 5 | Object { 6 | "apiVersion": "v1", 7 | "kind": "ServiceAccount", 8 | "metadata": Object { 9 | "labels": Object { 10 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 11 | "app.kubernetes.io/managed-by": "Helm", 12 | "app.kubernetes.io/name": "helm-sample", 13 | "app.kubernetes.io/version": "1.16.0", 14 | "helm.sh/chart": "helm-sample-0.1.0", 15 | }, 16 | "name": "test-sample-c8e2763d-helm-sample", 17 | }, 18 | }, 19 | Object { 20 | "apiVersion": "v1", 21 | "kind": "Service", 22 | "metadata": Object { 23 | "labels": Object { 24 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 25 | "app.kubernetes.io/managed-by": "Helm", 26 | "app.kubernetes.io/name": "helm-sample", 27 | "app.kubernetes.io/version": "1.16.0", 28 | "helm.sh/chart": "helm-sample-0.1.0", 29 | }, 30 | "name": "test-sample-c8e2763d-helm-sample", 31 | }, 32 | "spec": Object { 33 | "ports": Array [ 34 | Object { 35 | "name": "http", 36 | "port": 80, 37 | "protocol": "TCP", 38 | "targetPort": "http", 39 | }, 40 | ], 41 | "selector": Object { 42 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 43 | "app.kubernetes.io/name": "helm-sample", 44 | }, 45 | "type": "ClusterIP", 46 | }, 47 | }, 48 | Object { 49 | "apiVersion": "apps/v1", 50 | "kind": "Deployment", 51 | "metadata": Object { 52 | "labels": Object { 53 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 54 | "app.kubernetes.io/managed-by": "Helm", 55 | "app.kubernetes.io/name": "helm-sample", 56 | "app.kubernetes.io/version": "1.16.0", 57 | "helm.sh/chart": "helm-sample-0.1.0", 58 | }, 59 | "name": "test-sample-c8e2763d-helm-sample", 60 | }, 61 | "spec": Object { 62 | "replicas": 1, 63 | "selector": Object { 64 | "matchLabels": Object { 65 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 66 | "app.kubernetes.io/name": "helm-sample", 67 | }, 68 | }, 69 | "template": Object { 70 | "metadata": Object { 71 | "labels": Object { 72 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 73 | "app.kubernetes.io/name": "helm-sample", 74 | }, 75 | }, 76 | "spec": Object { 77 | "containers": Array [ 78 | Object { 79 | "image": "nginx:1.16.0", 80 | "imagePullPolicy": "IfNotPresent", 81 | "livenessProbe": Object { 82 | "httpGet": Object { 83 | "path": "/", 84 | "port": "http", 85 | }, 86 | }, 87 | "name": "helm-sample", 88 | "ports": Array [ 89 | Object { 90 | "containerPort": 80, 91 | "name": "http", 92 | "protocol": "TCP", 93 | }, 94 | ], 95 | "readinessProbe": Object { 96 | "httpGet": Object { 97 | "path": "/", 98 | "port": "http", 99 | }, 100 | }, 101 | "resources": Object {}, 102 | "securityContext": Object {}, 103 | }, 104 | ], 105 | "securityContext": Object {}, 106 | "serviceAccountName": "test-sample-c8e2763d-helm-sample", 107 | }, 108 | }, 109 | }, 110 | }, 111 | ] 112 | `; 113 | 114 | exports[`values can be specified when defining the chart 1`] = ` 115 | Array [ 116 | Object { 117 | "apiVersion": "v1", 118 | "kind": "ServiceAccount", 119 | "metadata": Object { 120 | "labels": Object { 121 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 122 | "app.kubernetes.io/managed-by": "Helm", 123 | "app.kubernetes.io/name": "helm-sample", 124 | "app.kubernetes.io/version": "1.16.0", 125 | "helm.sh/chart": "helm-sample-0.1.0", 126 | }, 127 | "name": "test-sample-c8e2763d-helm-sample", 128 | }, 129 | }, 130 | Object { 131 | "apiVersion": "v1", 132 | "kind": "Service", 133 | "metadata": Object { 134 | "labels": Object { 135 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 136 | "app.kubernetes.io/managed-by": "Helm", 137 | "app.kubernetes.io/name": "helm-sample", 138 | "app.kubernetes.io/version": "1.16.0", 139 | "helm.sh/chart": "helm-sample-0.1.0", 140 | }, 141 | "name": "test-sample-c8e2763d-helm-sample", 142 | }, 143 | "spec": Object { 144 | "ports": Array [ 145 | Object { 146 | "name": "http", 147 | "port": 80, 148 | "protocol": "TCP", 149 | "targetPort": "http", 150 | }, 151 | ], 152 | "selector": Object { 153 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 154 | "app.kubernetes.io/name": "helm-sample", 155 | }, 156 | "type": "ClusterIP", 157 | }, 158 | }, 159 | Object { 160 | "apiVersion": "apps/v1", 161 | "kind": "Deployment", 162 | "metadata": Object { 163 | "labels": Object { 164 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 165 | "app.kubernetes.io/managed-by": "Helm", 166 | "app.kubernetes.io/name": "helm-sample", 167 | "app.kubernetes.io/version": "1.16.0", 168 | "helm.sh/chart": "helm-sample-0.1.0", 169 | }, 170 | "name": "test-sample-c8e2763d-helm-sample", 171 | }, 172 | "spec": Object { 173 | "replicas": 889, 174 | "selector": Object { 175 | "matchLabels": Object { 176 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 177 | "app.kubernetes.io/name": "helm-sample", 178 | }, 179 | }, 180 | "template": Object { 181 | "metadata": Object { 182 | "labels": Object { 183 | "app.kubernetes.io/instance": "test-sample-c8e2763d", 184 | "app.kubernetes.io/name": "helm-sample", 185 | }, 186 | }, 187 | "spec": Object { 188 | "containers": Array [ 189 | Object { 190 | "image": "nginx:1.16.0", 191 | "imagePullPolicy": "IfNotPresent", 192 | "livenessProbe": Object { 193 | "httpGet": Object { 194 | "path": "/", 195 | "port": "http", 196 | }, 197 | }, 198 | "name": "helm-sample", 199 | "ports": Array [ 200 | Object { 201 | "containerPort": 80, 202 | "name": "http", 203 | "protocol": "TCP", 204 | }, 205 | ], 206 | "readinessProbe": Object { 207 | "httpGet": Object { 208 | "path": "/", 209 | "port": "http", 210 | }, 211 | }, 212 | "resources": Object {}, 213 | "securityContext": Object {}, 214 | }, 215 | ], 216 | "nodeSelector": Object { 217 | "selectMe": "boomboom", 218 | }, 219 | "securityContext": Object {}, 220 | "serviceAccountName": "test-sample-c8e2763d-helm-sample", 221 | }, 222 | }, 223 | }, 224 | }, 225 | ] 226 | `; 227 | -------------------------------------------------------------------------------- /test/__snapshots__/util.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`sanitizeValue sortKeys 1`] = ` 4 | "{ 5 | \\"aaa\\": 111, 6 | \\"nested\\": { 7 | \\"foo\\": { 8 | \\"bar\\": \\"1111\\", 9 | \\"zag\\": [ 10 | 1, 11 | 2, 12 | 3 13 | ] 14 | } 15 | }, 16 | \\"zzz\\": 999 17 | }" 18 | `; 19 | 20 | exports[`sanitizeValue sortKeys 2`] = ` 21 | "{ 22 | \\"zzz\\": 999, 23 | \\"aaa\\": 111, 24 | \\"nested\\": { 25 | \\"foo\\": { 26 | \\"zag\\": [ 27 | 1, 28 | 2, 29 | 3 30 | ], 31 | \\"bar\\": \\"1111\\" 32 | } 33 | } 34 | }" 35 | `; 36 | -------------------------------------------------------------------------------- /test/__snapshots__/yaml.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`load empty documents are filtered out 1`] = ` 4 | Array [ 5 | Object { 6 | "doc": 1, 7 | }, 8 | "str_doc", 9 | "", 10 | 0, 11 | Object { 12 | "doc": 2, 13 | }, 14 | ] 15 | `; 16 | 17 | exports[`load from file 1`] = ` 18 | Array [ 19 | Object { 20 | "hello": 1234, 21 | "world": 111, 22 | }, 23 | Object { 24 | "foo": Array [ 25 | "bar", 26 | "zoo", 27 | "goo", 28 | ], 29 | }, 30 | Array [ 31 | "hello", 32 | "world", 33 | ], 34 | ] 35 | `; 36 | 37 | exports[`load from url 1`] = ` 38 | Array [ 39 | Object { 40 | "apiVersion": "v1", 41 | "kind": "Service", 42 | "metadata": Object { 43 | "labels": Object { 44 | "app": "redis", 45 | "role": "master", 46 | "tier": "backend", 47 | }, 48 | "name": "redis-master", 49 | }, 50 | "spec": Object { 51 | "ports": Array [ 52 | Object { 53 | "port": 6379, 54 | "targetPort": 6379, 55 | }, 56 | ], 57 | "selector": Object { 58 | "app": "redis", 59 | "role": "master", 60 | "tier": "backend", 61 | }, 62 | }, 63 | }, 64 | Object { 65 | "apiVersion": "apps/v1", 66 | "kind": "Deployment", 67 | "metadata": Object { 68 | "name": "redis-master", 69 | }, 70 | "spec": Object { 71 | "replicas": 1, 72 | "selector": Object { 73 | "matchLabels": Object { 74 | "app": "redis", 75 | "role": "master", 76 | "tier": "backend", 77 | }, 78 | }, 79 | "template": Object { 80 | "metadata": Object { 81 | "labels": Object { 82 | "app": "redis", 83 | "role": "master", 84 | "tier": "backend", 85 | }, 86 | }, 87 | "spec": Object { 88 | "containers": Array [ 89 | Object { 90 | "image": "registry.k8s.io/redis:e2e", 91 | "name": "master", 92 | "ports": Array [ 93 | Object { 94 | "containerPort": 6379, 95 | }, 96 | ], 97 | "resources": Object { 98 | "requests": Object { 99 | "cpu": "100m", 100 | "memory": "100Mi", 101 | }, 102 | }, 103 | }, 104 | ], 105 | }, 106 | }, 107 | }, 108 | }, 109 | Object { 110 | "apiVersion": "v1", 111 | "kind": "Service", 112 | "metadata": Object { 113 | "labels": Object { 114 | "app": "redis", 115 | "role": "replica", 116 | "tier": "backend", 117 | }, 118 | "name": "redis-replica", 119 | }, 120 | "spec": Object { 121 | "ports": Array [ 122 | Object { 123 | "port": 6379, 124 | }, 125 | ], 126 | "selector": Object { 127 | "app": "redis", 128 | "role": "replica", 129 | "tier": "backend", 130 | }, 131 | }, 132 | }, 133 | Object { 134 | "apiVersion": "apps/v1", 135 | "kind": "Deployment", 136 | "metadata": Object { 137 | "name": "redis-replica", 138 | }, 139 | "spec": Object { 140 | "replicas": 2, 141 | "selector": Object { 142 | "matchLabels": Object { 143 | "app": "redis", 144 | "role": "replica", 145 | "tier": "backend", 146 | }, 147 | }, 148 | "template": Object { 149 | "metadata": Object { 150 | "labels": Object { 151 | "app": "redis", 152 | "role": "replica", 153 | "tier": "backend", 154 | }, 155 | }, 156 | "spec": Object { 157 | "containers": Array [ 158 | Object { 159 | "env": Array [ 160 | Object { 161 | "name": "GET_HOSTS_FROM", 162 | "value": "dns", 163 | }, 164 | ], 165 | "image": "gcr.io/google_samples/gb-redisslave:v1", 166 | "name": "replica", 167 | "ports": Array [ 168 | Object { 169 | "containerPort": 6379, 170 | }, 171 | ], 172 | "resources": Object { 173 | "requests": Object { 174 | "cpu": "100m", 175 | "memory": "100Mi", 176 | }, 177 | }, 178 | }, 179 | ], 180 | }, 181 | }, 182 | }, 183 | }, 184 | Object { 185 | "apiVersion": "v1", 186 | "kind": "Service", 187 | "metadata": Object { 188 | "labels": Object { 189 | "app": "guestbook", 190 | "tier": "frontend", 191 | }, 192 | "name": "frontend", 193 | }, 194 | "spec": Object { 195 | "ports": Array [ 196 | Object { 197 | "port": 80, 198 | }, 199 | ], 200 | "selector": Object { 201 | "app": "guestbook", 202 | "tier": "frontend", 203 | }, 204 | "type": "NodePort", 205 | }, 206 | }, 207 | Object { 208 | "apiVersion": "apps/v1", 209 | "kind": "Deployment", 210 | "metadata": Object { 211 | "name": "frontend", 212 | }, 213 | "spec": Object { 214 | "replicas": 3, 215 | "selector": Object { 216 | "matchLabels": Object { 217 | "app": "guestbook", 218 | "tier": "frontend", 219 | }, 220 | }, 221 | "template": Object { 222 | "metadata": Object { 223 | "labels": Object { 224 | "app": "guestbook", 225 | "tier": "frontend", 226 | }, 227 | }, 228 | "spec": Object { 229 | "containers": Array [ 230 | Object { 231 | "env": Array [ 232 | Object { 233 | "name": "GET_HOSTS_FROM", 234 | "value": "dns", 235 | }, 236 | ], 237 | "image": "gcr.io/google-samples/gb-frontend:v4", 238 | "name": "php-redis", 239 | "ports": Array [ 240 | Object { 241 | "containerPort": 80, 242 | }, 243 | ], 244 | "resources": Object { 245 | "requests": Object { 246 | "cpu": "100m", 247 | "memory": "100Mi", 248 | }, 249 | }, 250 | }, 251 | ], 252 | }, 253 | }, 254 | }, 255 | }, 256 | ] 257 | `; 258 | 259 | exports[`multi-line text block with long line keep line break 1`] = ` 260 | "foo: |- 261 | [section] 262 | abc: s 263 | def: 012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789 264 | bar: something 265 | " 266 | `; 267 | 268 | exports[`save empty documents are respected 1`] = ` 269 | "{} 270 | --- 271 | {} 272 | --- 273 | 274 | --- 275 | empty: true 276 | --- 277 | {} 278 | " 279 | `; 280 | 281 | exports[`save empty values are preserved 1`] = ` 282 | "i_am_undefined: null 283 | i_am_null: null 284 | empty_array: [] 285 | empty_object: {} 286 | " 287 | `; 288 | 289 | exports[`save multiple documents 1`] = ` 290 | "foo: bar 291 | hello: 292 | - 1 293 | - 2 294 | - 3 295 | --- 296 | number: 2 297 | " 298 | `; 299 | 300 | exports[`save single document 1`] = ` 301 | "foo: bar 302 | hello: 303 | - 1 304 | - 2 305 | - 3 306 | " 307 | `; 308 | 309 | exports[`save strings are quoted 1`] = ` 310 | "foo: \\"on\\" 311 | bar: this has a \\"big quote\\" 312 | not_a_string: true 313 | " 314 | `; 315 | -------------------------------------------------------------------------------- /test/cron.test.ts: -------------------------------------------------------------------------------- 1 | import { CronOptions, Cron } from '../src'; 2 | 3 | describe('Cron', () => { 4 | test('cron expression for running every minute', () => { 5 | expect(Cron.everyMinute().expressionString).toEqual('* * * * *'); 6 | }); 7 | 8 | test('cron expression for running every hour', () => { 9 | expect(Cron.hourly().expressionString).toEqual('0 * * * *'); 10 | }); 11 | 12 | test('cron expression for running every day', () => { 13 | expect(Cron.daily().expressionString).toEqual('0 0 * * *'); 14 | }); 15 | 16 | test('cron expression for running every week', () => { 17 | expect(Cron.weekly().expressionString).toEqual('0 0 * * 0'); 18 | }); 19 | 20 | test('cron expression for running every month', () => { 21 | expect(Cron.monthly().expressionString).toEqual('0 0 1 * *'); 22 | }); 23 | 24 | test('cron expression for running every year', () => { 25 | expect(Cron.annually().expressionString).toEqual('0 0 1 1 *'); 26 | }); 27 | 28 | test('custom schedule cron expression', () => { 29 | const expression: CronOptions = { 30 | minute: '5', 31 | hour: '*', 32 | day: '2', 33 | month: '*', 34 | weekDay: '*', 35 | }; 36 | expect(Cron.schedule(expression).expressionString).toEqual('5 * 2 * *'); 37 | }); 38 | 39 | test('custom schedule cron expression using default initialization', () => { 40 | const cron = new Cron(); 41 | expect(cron.expressionString).toEqual('* * * * *'); 42 | }); 43 | 44 | test('custom schedule cron expression using initialization', () => { 45 | const expression: CronOptions = { 46 | minute: '5', 47 | hour: '*', 48 | day: '2', 49 | month: '*', 50 | weekDay: '*', 51 | }; 52 | 53 | const cron = new Cron(expression); 54 | expect(cron.expressionString).toEqual('5 * 2 * *'); 55 | }); 56 | }); -------------------------------------------------------------------------------- /test/dependency.test.ts: -------------------------------------------------------------------------------- 1 | import { IConstruct, Construct } from 'constructs'; 2 | import { DependencyGraph } from '../src/dependency'; 3 | 4 | test('topology returns correct order', () => { 5 | const root = new Construct(undefined as any, 'App'); 6 | const group = new Construct(root, 'chart1'); 7 | 8 | const obj1 = new Construct(group, 'obj1'); 9 | const obj2 = new Construct(group, 'obj2'); 10 | const obj3 = new Construct(group, 'obj3'); 11 | 12 | obj1.node.addDependency(obj2); 13 | obj2.node.addDependency(obj3); 14 | 15 | const graph = new DependencyGraph(group.node); 16 | 17 | expect(graph.topology()).toEqual([group, obj3, obj2, obj1]); 18 | 19 | }); 20 | 21 | test('cycle detection', () => { 22 | 23 | const root = new Construct(undefined as any, 'App'); 24 | const group = new Construct(root, 'chart1'); 25 | 26 | const obj1 = new Construct(group, 'obj1'); 27 | const obj2 = new Construct(group, 'obj2'); 28 | const obj3 = new Construct(group, 'obj3'); 29 | 30 | obj1.node.addDependency(obj2); 31 | obj2.node.addDependency(obj3); 32 | obj3.node.addDependency(obj1); 33 | 34 | expect(() => { 35 | new DependencyGraph(group.node); 36 | }).toThrowError(`Dependency cycle detected: ${obj1.node.path} => ${obj2.node.path} => ${obj3.node.path} => ${obj1.node.path}`); 37 | 38 | }); 39 | 40 | test('value of root is null', () => { 41 | 42 | const root = new Construct(undefined as any, 'App'); 43 | const group = new Construct(root, 'chart1'); 44 | 45 | const obj1 = new Construct(group, 'obj1'); 46 | const obj2 = new Construct(group, 'obj2'); 47 | const obj3 = new Construct(group, 'obj3'); 48 | 49 | obj1.node.addDependency(obj2); 50 | obj2.node.addDependency(obj3); 51 | 52 | expect(new DependencyGraph(group.node).root.value).toBeUndefined(); 53 | 54 | }); 55 | 56 | test('children of root contains all orphans', () => { 57 | 58 | const root = new Construct(undefined as any, 'App'); 59 | const group = new Construct(root, 'chart1'); 60 | 61 | const obj1 = new Construct(group, 'obj1'); 62 | const obj2 = new Construct(group, 'obj2'); 63 | 64 | obj1.node.addDependency(obj2); 65 | 66 | const expected = new Set(); 67 | 68 | new DependencyGraph(group.node).root.outbound.forEach(c => expected.add(c.value!)); 69 | 70 | // chart1 and obj1 are orphans because no one depends on them (no parents) 71 | // they should be dependency roots, i.e chidren of the dummy root. 72 | expect(expected).toEqual(new Set([group, obj1])); 73 | 74 | }); 75 | 76 | test('ignores cross-scope nodes', () => { 77 | 78 | const root = new Construct(undefined as any, 'App'); 79 | const group1 = new Construct(root, 'group1'); 80 | const group2 = new Construct(root, 'group2'); 81 | 82 | const obj1 = new Construct(group1, 'obj1'); 83 | const obj2 = new Construct(group1, 'obj2'); 84 | const obj3 = new Construct(group2, 'obj3'); 85 | 86 | obj1.node.addDependency(obj2); 87 | 88 | // this is a cross-scope dependency since 'obj2' is 89 | // not inside the scope of 'chart1' 90 | obj2.node.addDependency(obj3); 91 | 92 | // we expect obj3 to not be part of the graph 93 | const graph = new DependencyGraph(group1.node); 94 | 95 | expect(graph.topology()).toEqual([group1, obj2, obj1]); 96 | 97 | }); 98 | -------------------------------------------------------------------------------- /test/duration.test.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from '../src'; 2 | 3 | test('negative amount', () => { 4 | expect(() => Duration.seconds(-1)).toThrow(/negative/); 5 | }); 6 | 7 | test('Duration in seconds', () => { 8 | const duration = Duration.seconds(300); 9 | expect(duration.toSeconds()).toBe(300); 10 | expect(duration.toMinutes()).toBe(5); 11 | expect(() => duration.toDays()).toThrow(/'300 seconds' cannot be converted into a whole number of days/); 12 | expect(duration.toDays({ integral: false })).toBeCloseTo(300 / 86_400); 13 | expect(Duration.seconds(60 * 60 * 24).toDays()).toBe(1); 14 | }); 15 | 16 | test('Duration in minutes', () => { 17 | const duration = Duration.minutes(5); 18 | 19 | expect(duration.toSeconds()).toBe(300); 20 | expect(duration.toMinutes()).toBe(5); 21 | expect(() => duration.toDays()).toThrow(/'5 minutes' cannot be converted into a whole number of days/); 22 | expect(duration.toDays({ integral: false })).toBeCloseTo(300 / 86_400); 23 | expect(Duration.minutes(60 * 24).toDays()).toBe(1); 24 | }); 25 | 26 | test('Duration in hours', () => { 27 | const duration = Duration.hours(5); 28 | 29 | expect(duration.toSeconds()).toBe(18_000); 30 | expect(duration.toMinutes()).toBe(300); 31 | expect(() => duration.toDays()).toThrow(/'5 hours' cannot be converted into a whole number of days/); 32 | expect(duration.toDays({ integral: false })).toBeCloseTo(5 / 24); 33 | expect(Duration.hours(24).toDays()).toBe(1); 34 | }); 35 | 36 | test('seconds to milliseconds', () => { 37 | const duration = Duration.seconds(5); 38 | expect(duration.toMilliseconds()).toBe(5_000); 39 | }); 40 | 41 | test('Duration in days', () => { 42 | const duration = Duration.days(1); 43 | expect(duration.toSeconds()).toBe(86_400); 44 | expect(duration.toMinutes()).toBe(1_440); 45 | expect(duration.toDays()).toBe(1); 46 | }); 47 | 48 | test('toIsoString', () => { 49 | expect(Duration.seconds(0).toIsoString()).toBe('PT0S'); 50 | expect(Duration.minutes(0).toIsoString()).toBe('PT0S'); 51 | expect(Duration.hours(0).toIsoString()).toBe('PT0S'); 52 | expect(Duration.days(0).toIsoString()).toBe('PT0S'); 53 | 54 | expect(Duration.seconds(5).toIsoString()).toBe('PT5S'); 55 | expect(Duration.minutes(5).toIsoString()).toBe('PT5M'); 56 | expect(Duration.hours(5).toIsoString()).toBe('PT5H'); 57 | expect(Duration.days(5).toIsoString()).toBe('PT5D'); 58 | 59 | expect(Duration.seconds(1 + 60 * (1 + 60 * (1 + 24))).toIsoString()).toBe('PT1D1H1M1S'); 60 | }); 61 | 62 | test('parse', () => { 63 | expect(Duration.parse('PT0S').toSeconds()).toBe(0); 64 | expect(Duration.parse('PT0M').toSeconds()).toBe(0); 65 | expect(Duration.parse('PT0H').toSeconds()).toBe(0); 66 | expect(Duration.parse('PT0D').toSeconds()).toBe(0); 67 | 68 | expect(Duration.parse('PT5S').toSeconds()).toBe(5); 69 | expect(Duration.parse('PT5M').toSeconds()).toBe(300); 70 | expect(Duration.parse('PT5H').toSeconds()).toBe(18_000); 71 | expect(Duration.parse('PT5D').toSeconds()).toBe(432_000); 72 | 73 | expect(Duration.parse('PT1D1H1M1S').toSeconds()).toBe(1 + 60 * (1 + 60 * (1 + 24))); 74 | }); 75 | 76 | test('to human string', () => { 77 | expect(Duration.minutes(0).toHumanString()).toBe('0 minutes'); 78 | 79 | expect(Duration.minutes(10).toHumanString()).toBe('10 minutes'); 80 | expect(Duration.minutes(1).toHumanString()).toBe('1 minute'); 81 | 82 | expect(Duration.minutes(62).toHumanString()).toBe('1 hour 2 minutes'); 83 | 84 | expect(Duration.seconds(3666).toHumanString()).toBe('1 hour 1 minute'); 85 | 86 | expect(Duration.millis(3000).toHumanString()).toBe('3 seconds'); 87 | expect(Duration.millis(3666).toHumanString()).toBe('3 seconds 666 millis'); 88 | 89 | expect(Duration.millis(3.6).toHumanString()).toBe('3.6 millis'); 90 | }); 91 | -------------------------------------------------------------------------------- /test/fixtures/guestbook-all-in-one.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: redis-master 5 | labels: 6 | app: redis 7 | tier: backend 8 | role: master 9 | spec: 10 | ports: 11 | - port: 6379 12 | targetPort: 6379 13 | selector: 14 | app: redis 15 | tier: backend 16 | role: master 17 | --- 18 | apiVersion: apps/v1 # for k8s versions before 1.9.0 use apps/v1beta2 and before 1.8.0 use extensions/v1beta1 19 | kind: Deployment 20 | metadata: 21 | name: redis-master 22 | spec: 23 | selector: 24 | matchLabels: 25 | app: redis 26 | role: master 27 | tier: backend 28 | replicas: 1 29 | template: 30 | metadata: 31 | labels: 32 | app: redis 33 | role: master 34 | tier: backend 35 | spec: 36 | containers: 37 | - name: master 38 | image: registry.k8s.io/redis:e2e # or just image: redis 39 | resources: 40 | requests: 41 | cpu: 100m 42 | memory: 100Mi 43 | ports: 44 | - containerPort: 6379 45 | --- 46 | apiVersion: v1 47 | kind: Service 48 | metadata: 49 | name: redis-slave 50 | labels: 51 | app: redis 52 | tier: backend 53 | role: slave 54 | spec: 55 | ports: 56 | - port: 6379 57 | selector: 58 | app: redis 59 | tier: backend 60 | role: slave 61 | --- 62 | apiVersion: apps/v1 # for k8s versions before 1.9.0 use apps/v1beta2 and before 1.8.0 use extensions/v1beta1 63 | kind: Deployment 64 | metadata: 65 | name: redis-slave 66 | spec: 67 | selector: 68 | matchLabels: 69 | app: redis 70 | role: slave 71 | tier: backend 72 | replicas: 2 73 | template: 74 | metadata: 75 | labels: 76 | app: redis 77 | role: slave 78 | tier: backend 79 | spec: 80 | containers: 81 | - name: slave 82 | image: gcr.io/google_samples/gb-redisslave:v1 83 | resources: 84 | requests: 85 | cpu: 100m 86 | memory: 100Mi 87 | env: 88 | - name: GET_HOSTS_FROM 89 | value: dns 90 | # If your cluster config does not include a dns service, then to 91 | # instead access an environment variable to find the master 92 | # service's host, comment out the 'value: dns' line above, and 93 | # uncomment the line below: 94 | # value: env 95 | ports: 96 | - containerPort: 6379 97 | --- 98 | apiVersion: v1 99 | kind: Service 100 | metadata: 101 | name: frontend 102 | labels: 103 | app: guestbook 104 | tier: frontend 105 | spec: 106 | # comment or delete the following line if you want to use a LoadBalancer 107 | type: NodePort 108 | # if your cluster supports it, uncomment the following to automatically create 109 | # an external load-balanced IP for the frontend service. 110 | # type: LoadBalancer 111 | ports: 112 | - port: 80 113 | selector: 114 | app: guestbook 115 | tier: frontend 116 | --- 117 | apiVersion: apps/v1 # for k8s versions before 1.9.0 use apps/v1beta2 and before 1.8.0 use extensions/v1beta1 118 | kind: Deployment 119 | metadata: 120 | name: frontend 121 | spec: 122 | selector: 123 | matchLabels: 124 | app: guestbook 125 | tier: frontend 126 | replicas: 3 127 | template: 128 | metadata: 129 | labels: 130 | app: guestbook 131 | tier: frontend 132 | spec: 133 | containers: 134 | - name: php-redis 135 | image: gcr.io/google-samples/gb-frontend:v4 136 | resources: 137 | requests: 138 | cpu: 100m 139 | memory: 100Mi 140 | env: 141 | - name: GET_HOSTS_FROM 142 | value: dns 143 | # If your cluster config does not include a dns service, then to 144 | # instead access environment variables to find service host 145 | # info, comment out the 'value: dns' line above, and uncomment the 146 | # line below: 147 | # value: env 148 | ports: 149 | - containerPort: 80 150 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: helm-sample 3 | description: A Helm chart for Kubernetes 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | appVersion: 1.16.0 24 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "helm-sample.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "helm-sample.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "helm-sample.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "helm-sample.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | echo "Visit http://127.0.0.1:8080 to use your application" 20 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "helm-sample.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "helm-sample.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "helm-sample.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "helm-sample.labels" -}} 37 | helm.sh/chart: {{ include "helm-sample.chart" . }} 38 | {{ include "helm-sample.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "helm-sample.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "helm-sample.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "helm-sample.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "helm-sample.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "helm-sample.fullname" . }} 5 | labels: 6 | {{- include "helm-sample.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "helm-sample.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "helm-sample.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "helm-sample.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | containers: 31 | - name: {{ .Chart.Name }} 32 | securityContext: 33 | {{- toYaml .Values.securityContext | nindent 12 }} 34 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 35 | imagePullPolicy: {{ .Values.image.pullPolicy }} 36 | ports: 37 | - name: http 38 | containerPort: 80 39 | protocol: TCP 40 | livenessProbe: 41 | httpGet: 42 | path: / 43 | port: http 44 | readinessProbe: 45 | httpGet: 46 | path: / 47 | port: http 48 | resources: 49 | {{- toYaml .Values.resources | nindent 12 }} 50 | {{- with .Values.nodeSelector }} 51 | nodeSelector: 52 | {{- toYaml . | nindent 8 }} 53 | {{- end }} 54 | {{- with .Values.affinity }} 55 | affinity: 56 | {{- toYaml . | nindent 8 }} 57 | {{- end }} 58 | {{- with .Values.tolerations }} 59 | tolerations: 60 | {{- toYaml . | nindent 8 }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "helm-sample.fullname" . }} 6 | labels: 7 | {{- include "helm-sample.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "helm-sample.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "helm-sample.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 5 | apiVersion: networking.k8s.io/v1beta1 6 | {{- else -}} 7 | apiVersion: extensions/v1beta1 8 | {{- end }} 9 | kind: Ingress 10 | metadata: 11 | name: {{ $fullName }} 12 | labels: 13 | {{- include "helm-sample.labels" . | nindent 4 }} 14 | {{- with .Values.ingress.annotations }} 15 | annotations: 16 | {{- toYaml . | nindent 4 }} 17 | {{- end }} 18 | spec: 19 | {{- if .Values.ingress.tls }} 20 | tls: 21 | {{- range .Values.ingress.tls }} 22 | - hosts: 23 | {{- range .hosts }} 24 | - {{ . | quote }} 25 | {{- end }} 26 | secretName: {{ .secretName }} 27 | {{- end }} 28 | {{- end }} 29 | rules: 30 | {{- range .Values.ingress.hosts }} 31 | - host: {{ .host | quote }} 32 | http: 33 | paths: 34 | {{- range .paths }} 35 | - path: {{ . }} 36 | backend: 37 | serviceName: {{ $fullName }} 38 | servicePort: {{ $svcPort }} 39 | {{- end }} 40 | {{- end }} 41 | {{- end }} 42 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "helm-sample.fullname" . }} 5 | labels: 6 | {{- include "helm-sample.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "helm-sample.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "helm-sample.serviceAccountName" . }} 6 | labels: 7 | {{- include "helm-sample.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /test/fixtures/helm-sample/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for helm-sample. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: nginx 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "" 12 | 13 | imagePullSecrets: [] 14 | nameOverride: "" 15 | fullnameOverride: "" 16 | 17 | serviceAccount: 18 | # Specifies whether a service account should be created 19 | create: true 20 | # Annotations to add to the service account 21 | annotations: {} 22 | # The name of the service account to use. 23 | # If not set and create is true, a name is generated using the fullname template 24 | name: "" 25 | 26 | podAnnotations: {} 27 | 28 | podSecurityContext: {} 29 | # fsGroup: 2000 30 | 31 | securityContext: {} 32 | # capabilities: 33 | # drop: 34 | # - ALL 35 | # readOnlyRootFilesystem: true 36 | # runAsNonRoot: true 37 | # runAsUser: 1000 38 | 39 | service: 40 | type: ClusterIP 41 | port: 80 42 | 43 | ingress: 44 | enabled: false 45 | annotations: {} 46 | # kubernetes.io/ingress.class: nginx 47 | # kubernetes.io/tls-acme: "true" 48 | hosts: 49 | - host: chart-example.local 50 | paths: [] 51 | tls: [] 52 | # - secretName: chart-example-tls 53 | # hosts: 54 | # - chart-example.local 55 | 56 | resources: {} 57 | # We usually recommend not to specify default resources and to leave this as a conscious 58 | # choice for the user. This also increases chances charts run on environments with little 59 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 60 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 61 | # limits: 62 | # cpu: 100m 63 | # memory: 128Mi 64 | # requests: 65 | # cpu: 100m 66 | # memory: 128Mi 67 | 68 | autoscaling: 69 | enabled: false 70 | minReplicas: 1 71 | maxReplicas: 100 72 | targetCPUUtilizationPercentage: 80 73 | # targetMemoryUtilizationPercentage: 80 74 | 75 | nodeSelector: {} 76 | 77 | tolerations: [] 78 | 79 | affinity: {} 80 | -------------------------------------------------------------------------------- /test/fixtures/sample.yaml: -------------------------------------------------------------------------------- 1 | hello: 1234 2 | world: 111 3 | --- 4 | foo: 5 | - bar 6 | - zoo 7 | - goo 8 | --- 9 | --- 10 | - hello 11 | - world -------------------------------------------------------------------------------- /test/helm.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { Helm, Testing } from '../src'; 3 | import { _child_process } from '../src/_child_process'; 4 | 5 | const SAMPLE_CHART_PATH = path.join(__dirname, 'fixtures', 'helm-sample'); 6 | 7 | beforeEach(() => jest.restoreAllMocks()); 8 | 9 | test('basic usage', () => { 10 | // GIVEN 11 | const chart = Testing.chart(); 12 | 13 | // WHEN 14 | const helm = new Helm(chart, 'sample', { 15 | chart: SAMPLE_CHART_PATH, 16 | }); 17 | 18 | // THEN 19 | expect(helm.releaseName).toEqual('test-sample-c8e2763d'); 20 | expect(Testing.synth(chart)).toMatchSnapshot(); 21 | }); 22 | 23 | test('fails if the helm executable is not found', () => { 24 | // GIVEN 25 | const chart = Testing.chart(); 26 | const executable = `foo-${Math.random() * 999999}`; 27 | 28 | // THEN 29 | expect(() => new Helm(chart, 'sample', { 30 | chart: SAMPLE_CHART_PATH, 31 | helmExecutable: executable, 32 | })).toThrow(`unable to execute '${executable}' to render Helm chart`); 33 | }); 34 | 35 | test('values can be specified when defining the chart', () => { 36 | // GIVEN 37 | const chart = Testing.chart(); 38 | 39 | // WHEN 40 | new Helm(chart, 'sample', { 41 | chart: SAMPLE_CHART_PATH, 42 | values: { 43 | replicaCount: 889, 44 | ingress: { 45 | enabled: false, 46 | }, 47 | nodeSelector: { 48 | selectMe: 'boomboom', 49 | }, 50 | }, 51 | }); 52 | 53 | // THEN 54 | expect(Testing.synth(chart)).toMatchSnapshot(); 55 | }); 56 | 57 | test('releaseName can be used to specify the release name', () => { 58 | // GIVEN 59 | const chart = Testing.chart(); 60 | 61 | // WHEN 62 | const helm = new Helm(chart, 'sample', { 63 | chart: SAMPLE_CHART_PATH, 64 | releaseName: 'your-release', 65 | }); 66 | 67 | // THEN - all names start with "your-release-" 68 | expect(helm.releaseName).toEqual('your-release'); 69 | const names: string[] = Testing.synth(chart).map(obj => obj.metadata.name); 70 | for (const n of names) { 71 | expect(n.startsWith('your-release-')).toBeTruthy(); 72 | } 73 | }); 74 | 75 | test('it is possible to interact with api objects in the chart', () => { 76 | // GIVEN 77 | const chart = Testing.chart(); 78 | 79 | // WHEN 80 | const helm = new Helm(chart, 'sample', { 81 | chart: SAMPLE_CHART_PATH, 82 | }); 83 | 84 | const service = helm.apiObjects.find(o => o.kind === 'ServiceAccount' && o.name === 'test-sample-c8e2763d-helm-sample'); 85 | service?.metadata.addAnnotation('my.annotation', 'hey-there'); 86 | 87 | // THEN 88 | expect(helm.apiObjects.map(o => `${o.kind}/${o.name}`).sort()).toEqual([ 89 | 'Deployment/test-sample-c8e2763d-helm-sample', 90 | 'Service/test-sample-c8e2763d-helm-sample', 91 | 'ServiceAccount/test-sample-c8e2763d-helm-sample', 92 | ]); 93 | 94 | expect(service?.toJson().metadata.annotations).toEqual({ 95 | 'my.annotation': 'hey-there', 96 | }); 97 | }); 98 | 99 | test('helmFlags can be used to specify additional helm options', () => { 100 | // GIVEN 101 | const spawnMock = jest.spyOn(_child_process, 'spawnSync').mockReturnValue({ 102 | status: 0, 103 | stderr: Buffer.from(''), 104 | stdout: Buffer.from(''), 105 | pid: 123, 106 | output: [Buffer.from('stdout', 'utf8'), Buffer.from('stderr', 'utf8')], 107 | signal: null, 108 | }); 109 | 110 | const chart = Testing.chart(); 111 | 112 | // WHEN 113 | new Helm(chart, 'sample', { 114 | chart: SAMPLE_CHART_PATH, 115 | helmFlags: [ 116 | '--description', 'my custom description', 117 | '--no-hooks', 118 | ], 119 | }); 120 | 121 | // THEN 122 | const expectedArgs: string[] = [ 123 | 'template', 124 | '--description', 'my custom description', 125 | '--no-hooks', 126 | 'test-sample-c8e2763d', 127 | SAMPLE_CHART_PATH, 128 | ]; 129 | 130 | expect(spawnMock).toHaveBeenCalledTimes(1); 131 | expect(spawnMock).toHaveBeenCalledWith('helm', expectedArgs, { maxBuffer: 10485760 }); 132 | }); 133 | 134 | test('repo can be used to specify helm repo', () => { 135 | // GIVEN 136 | const spawnMock = jest.spyOn(_child_process, 'spawnSync').mockReturnValue({ 137 | status: 0, 138 | stderr: Buffer.from(''), 139 | stdout: Buffer.from(''), 140 | pid: 123, 141 | output: [Buffer.from('stdout', 'utf8'), Buffer.from('stderr', 'utf8')], 142 | signal: null, 143 | }); 144 | 145 | const chart = Testing.chart(); 146 | 147 | // WHEN 148 | new Helm(chart, 'sample', { 149 | chart: SAMPLE_CHART_PATH, 150 | repo: 'foo-repo', 151 | version: 'foo-version', 152 | namespace: 'foo-namespace', 153 | }); 154 | 155 | // THEN 156 | const expectedArgs: string[] = [ 157 | 'template', 158 | '--repo', 'foo-repo', 159 | '--version', 'foo-version', 160 | '--namespace', 'foo-namespace', 161 | 'test-sample-c8e2763d', 162 | SAMPLE_CHART_PATH, 163 | ]; 164 | 165 | expect(spawnMock).toHaveBeenCalledTimes(1); 166 | expect(spawnMock).toHaveBeenCalledWith('helm', expectedArgs, { maxBuffer: 10485760 }); 167 | }); 168 | 169 | test('propagates helm failures', () => { 170 | // GIVEN 171 | const chart = Testing.chart(); 172 | 173 | // THEN 174 | expect(() => new Helm(chart, 'my-chart', { 175 | chart: SAMPLE_CHART_PATH, 176 | helmFlags: ['--invalid-argument-not-found-boom-boom'], 177 | })).toThrow(/unknown flag/); 178 | }); 179 | -------------------------------------------------------------------------------- /test/include.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as yaml from 'yaml'; 3 | import { Testing, Include, Yaml } from '../src'; 4 | 5 | test('Include can be used to load from YAML', () => { 6 | // GIVEN 7 | const chart = Testing.chart(); 8 | const source = `${__dirname}/fixtures/guestbook-all-in-one.yaml`; 9 | 10 | // WHEN 11 | new Include(chart, 'guestbook', { url: source }); 12 | 13 | // THEN 14 | const expected = yaml.parseAllDocuments(fs.readFileSync(source, 'utf-8'), { 15 | version: '1.1', 16 | }).map(x => x.toJSON()); 17 | const actual = Testing.synth(chart); 18 | expect(actual).toStrictEqual(expected); 19 | }); 20 | 21 | test('skips empty documents', () => { 22 | // GIVEN 23 | const chart = Testing.chart(); 24 | const file = Yaml.tmp([{ }]); 25 | 26 | // WHEN 27 | const inc = new Include(chart, 'empty', { url: file }); 28 | 29 | // THEN 30 | expect(inc.node.children.length).toEqual(0); 31 | }); 32 | 33 | test('multiple resources with the same k8s name can be included so long as their kind is different', () => { 34 | // GIVEN 35 | const chart = Testing.chart(); 36 | 37 | // WHEN 38 | const file = Yaml.tmp([ 39 | { 40 | apiVersion: 'v1', 41 | kind: 'Foo', 42 | metadata: { 43 | name: 'resource1', 44 | }, 45 | }, 46 | { }, 47 | { 48 | apiVersion: 'v1', 49 | kind: 'Bar', 50 | metadata: { 51 | name: 'resource1', 52 | }, 53 | }, 54 | ]); 55 | 56 | const inc = new Include(chart, 'foo', { url: file }); 57 | const ids = inc.node.children.map(x => x.node.id); 58 | expect(ids).toStrictEqual(['resource1-foo', 'resource1-bar']); 59 | }); 60 | 61 | test('apiObjects returns all the API objects', () => { 62 | // GIVEN 63 | const chart = Testing.chart(); 64 | 65 | // WHEN 66 | const file = Yaml.tmp([ 67 | { 68 | apiVersion: 'v1', 69 | kind: 'Foo', 70 | metadata: { 71 | name: 'resource1', 72 | }, 73 | }, 74 | { }, 75 | { 76 | apiVersion: 'v1', 77 | kind: 'Bar', 78 | metadata: { 79 | name: 'resource1', 80 | }, 81 | }, 82 | ]); 83 | 84 | const inc = new Include(chart, 'foo', { url: file }); 85 | expect(inc.apiObjects.map(x => x.kind).sort()).toEqual(['Bar', 'Foo']); 86 | }); -------------------------------------------------------------------------------- /test/json-patch.test.ts: -------------------------------------------------------------------------------- 1 | import { JsonPatch } from '../src'; 2 | 3 | describe('operation factories', () => { 4 | test('add()', () => { 5 | expect(JsonPatch.add('/foo', { hello: 1234 })._toJson()).toStrictEqual({ op: 'add', path: '/foo', value: { hello: 1234 } }); 6 | expect(JsonPatch.add('/foo/bar', 123)._toJson()).toStrictEqual({ op: 'add', path: '/foo/bar', value: 123 }); 7 | }); 8 | 9 | test('remove()', () => { 10 | expect(JsonPatch.remove('/foo/hello/0')._toJson()).toStrictEqual({ op: 'remove', path: '/foo/hello/0' }); 11 | }); 12 | 13 | test('replace()', () => { 14 | expect(JsonPatch.replace('/foo/hello/0', { value: 1234 })._toJson()).toStrictEqual({ op: 'replace', path: '/foo/hello/0', value: { value: 1234 } }); 15 | }); 16 | 17 | test('copy()', () => { 18 | expect(JsonPatch.copy('/from', '/to')._toJson()).toStrictEqual({ op: 'copy', from: '/from', path: '/to' }); 19 | }); 20 | 21 | test('move()', () => { 22 | expect(JsonPatch.move('/from', '/to')._toJson()).toStrictEqual({ op: 'move', from: '/from', path: '/to' }); 23 | }); 24 | 25 | test('test()', () => { 26 | expect(JsonPatch.test('/path', 'value')._toJson()).toStrictEqual({ op: 'test', path: '/path', value: 'value' }); 27 | }); 28 | }); 29 | 30 | test('apply()', () => { 31 | const input = { 32 | hello: 123, 33 | world: { 34 | foo: ['bar', 'baz'], 35 | hi: { 36 | there: 'hello-again', 37 | }, 38 | }, 39 | }; 40 | 41 | const output = JsonPatch.apply(input, 42 | JsonPatch.replace('/world/hi/there', 'goodbye'), 43 | JsonPatch.add('/world/foo/', 'boom'), 44 | JsonPatch.remove('/hello')); 45 | 46 | expect(output).toStrictEqual({ 47 | world: { 48 | foo: ['boom', 'bar', 'baz'], 49 | hi: { 50 | there: 'goodbye', 51 | }, 52 | }, 53 | }); 54 | }); 55 | 56 | test('apply() does not mutate the patches', () => { 57 | const input = { 58 | world: {}, 59 | }; 60 | 61 | const patches = [ 62 | JsonPatch.add('/world/foo', []), 63 | JsonPatch.add('/world/foo/-', 'boom'), 64 | ]; 65 | 66 | JsonPatch.apply(input, ...patches); 67 | 68 | expect(patches[0]._toJson()).toEqual(JsonPatch.add('/world/foo', [])._toJson()); 69 | }); 70 | -------------------------------------------------------------------------------- /test/metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiObject, 3 | ApiObjectMetadataDefinition, 4 | Lazy, 5 | OwnerReference, 6 | Testing, 7 | } from '../src'; 8 | 9 | test('Can add a label', () => { 10 | const meta = new ApiObjectMetadataDefinition({ 11 | apiObject: createApiObject(), 12 | }); 13 | 14 | meta.addLabel('key', 'value'); 15 | 16 | const actual: any = meta.toJson(); 17 | 18 | expect(actual.labels).toEqual({ 19 | key: 'value', 20 | }); 21 | }); 22 | 23 | test('Can add an annotation', () => { 24 | const meta = new ApiObjectMetadataDefinition({ 25 | apiObject: createApiObject(), 26 | }); 27 | 28 | meta.addAnnotation('key', 'value'); 29 | 30 | const actual = meta.toJson(); 31 | 32 | expect(actual.annotations).toEqual({ 33 | key: 'value', 34 | }); 35 | }); 36 | 37 | test('Can add a finalizer', () => { 38 | const meta = new ApiObjectMetadataDefinition({ 39 | apiObject: createApiObject(), 40 | }); 41 | 42 | meta.addFinalizers('my-finalizer'); 43 | 44 | const actual = meta.toJson(); 45 | 46 | expect(actual.finalizers).toEqual(['my-finalizer']); 47 | }); 48 | 49 | test('Can add an owner reference', () => { 50 | const meta = new ApiObjectMetadataDefinition({ 51 | apiObject: createApiObject(), 52 | }); 53 | 54 | meta.addOwnerReference({ 55 | apiVersion: 'v1', 56 | kind: 'Pod', 57 | name: 'mypod', 58 | uid: 'abcdef12-3456-7890-abcd-ef1234567890', 59 | }); 60 | 61 | const actual = meta.toJson(); 62 | 63 | expect(actual.ownerReferences).toEqual([ 64 | { 65 | apiVersion: 'v1', 66 | kind: 'Pod', 67 | name: 'mypod', 68 | uid: 'abcdef12-3456-7890-abcd-ef1234567890', 69 | }, 70 | ]); 71 | }); 72 | 73 | test('Instantiation properties are all respected', () => { 74 | const meta = new ApiObjectMetadataDefinition({ 75 | apiObject: createApiObject(), 76 | labels: { key: 'value' }, 77 | annotations: { key: 'value' }, 78 | name: 'name', 79 | namespace: 'namespace', 80 | }); 81 | 82 | const actual = meta.toJson(); 83 | 84 | const expected = { 85 | name: 'name', 86 | namespace: 'namespace', 87 | annotations: { 88 | key: 'value', 89 | }, 90 | labels: { 91 | key: 'value', 92 | }, 93 | }; 94 | 95 | expect(actual).toStrictEqual(expected); 96 | }); 97 | 98 | test('ensure Lazy properties are resolved', () => { 99 | const meta = new ApiObjectMetadataDefinition({ 100 | apiObject: createApiObject(), 101 | labels: { key: 'value' }, 102 | annotations: { 103 | key: 'value', 104 | lazy: Lazy.any({ 105 | produce: () => { 106 | return { uiMeta: 'is good' }; 107 | }, 108 | }), 109 | }, 110 | name: 'name', 111 | namespace: 'namespace', 112 | }); 113 | 114 | const actual = meta.toJson(); 115 | 116 | const expected = { 117 | name: 'name', 118 | namespace: 'namespace', 119 | annotations: { 120 | key: 'value', 121 | lazy: { 122 | uiMeta: 'is good', 123 | }, 124 | }, 125 | labels: { 126 | key: 'value', 127 | }, 128 | }; 129 | 130 | expect(actual).toStrictEqual(expected); 131 | }); 132 | 133 | test('Can include arbirary key/value options', () => { 134 | const meta = new ApiObjectMetadataDefinition({ 135 | apiObject: createApiObject(), 136 | foo: 123, 137 | bar: { 138 | helloL: 'world', 139 | }, 140 | }); 141 | 142 | meta.add('bar', 'baz'); 143 | 144 | expect(meta.toJson()).toStrictEqual({ 145 | bar: 'baz', 146 | foo: 123, 147 | }); 148 | }); 149 | 150 | test('labels are cloned', () => { 151 | const shared = { foo: 'bar' }; 152 | const met1 = new ApiObjectMetadataDefinition({ 153 | apiObject: createApiObject(), 154 | labels: shared, 155 | }); 156 | 157 | met1.addLabel('bar', 'baz'); 158 | 159 | const met2 = new ApiObjectMetadataDefinition({ 160 | apiObject: createApiObject(), 161 | labels: shared, 162 | }); 163 | 164 | expect(met2.toJson()).toMatchInlineSnapshot(` 165 | Object { 166 | "labels": Object { 167 | "foo": "bar", 168 | }, 169 | } 170 | `); 171 | }); 172 | 173 | test('annotations are cloned', () => { 174 | const shared = { foo: 'bar' }; 175 | const met1 = new ApiObjectMetadataDefinition({ 176 | apiObject: createApiObject(), 177 | annotations: shared, 178 | }); 179 | 180 | met1.addAnnotation('bar', 'baz'); 181 | 182 | const met2 = new ApiObjectMetadataDefinition({ 183 | apiObject: createApiObject(), 184 | annotations: shared, 185 | }); 186 | 187 | expect(met2.toJson()).toMatchInlineSnapshot(` 188 | Object { 189 | "annotations": Object { 190 | "foo": "bar", 191 | }, 192 | } 193 | `); 194 | }); 195 | 196 | test('finalizers are cloned', () => { 197 | const shared = ['foo']; 198 | const met1 = new ApiObjectMetadataDefinition({ 199 | apiObject: createApiObject(), 200 | finalizers: shared, 201 | }); 202 | 203 | met1.addFinalizers('bar', 'baz'); 204 | 205 | const met2 = new ApiObjectMetadataDefinition({ 206 | apiObject: createApiObject(), 207 | finalizers: shared, 208 | }); 209 | 210 | expect(met2.toJson()).toMatchInlineSnapshot(` 211 | Object { 212 | "finalizers": Array [ 213 | "foo", 214 | ], 215 | } 216 | `); 217 | }); 218 | 219 | test('ownerReferences are cloned', () => { 220 | const shared: OwnerReference[] = [ 221 | { apiVersion: 'v1', kind: 'Kind', name: 'name1', uid: 'uid1' }, 222 | ]; 223 | const met1 = new ApiObjectMetadataDefinition({ 224 | apiObject: createApiObject(), 225 | ownerReferences: shared, 226 | }); 227 | 228 | met1.addOwnerReference({ 229 | apiVersion: 'v1', 230 | kind: 'Kind', 231 | name: 'name2', 232 | uid: 'uid2', 233 | }); 234 | 235 | const met2 = new ApiObjectMetadataDefinition({ 236 | apiObject: createApiObject(), 237 | ownerReferences: shared, 238 | }); 239 | 240 | expect(met2.toJson()).toMatchInlineSnapshot(` 241 | Object { 242 | "ownerReferences": Array [ 243 | Object { 244 | "apiVersion": "v1", 245 | "kind": "Kind", 246 | "name": "name1", 247 | "uid": "uid1", 248 | }, 249 | ], 250 | } 251 | `); 252 | }); 253 | 254 | function createApiObject(): ApiObject { 255 | const chart = Testing.chart(); 256 | return new ApiObject(chart, 'ApiObject', { 257 | apiVersion: 'v1', 258 | kind: 'Service', 259 | }); 260 | } 261 | -------------------------------------------------------------------------------- /test/names-legacy.test.ts: -------------------------------------------------------------------------------- 1 | import { createTree } from './util'; 2 | import { Names } from '../src/names'; 3 | 4 | const toDnsName = (path: string, maxLen?: number) => Names.toDnsLabel(createTree(path), { maxLen }); 5 | const toLabelValue = (path: string, delimiter?: string, maxLen?: number) => Names.toLabelValue(createTree(path), { maxLen, delimiter }); 6 | 7 | beforeAll(() => process.env.CDK8S_LEGACY_HASH = '1'); 8 | afterAll(() => delete process.env.CDK8S_LEGACY_HASH); 9 | 10 | describe('toDnsLabel', () => { 11 | test('ignores default children', () => { 12 | expect(toDnsName('hello/default/foo/world/default')).toEqual('hello-foo-world-5d193db9'); 13 | expect(toDnsName('hello/resource/foo/world/resource')).toEqual('hello-foo-world-f5dd971f'); 14 | expect(toDnsName('hello/resource/foo/world/default')).toEqual('hello-foo-world-2f1cee85'); 15 | expect(toDnsName('hello/Resource/foo/world/Default')).toEqual('hello-foo-world-857189b5'); 16 | expect(toDnsName('hello/default/foo/world/resource')).toEqual('hello-foo-world-e89fdfae'); 17 | expect(toDnsName('resource/default')).toEqual('40b6bcd9'); 18 | }); 19 | 20 | test('normalize to dns_name', () => { 21 | expect(toDnsName(' ')).toEqual('36a9e7f1'); 22 | expect(toDnsName('Hello')).toEqual('hello-185f8db3'); 23 | expect(toDnsName('hey*')).toEqual('hey-96c05e6c'); 24 | expect(toDnsName('not allowed')).toEqual('notallowed-a26075ed'); 25 | }); 26 | 27 | test('maximum length for a single term', () => { 28 | expect(toDnsName('1234567890abcdef', 15)).toEqual('123456-8e9916c5'); 29 | expect(toDnsName('x' + 'a'.repeat(64))).toEqual('xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-f69f4ba1'); 30 | }); 31 | 32 | test('single term is not decorated with a hash', () => { 33 | expect(toDnsName('foo')).toEqual('foo'); 34 | expect(toDnsName('foo-bar-123-455')).toEqual('foo-bar-123-455'); 35 | expect(toDnsName('z'.repeat(63))).toEqual('z'.repeat(63)); 36 | }); 37 | 38 | test('multiple terms are separated by "." and a hash is appended', () => { 39 | expect(toDnsName('hello-foo-world')).toEqual('hello-foo-world'); // this is actually a single term 40 | expect(toDnsName('hello-hello-foo-world')).toEqual('hello-hello-foo-world'); // intentionally duplicated 41 | expect(toDnsName('hello-foo/world')).toEqual('hello-foo-world-54700203'); // two terms 42 | expect(toDnsName('hello-foo/foo')).toEqual('hello-foo-foo-e078a973'); // two terms, intentionally duplicated 43 | expect(toDnsName('hello/foo/world')).toEqual('hello-foo-world-4f6e4fd8'); // three terms 44 | }); 45 | 46 | test('invalid max length (minimum is 8 - for hash)', () => { 47 | const expected = /minimum max length for object names is 8/; 48 | expect(() => toDnsName('foo', 4)).toThrow(expected); 49 | expect(() => toDnsName('foo', 7)).toThrow(expected); 50 | 51 | // these are ok 52 | expect(toDnsName('foo', 8)).toEqual('foo'); 53 | expect(toDnsName('foo', 9)).toEqual('foo'); 54 | }); 55 | 56 | test('omit duplicate components in names', () => { 57 | expect(toDnsName('hello/hello/foo/world')).toEqual('hello-foo-world-1d4999d0'); 58 | expect(toDnsName('hello/hello/hello/foo/world')).toEqual('hello-foo-world-d3ebcda3'); 59 | expect(toDnsName('hello/hello/hello/hello/hello')).toEqual('hello-456bb9d7'); 60 | expect(toDnsName('hello/cool/cool/cool/cool')).toEqual('hello-cool-83150e81'); 61 | expect(toDnsName('hello/world/world/world/cool')).toEqual('hello-world-cool-0148a798'); 62 | }); 63 | 64 | test('trimming (prioritize last component)', () => { 65 | expect(toDnsName('hello/world', 8)).toEqual('761e91eb'); 66 | expect(toDnsName('hello/world/this/is/cool', 8)).toEqual('a7c39f00'); 67 | expect(toDnsName('hello/world/this/is/cool', 12)).toEqual('coo-a7c39f00'); 68 | expect(toDnsName('hello/hello/this/is/cool', 12)).toEqual('coo-8751188b'); 69 | expect(toDnsName('hello/cool/cool/cool/cool', 15)).toEqual('h-cool-83150e81'); 70 | expect(toDnsName('hello/world/this/is/cool', 14)).toEqual('cool-a7c39f00'); 71 | expect(toDnsName('hello/world/this/is/cool', 15)).toEqual('i-cool-a7c39f00'); 72 | expect(toDnsName('hello/world/this/is/cool', 25)).toEqual('wor-this-is-cool-a7c39f00'); 73 | }); 74 | 75 | test('filter empty components', () => { 76 | expect(toDnsName('hello/world---this-is-cool---')).toEqual('hello-world-this-is-cool-85209c22'); 77 | expect(toDnsName('hello-world-this-is-cool')).toEqual('hello-world-this-is-cool'); 78 | expect(toDnsName('hello/world-this/is-cool')).toEqual('hello-world-this-is-cool-9bdccb95'); 79 | }); 80 | }); 81 | 82 | describe('toLabel', () => { 83 | test('ignores default children', () => { 84 | expect(toLabelValue('hello/default/foo/world/default')).toEqual('hello-foo-world-5d193db9'); 85 | expect(toLabelValue('hello/resource/foo/world/resource')).toEqual('hello-foo-world-f5dd971f'); 86 | expect(toLabelValue('hello/resource/foo/world/default')).toEqual('hello-foo-world-2f1cee85'); 87 | expect(toLabelValue('hello/Resource/foo/world/Default')).toEqual('hello-foo-world-857189b5'); 88 | expect(toLabelValue('hello/default/foo/world/resource')).toEqual('hello-foo-world-e89fdfae'); 89 | expect(toLabelValue('resource/default')).toEqual('40b6bcd9'); 90 | }); 91 | 92 | test('normalize to dns_name', () => { 93 | expect(toLabelValue(' ')).toEqual('36a9e7f1'); 94 | expect(toLabelValue('Hello')).toEqual('Hello'); // Upper case is allowed for a label 95 | expect(toLabelValue('hey*')).toEqual('hey-96c05e6c'); 96 | expect(toLabelValue('not allowed')).toEqual('notallowed-a26075ed'); 97 | }); 98 | 99 | test('maximum length for a single term', () => { 100 | expect(toLabelValue('1234567890abcdef', '-', 15)).toEqual('123456-8e9916c5'); 101 | expect(toLabelValue('x' + 'a'.repeat(64))).toEqual('xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-f69f4ba1'); 102 | }); 103 | 104 | test('single term is not decorated with a hash', () => { 105 | expect(toLabelValue('foo')).toEqual('foo'); 106 | expect(toLabelValue('foo-bar-123-455')).toEqual('foo-bar-123-455'); 107 | expect(toLabelValue('z'.repeat(63))).toEqual('z'.repeat(63)); 108 | }); 109 | 110 | test('multiple terms are separated by "." and a hash is appended', () => { 111 | expect(toLabelValue('hello-foo-world')).toEqual('hello-foo-world'); // this is actually a single term 112 | expect(toLabelValue('hello-hello-foo-world')).toEqual('hello-hello-foo-world'); // intentionally duplicated 113 | expect(toLabelValue('hello-foo/world')).toEqual('hello-foo-world-54700203'); // two terms 114 | expect(toLabelValue('hello-foo/foo')).toEqual('hello-foo-foo-e078a973'); // two terms, intentionally duplicated 115 | expect(toLabelValue('hello/foo/world')).toEqual('hello-foo-world-4f6e4fd8'); // three terms 116 | }); 117 | 118 | test('invalid max length (minimum is 8 - for hash)', () => { 119 | const expected = /minimum max length for label is 8/; 120 | expect(() => toLabelValue('foo', '-', 4)).toThrow(expected); 121 | expect(() => toLabelValue('foo', '-', 7)).toThrow(expected); 122 | 123 | // these are ok 124 | expect(toLabelValue('foo', '-', 8)).toEqual('foo'); 125 | expect(toLabelValue('foo', '-', 9)).toEqual('foo'); 126 | }); 127 | 128 | test('omit duplicate components in names', () => { 129 | expect(toLabelValue('hello/hello/foo/world')).toEqual('hello-foo-world-1d4999d0'); 130 | expect(toLabelValue('hello/hello/hello/foo/world')).toEqual('hello-foo-world-d3ebcda3'); 131 | expect(toLabelValue('hello/hello/hello/hello/hello')).toEqual('hello-456bb9d7'); 132 | expect(toLabelValue('hello/cool/cool/cool/cool')).toEqual('hello-cool-83150e81'); 133 | expect(toLabelValue('hello/world/world/world/cool')).toEqual('hello-world-cool-0148a798'); 134 | }); 135 | 136 | test('trimming (prioritize last component)', () => { 137 | expect(toLabelValue('hello/world', '-', 8)).toEqual('761e91eb'); 138 | expect(toLabelValue('hello/world/this/is/cool', '-', 8)).toEqual('a7c39f00'); 139 | expect(toLabelValue('hello/world/this/is/cool', '-', 12)).toEqual('coo-a7c39f00'); 140 | expect(toLabelValue('hello/hello/this/is/cool', '-', 12)).toEqual('coo-8751188b'); 141 | expect(toLabelValue('hello/cool/cool/cool/cool', '-', 15)).toEqual('h-cool-83150e81'); 142 | expect(toLabelValue('hello/world/this/is/cool', '-', 14)).toEqual('cool-a7c39f00'); 143 | expect(toLabelValue('hello/world/this/is/cool', '-', 15)).toEqual('i-cool-a7c39f00'); 144 | expect(toLabelValue('hello/world/this/is/cool', '-', 25)).toEqual('wor-this-is-cool-a7c39f00'); 145 | }); 146 | 147 | test('filter empty components', () => { 148 | expect(toLabelValue('hello---this/is/cool/-')).toEqual('hello-this-is-cool-a30e4c1e'); 149 | expect(toLabelValue('hello---this/is---/cool/-')).toEqual('hello-this-is-cool-a9b9d489'); 150 | }); 151 | }); -------------------------------------------------------------------------------- /test/names.test.ts: -------------------------------------------------------------------------------- 1 | import { createTree } from './util'; 2 | import { NameOptions, Names } from '../src/names'; 3 | 4 | const toDnsName = (path: string, options: NameOptions = { }) => Names.toDnsLabel(createTree(path), options); 5 | const toLabelValue = (path: string, options: NameOptions = { }) => Names.toLabelValue(createTree(path), options); 6 | 7 | describe('toDnsLabel', () => { 8 | test('ignores default children', () => { 9 | expect(toDnsName('hello/default/foo/world/default')).toEqual('hello-foo-world-c8ceb89a'); 10 | expect(toDnsName('hello/resource/foo/world/resource')).toEqual('hello-foo-world-c8c051a2'); 11 | expect(toDnsName('hello/resource/foo/world/default')).toEqual('hello-foo-world-c8285558'); 12 | expect(toDnsName('hello/Resource/foo/world/Default')).toEqual('hello-foo-world-c8455d08'); 13 | expect(toDnsName('hello/default/foo/world/resource')).toEqual('hello-foo-world-c83a0f50'); 14 | expect(toDnsName('resource/default')).toEqual('c818ce2d'); 15 | }); 16 | 17 | test('normalize to dns_name', () => { 18 | expect(toDnsName('Hello')).toEqual('hello-c8a347e4'); 19 | expect(toDnsName('hey*')).toEqual('hey-c808ed9e'); 20 | expect(toDnsName('not allowed')).toEqual('notallowed-c82fed05'); 21 | }); 22 | 23 | test('maximum length for a single term', () => { 24 | expect(toDnsName('1234567890abcdef', { maxLen: 15 })).toEqual('123456-c85fab94'); 25 | expect(toDnsName('x' + 'a'.repeat(64))).toEqual('xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-c86953f2'); 26 | }); 27 | 28 | test('single term is not decorated with a hash', () => { 29 | expect(toDnsName('foo')).toEqual('foo'); 30 | expect(toDnsName('foo-bar-123-455')).toEqual('foo-bar-123-455'); 31 | expect(toDnsName('z'.repeat(63))).toEqual('z'.repeat(63)); 32 | }); 33 | 34 | test('multiple terms are separated by "." and a hash is appended', () => { 35 | expect(toDnsName('hello-foo-world')).toEqual('hello-foo-world'); // this is actually a single term 36 | expect(toDnsName('hello-hello-foo-world')).toEqual('hello-hello-foo-world'); // intentionally duplicated 37 | expect(toDnsName('hello-foo/world')).toEqual('hello-foo-world-c83c4a8a'); // two terms 38 | expect(toDnsName('hello-foo/foo')).toEqual('hello-foo-foo-c884a60a'); // two terms, intentionally duplicated 39 | expect(toDnsName('hello/foo/world')).toEqual('hello-foo-world-c89b166b'); // three terms 40 | }); 41 | 42 | test('invalid max length (minimum is 8 - for hash)', () => { 43 | const expected = /minimum max length for object names is 8/; 44 | expect(() => toDnsName('foo', { maxLen: 4 })).toThrow(expected); 45 | expect(() => toDnsName('foo', { maxLen: 7 })).toThrow(expected); 46 | 47 | // these are ok 48 | expect(toDnsName('foo', { maxLen: 8 })).toEqual('foo'); 49 | expect(toDnsName('foo', { maxLen: 9 })).toEqual('foo'); 50 | }); 51 | 52 | test('omit duplicate components in names', () => { 53 | expect(toDnsName('hello/hello/foo/world')).toEqual('hello-foo-world-c8538d75'); 54 | expect(toDnsName('hello/hello/hello/foo/world')).toEqual('hello-foo-world-c815bea4'); 55 | expect(toDnsName('hello/hello/hello/hello/hello')).toEqual('hello-c830c284'); 56 | expect(toDnsName('hello/cool/cool/cool/cool')).toEqual('hello-cool-c816948a'); 57 | expect(toDnsName('hello/world/world/world/cool')).toEqual('hello-world-cool-c8e259cb'); 58 | }); 59 | 60 | test('trimming (prioritize last component)', () => { 61 | expect(toDnsName('hello/world', { maxLen: 8 })).toEqual('c85bc96a'); 62 | expect(toDnsName('hello/world/this/is/cool', { maxLen: 8 })).toEqual('c80ec725'); 63 | expect(toDnsName('hello/world/this/is/cool', { maxLen: 12 })).toEqual('coo-c80ec725'); 64 | expect(toDnsName('hello/hello/this/is/cool', { maxLen: 12 })).toEqual('coo-c812c430'); 65 | expect(toDnsName('hello/cool/cool/cool/cool', { maxLen: 15 })).toEqual('h-cool-c816948a'); 66 | expect(toDnsName('hello/world/this/is/cool', { maxLen: 14 })).toEqual('cool-c80ec725'); 67 | expect(toDnsName('hello/world/this/is/cool', { maxLen: 15 })).toEqual('i-cool-c80ec725'); 68 | expect(toDnsName('hello/world/this/is/cool', { maxLen: 25 })).toEqual('wor-this-is-cool-c80ec725'); 69 | }); 70 | 71 | test('filter empty components', () => { 72 | expect(toDnsName('hello/world---this-is-cool---')).toEqual('hello-world-this-is-cool-c88665d5'); 73 | expect(toDnsName('hello-world-this-is-cool')).toEqual('hello-world-this-is-cool'); 74 | expect(toDnsName('hello/world-this/is-cool')).toEqual('hello-world-this-is-cool-c81c7478'); 75 | }); 76 | 77 | test('optional hash', () => { 78 | expect(toDnsName('hello/default/foo/world/resource', { includeHash: false })).toEqual('hello-foo-world'); 79 | expect(toDnsName('hello/world/this/is/cool', { includeHash: false, maxLen: 8 })).toEqual('is-cool'); 80 | }); 81 | }); 82 | 83 | describe('toLabel', () => { 84 | 85 | test('ignores default children', () => { 86 | expect(toLabelValue('hello/default/foo/world/default')).toEqual('hello-foo-world-c8ceb89a'); 87 | expect(toLabelValue('hello/resource/foo/world/resource')).toEqual('hello-foo-world-c8c051a2'); 88 | expect(toLabelValue('hello/resource/foo/world/default')).toEqual('hello-foo-world-c8285558'); 89 | expect(toLabelValue('hello/Resource/foo/world/Default')).toEqual('hello-foo-world-c8455d08'); 90 | expect(toLabelValue('hello/default/foo/world/resource')).toEqual('hello-foo-world-c83a0f50'); 91 | expect(toLabelValue('resource/default')).toEqual('c818ce2d'); 92 | }); 93 | 94 | test('normalize to dns_name', () => { 95 | expect(toLabelValue('Hello')).toEqual('Hello'); // Upper case is allowed for a label 96 | expect(toLabelValue('hey*')).toEqual('hey-c808ed9e'); 97 | expect(toLabelValue('not allowed')).toEqual('notallowed-c82fed05'); 98 | }); 99 | 100 | test('maximum length for a single term', () => { 101 | expect(toLabelValue('1234567890abcdef', { maxLen: 15, delimiter: '-' })).toEqual('123456-c85fab94'); 102 | expect(toLabelValue('x' + 'a'.repeat(64))).toEqual('xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-c86953f2'); 103 | }); 104 | 105 | test('single term is not decorated with a hash', () => { 106 | expect(toLabelValue('foo')).toEqual('foo'); 107 | expect(toLabelValue('foo-bar-123-455')).toEqual('foo-bar-123-455'); 108 | expect(toLabelValue('z'.repeat(63))).toEqual('z'.repeat(63)); 109 | }); 110 | 111 | test('multiple terms are separated by "." and a hash is appended', () => { 112 | expect(toLabelValue('hello-foo-world')).toEqual('hello-foo-world'); // this is actually a single term 113 | expect(toLabelValue('hello-hello-foo-world')).toEqual('hello-hello-foo-world'); // intentionally duplicated 114 | expect(toLabelValue('hello-foo/world')).toEqual('hello-foo-world-c83c4a8a'); // two terms 115 | expect(toLabelValue('hello-foo/foo')).toEqual('hello-foo-foo-c884a60a'); // two terms, intentionally duplicated 116 | expect(toLabelValue('hello/foo/world')).toEqual('hello-foo-world-c89b166b'); // three terms 117 | }); 118 | 119 | test('invalid max length (minimum is 8 - for hash)', () => { 120 | const expected = /minimum max length for label is 8/; 121 | expect(() => toLabelValue('foo', { maxLen: 4 })).toThrow(expected); 122 | expect(() => toLabelValue('foo', { maxLen: 7 })).toThrow(expected); 123 | 124 | // these are ok 125 | expect(toLabelValue('foo', { maxLen: 8 })).toEqual('foo'); 126 | expect(toLabelValue('foo', { maxLen: 9 })).toEqual('foo'); 127 | }); 128 | 129 | test('omit duplicate components in names', () => { 130 | expect(toLabelValue('hello/hello/foo/world')).toEqual('hello-foo-world-c8538d75'); 131 | expect(toLabelValue('hello/hello/hello/foo/world')).toEqual('hello-foo-world-c815bea4'); 132 | expect(toLabelValue('hello/hello/hello/hello/hello')).toEqual('hello-c830c284'); 133 | expect(toLabelValue('hello/cool/cool/cool/cool')).toEqual('hello-cool-c816948a'); 134 | expect(toLabelValue('hello/world/world/world/cool')).toEqual('hello-world-cool-c8e259cb'); 135 | }); 136 | 137 | test('trimming (prioritize last component)', () => { 138 | expect(toLabelValue('hello/world', { maxLen: 8 })).toEqual('c85bc96a'); 139 | expect(toLabelValue('hello/world/this/is/cool', { maxLen: 8 })).toEqual('c80ec725'); 140 | expect(toLabelValue('hello/world/this/is/cool', { maxLen: 12 })).toEqual('coo-c80ec725'); 141 | expect(toLabelValue('hello/hello/this/is/cool', { maxLen: 12 })).toEqual('coo-c812c430'); 142 | expect(toLabelValue('hello/cool/cool/cool/cool', { maxLen: 15 })).toEqual('h-cool-c816948a'); 143 | expect(toLabelValue('hello/world/this/is/cool', { maxLen: 14 })).toEqual('cool-c80ec725'); 144 | expect(toLabelValue('hello/world/this/is/cool', { maxLen: 15 })).toEqual('i-cool-c80ec725'); 145 | expect(toLabelValue('hello/world/this/is/cool', { maxLen: 25 })).toEqual('wor-this-is-cool-c80ec725'); 146 | }); 147 | 148 | test('filter empty components', () => { 149 | expect(toLabelValue('hello---this/is/cool/-')).toEqual('hello-this-is-cool-c83b900b'); 150 | expect(toLabelValue('hello---this/is---/cool/-')).toEqual('hello-this-is-cool-c82d69dd'); 151 | }); 152 | 153 | test('optional hash', () => { 154 | expect(toLabelValue('hello/default/foo/world/resource', { includeHash: false })).toEqual('hello-foo-world'); 155 | expect(toLabelValue('hello/world/this/is/cool', { includeHash: false, maxLen: 8 })).toEqual('is-cool'); 156 | }); 157 | }); -------------------------------------------------------------------------------- /test/size.test.ts: -------------------------------------------------------------------------------- 1 | import { Size, SizeRoundingBehavior } from '../src'; 2 | 3 | test('negative amount', () => { 4 | expect(() => Size.kibibytes(-1)).toThrow(/negative/); 5 | }); 6 | 7 | test('Size in kibibytes', () => { 8 | const size = Size.kibibytes(4_294_967_296); 9 | 10 | expect(size.toKibibytes()).toEqual(4_294_967_296); 11 | expect(size.toMebibytes()).toEqual(4_194_304); 12 | expect(size.toGibibytes()).toEqual(4_096); 13 | expect(size.toTebibytes()).toEqual(4); 14 | expect(() => size.toPebibytes()).toThrow(/'4294967296 kibibytes' cannot be converted into a whole number/); 15 | expect(size.toPebibytes({ rounding: SizeRoundingBehavior.NONE })).toBeCloseTo(4_294_967_296 / (1024 * 1024 * 1024 * 1024)); 16 | 17 | expect(Size.kibibytes(4 * 1024 * 1024).toGibibytes()).toEqual(4); 18 | }); 19 | 20 | test('Size in mebibytes', () => { 21 | const size = Size.mebibytes(4_194_304); 22 | 23 | expect(size.toKibibytes()).toEqual(4_294_967_296); 24 | expect(size.toMebibytes()).toEqual(4_194_304); 25 | expect(size.toGibibytes()).toEqual(4_096); 26 | expect(size.toTebibytes()).toEqual(4); 27 | expect(() => size.toPebibytes()).toThrow(/'4194304 mebibytes' cannot be converted into a whole number/); 28 | expect(size.toPebibytes({ rounding: SizeRoundingBehavior.NONE })).toBeCloseTo(4_194_304 / (1024 * 1024 * 1024)); 29 | 30 | expect(Size.mebibytes(4 * 1024).toGibibytes()).toEqual(4); 31 | }); 32 | 33 | test('Size in gibibyte', () => { 34 | const size = Size.gibibytes(5); 35 | 36 | expect(size.toKibibytes()).toEqual(5_242_880); 37 | expect(size.toMebibytes()).toEqual(5_120); 38 | expect(size.toGibibytes()).toEqual(5); 39 | expect(() => size.toTebibytes()).toThrow(/'5 gibibytes' cannot be converted into a whole number/); 40 | expect(size.toTebibytes({ rounding: SizeRoundingBehavior.NONE })).toBeCloseTo(5 / 1024); 41 | expect(() => size.toPebibytes()).toThrow(/'5 gibibytes' cannot be converted into a whole number/); 42 | expect(size.toPebibytes({ rounding: SizeRoundingBehavior.NONE })).toBeCloseTo(5 / (1024 * 1024)); 43 | 44 | expect(Size.gibibytes(4096).toTebibytes()).toEqual(4); 45 | }); 46 | 47 | test('Size in tebibyte', () => { 48 | const size = Size.tebibytes(5); 49 | 50 | expect(size.toKibibytes()).toEqual(5_368_709_120); 51 | expect(size.toMebibytes()).toEqual(5_242_880); 52 | expect(size.toGibibytes()).toEqual(5_120); 53 | expect(size.toTebibytes()).toEqual(5); 54 | expect(() => size.toPebibytes()).toThrow(/'5 tebibytes' cannot be converted into a whole number/); 55 | expect(size.toPebibytes({ rounding: SizeRoundingBehavior.NONE })).toBeCloseTo(5 / 1024); 56 | 57 | expect(Size.tebibytes(4096).toPebibytes()).toEqual(4); 58 | }); 59 | 60 | test('Size in pebibyte', () => { 61 | const size = Size.pebibyte(5); 62 | 63 | expect(size.toKibibytes()).toEqual(5_497_558_138_880); 64 | expect(size.toMebibytes()).toEqual(5_368_709_120); 65 | expect(size.toGibibytes()).toEqual(5_242_880); 66 | expect(size.toTebibytes()).toEqual(5_120); 67 | expect(size.toPebibytes()).toEqual(5); 68 | }); 69 | 70 | test('rounding behavior', () => { 71 | const size = Size.mebibytes(5_200); 72 | 73 | expect(() => size.toGibibytes()).toThrow(/cannot be converted into a whole number/); 74 | expect(() => size.toGibibytes({ rounding: SizeRoundingBehavior.FAIL })).toThrow(/cannot be converted into a whole number/); 75 | 76 | expect(size.toGibibytes({ rounding: SizeRoundingBehavior.FLOOR })).toEqual(5); 77 | expect(size.toTebibytes({ rounding: SizeRoundingBehavior.FLOOR })).toEqual(0); 78 | expect(size.toKibibytes({ rounding: SizeRoundingBehavior.FLOOR })).toBeCloseTo(5_324_800); 79 | 80 | expect(size.toGibibytes({ rounding: SizeRoundingBehavior.NONE })).toEqual(5.078125); 81 | expect(size.toTebibytes({ rounding: SizeRoundingBehavior.NONE })).toEqual(5200 / (1024 * 1024)); 82 | expect(size.toKibibytes({ rounding: SizeRoundingBehavior.NONE })).toEqual(5_324_800); 83 | }); 84 | 85 | test('asString function gives abbreviated units', () => { 86 | expect(Size.kibibytes(10).asString()).toEqual('10Ki'); 87 | expect(Size.mebibytes(10).asString()).toEqual('10Mi'); 88 | expect(Size.gibibytes(10).asString()).toEqual('10Gi'); 89 | expect(Size.tebibytes(10).asString()).toEqual('10Ti'); 90 | expect(Size.pebibyte(10).asString()).toEqual('10Pi'); 91 | }); 92 | -------------------------------------------------------------------------------- /test/tokens.test.ts: -------------------------------------------------------------------------------- 1 | import { ApiObject, Lazy, Testing } from '../src'; 2 | import { resolve } from '../src/resolve'; 3 | 4 | test('lazy', () => { 5 | 6 | const chart = Testing.chart(); 7 | 8 | const apiObject = new ApiObject(chart, 'Pod', { 9 | apiVersion: 'v1', 10 | kind: 'Pod', 11 | metadata: { 12 | name: 'mypod', 13 | }, 14 | }); 15 | 16 | // GIVEN 17 | const hello = { 18 | number: Lazy.any({ produce: () => 1234 }), 19 | string: Lazy.any({ produce: () => 'hello' }), 20 | implicit: createImplictToken(908), 21 | }; 22 | 23 | expect(resolve([], hello, apiObject)).toStrictEqual({ 24 | number: 1234, 25 | string: 'hello', 26 | implicit: 908, 27 | }); 28 | }); 29 | 30 | // this is how union tokens are generated by cdk8s-cli 31 | function createImplictToken(value: any) { 32 | const implicit = {}; 33 | Object.defineProperty(implicit, 'resolve', { value: () => value }); 34 | return implicit; 35 | } 36 | 37 | test('does not resolve aws-cdk tokens', () => { 38 | 39 | const chart = Testing.chart(); 40 | 41 | new ApiObject(chart, 'Pod', { 42 | apiVersion: 'v1', 43 | kind: 'Pod', 44 | metadata: { 45 | name: 'mypod', 46 | }, 47 | spec: { 48 | // this is how an aws-cdk token looks like in string form 49 | bucketName: '${Token[TOKEN.61]}', 50 | someLazyProperty: Lazy.any({ produce: () => 'lazyValue' }), 51 | }, 52 | }); 53 | 54 | const manifest = chart.toJson(); 55 | 56 | expect(manifest).toEqual([{ 57 | apiVersion: 'v1', 58 | kind: 'Pod', 59 | metadata: { 60 | name: 'mypod', 61 | }, 62 | spec: { 63 | // aws-cdk token left untouched on chart synth. 64 | bucketName: '${Token[TOKEN.61]}', 65 | someLazyProperty: 'lazyValue', 66 | }, 67 | }]); 68 | 69 | }); -------------------------------------------------------------------------------- /test/util.test.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeValue } from '../src/_util'; 2 | 3 | describe('sanitizeValue', () => { 4 | 5 | test('default options', () => { 6 | expect(sanitizeValue(null)).toBe(undefined); 7 | expect(sanitizeValue(undefined)).toBe(undefined); 8 | expect(sanitizeValue({ })).toStrictEqual({ }); 9 | expect(sanitizeValue([])).toStrictEqual([]); 10 | expect(sanitizeValue(1)).toBe(1); 11 | expect(sanitizeValue({ hello: 123 })).toStrictEqual({ hello: 123 }); 12 | expect(sanitizeValue([1, 2, 3])).toStrictEqual([1, 2, 3]); 13 | expect(sanitizeValue({ xoo: 123, foo: [] })).toStrictEqual({ xoo: 123, foo: [] }); 14 | expect(sanitizeValue({ xoo: { }, foo: { bar: { zoo: undefined, hey: { }, me: 123 } } })) 15 | .toStrictEqual({ xoo: { }, foo: { bar: { hey: { }, me: 123 } } }); 16 | expect(sanitizeValue({ xoo: 123, foo: [1, 2, { foo: 123, bar: undefined, zoo: [] }, 3] })) 17 | .toStrictEqual({ xoo: 123, foo: [1, 2, { foo: 123, zoo: [] }, 3] }); 18 | expect(sanitizeValue([1, 2, 3, [], { }, 4])).toStrictEqual([1, 2, 3, [], { }, 4]); // special case 19 | 20 | expect(() => sanitizeValue(new Dummy())).toThrow(/can't render non-simple object of type 'Dummy'/); 21 | }); 22 | 23 | test('filterEmptyArrays', () => { 24 | const options = { 25 | filterEmptyArrays: true, 26 | }; 27 | 28 | expect(sanitizeValue([], options)).toBe(undefined); 29 | expect(sanitizeValue({ foo: [], bar: [1, 2] }, options)).toStrictEqual({ bar: [1, 2] }); 30 | expect(sanitizeValue({ foo: { bar: [] } }, options)).toStrictEqual({ foo: { } }); 31 | }); 32 | 33 | test('filterEmptyObjects', () => { 34 | const options = { 35 | filterEmptyObjects: true, 36 | }; 37 | 38 | expect(sanitizeValue({ }, options)).toBe(undefined); 39 | expect(sanitizeValue({ foo: { }, bar: { hey: 'there' } }, options)).toStrictEqual({ bar: { hey: 'there' } }); 40 | expect(sanitizeValue({ foo: { bar: { } } }, options)).toStrictEqual(undefined); 41 | }); 42 | 43 | test('sortKeys', () => { 44 | const input = { zzz: 999, aaa: 111, nested: { foo: { zag: [1, 2, 3], bar: '1111' } } }; 45 | expect(str(sanitizeValue(input))).toMatchSnapshot(); 46 | expect(str(sanitizeValue(input, { sortKeys: false }))).toMatchSnapshot(); 47 | }); 48 | 49 | class Dummy { } 50 | }); 51 | 52 | function str(obj: any) { 53 | return JSON.stringify(obj, undefined, 2); 54 | } -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | 3 | export function createTree(path: string) { 4 | const queue = path.split('/'); 5 | let curr = new Construct(undefined as any, undefined as any); 6 | 7 | while (queue.length) { 8 | const id = queue.shift() as string; 9 | curr = new Construct(curr, id!); 10 | } 11 | 12 | return curr; 13 | } 14 | -------------------------------------------------------------------------------- /test/yaml.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | import { Yaml } from '../src/yaml'; 5 | 6 | describe('load', () => { 7 | test('from file', () => { 8 | expect(Yaml.load(`${__dirname}/fixtures/sample.yaml`)).toMatchSnapshot(); 9 | }); 10 | 11 | test('from url', () => { 12 | expect(Yaml.load('https://raw.githubusercontent.com/kubernetes/examples/33dfad21f4f4364c9eb7d48741b954915552ca0a/guestbook/all-in-one/guestbook-all-in-one.yaml')).toMatchSnapshot(); 13 | }); 14 | 15 | test('empty documents are filtered out', () => { 16 | const file = Yaml.tmp([ 17 | { doc: 1 }, 18 | null, // filtered 19 | 'str_doc', // not filtered 20 | { }, // filtered 21 | '', // not filtered 22 | undefined, // filtered 23 | 0, // not filtered 24 | [], // filtered 25 | { doc: 2 }, 26 | ]); 27 | 28 | expect(Yaml.load(file)).toMatchSnapshot(); 29 | }); 30 | }); 31 | 32 | describe('save', () => { 33 | test('single document', () => { 34 | const outputFile = Yaml.tmp([{ foo: 'bar', hello: [1, 2, 3] }]); 35 | expect(fs.readFileSync(outputFile, 'utf-8')).toMatchSnapshot(); 36 | }); 37 | 38 | test('multiple documents', () => { 39 | const outputFile = Yaml.tmp([ 40 | { foo: 'bar', hello: [1, 2, 3] }, 41 | { number: 2 }, 42 | ]); 43 | 44 | expect(fs.readFileSync(outputFile, 'utf-8')).toMatchSnapshot(); 45 | }); 46 | 47 | test('empty values are preserved', () => { 48 | const temp = Yaml.tmp([ 49 | { 50 | i_am_undefined: undefined, // converted to "null" 51 | i_am_null: null, 52 | empty_array: [], 53 | empty_object: {}, 54 | }, 55 | ]); 56 | 57 | expect(fs.readFileSync(temp, 'utf-8')).toMatchSnapshot(); 58 | }); 59 | 60 | test('empty documents are respected', () => { 61 | const outputFile = Yaml.tmp([ 62 | {}, 63 | {}, 64 | undefined, 65 | { empty: true }, 66 | {}, 67 | ]); 68 | 69 | expect(fs.readFileSync(outputFile, 'utf-8')).toMatchSnapshot(); 70 | }); 71 | 72 | test('strings are quoted', () => { 73 | const outputFile = Yaml.tmp([{ 74 | foo: 'on', 75 | bar: 'this has a "big quote"', 76 | not_a_string: true, 77 | }]); 78 | 79 | expect(fs.readFileSync(outputFile, 'utf-8')).toMatchSnapshot(); 80 | }); 81 | 82 | test('escaped character does not divided by cross line boundaries', () => { 83 | const longStringList = [ 84 | '^(((\d*(\.\d*)?h)|(\d*(\.\d*)?m)|(\d*(\.\d*)?s)|' + 85 | '(\d*(\.\d*)?ms)|(\d*(\.\d*)?us)|(\d*(\.\d*)?µs)|' + 86 | '(\d*(\.\d*)?ns))+|infinity|infinite)$', 87 | ]; 88 | const dumpPath = Yaml.tmp(longStringList); 89 | expect(fs.readFileSync(dumpPath, 'utf8').trimEnd()).not.toContain('\n'); 90 | }); 91 | }); 92 | 93 | test('yaml 1.1 octal numbers are parsed correctly', () => { 94 | const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk8s-')); 95 | const filePath = path.join(tmpdir, 'temp.yaml'); 96 | fs.writeFileSync(filePath, 'foo: 0755'); 97 | 98 | expect(Yaml.load(filePath)).toEqual([{ foo: 493 }]); 99 | }); 100 | 101 | test('multi-line text block with long line keep line break', () => { 102 | const yamlString = { 103 | foo: `[section] 104 | abc: s 105 | def: 012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789`, 106 | bar: 'something', 107 | }; 108 | expect(Yaml.stringify(yamlString)).toMatchSnapshot(); 109 | }); 110 | 111 | test('stringify() accepts multiple documents', () => { 112 | const actual = Yaml.stringify({ foo: 123 }, { bar: ['hi', 'there'], jam: 12 }); 113 | expect(actual).toStrictEqual([ 114 | 'foo: 123', 115 | '---', 116 | 'bar:', 117 | ' - hi', 118 | ' - there', 119 | 'jam: 12', 120 | '', 121 | ].join('\n')); 122 | }); 123 | 124 | test("strings don't become booleans", () => { 125 | const actual = Yaml.stringify({ a_yes: 'yes', a_no: 'no', a_true: 'true', a_false: 'false' }); 126 | expect(actual).toStrictEqual([ 127 | 'a_yes: "yes"', 128 | 'a_no: "no"', 129 | 'a_true: "true"', 130 | 'a_false: "false"', 131 | '', 132 | ].join('\n')); 133 | }); 134 | -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | { 3 | "compilerOptions": { 4 | "alwaysStrict": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "inlineSourceMap": true, 9 | "inlineSources": true, 10 | "lib": [ 11 | "es2020" 12 | ], 13 | "module": "CommonJS", 14 | "noEmitOnError": false, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitAny": true, 17 | "noImplicitReturns": true, 18 | "noImplicitThis": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "resolveJsonModule": true, 22 | "strict": true, 23 | "strictNullChecks": true, 24 | "strictPropertyInitialization": true, 25 | "stripInternal": true, 26 | "target": "ES2020" 27 | }, 28 | "include": [ 29 | "src/**/*.ts", 30 | "test/**/*.ts", 31 | ".projenrc.js" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | --------------------------------------------------------------------------------