├── .github ├── actions │ └── setup-goversion │ │ └── action.yml ├── dependabot.yml ├── issue_label_bot.yaml ├── renovate-config.json5 └── workflows │ ├── acceptance-tests.yml │ ├── check-dagger-drift.yml │ ├── docker.yml │ ├── lint-pr-title.yml │ ├── publish-page.yml │ ├── release-please.yml │ ├── renovate.yml │ └── tests.yml ├── .gitignore ├── .golangci.yml ├── .pre-commit-hooks.yaml ├── .release-please-manifest.json ├── .release-please.json ├── .vscode └── settings.json ├── ADOPTERS.md ├── CHANGELOG.md ├── CODEOWNERS ├── Dockerfile ├── GOVERNANCE.md ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── README.md ├── SECURITY.md ├── acceptance-tests ├── README.md ├── apply_test.go ├── export_test.go ├── flag_test.go ├── go.mod ├── go.sum ├── help_test.go ├── helpers_test.go └── show_test.go ├── catalog-info.yaml ├── cmd └── tk │ ├── .gitignore │ ├── args.go │ ├── env.go │ ├── export.go │ ├── export_test.go │ ├── flags.go │ ├── flags_test.go │ ├── fmt.go │ ├── init.go │ ├── jsonnet.go │ ├── lint.go │ ├── main.go │ ├── prefix.go │ ├── status.go │ ├── tool.go │ ├── toolCharts.go │ ├── util.go │ ├── workflow.go │ └── workflow_test.go ├── dagger.json ├── dagger ├── .gitattributes ├── .gitignore ├── README.md ├── dagger.gen.go ├── go.mod ├── go.sum ├── internal │ ├── dagger │ │ └── dagger.gen.go │ ├── querybuilder │ │ ├── marshal.go │ │ └── querybuilder.go │ └── telemetry │ │ ├── attrs.go │ │ ├── env.go │ │ ├── exporters.go │ │ ├── init.go │ │ ├── live.go │ │ ├── logging.go │ │ ├── metrics.go │ │ ├── proxy.go │ │ ├── span.go │ │ └── transform.go └── main.go ├── docs ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── astro.config.ts ├── design │ └── tanka.md ├── img │ ├── banner.png │ ├── community-call.png │ ├── example.png │ ├── grafana_gopher.png │ ├── kubernetes_gopher.png │ ├── logo.svg │ ├── logo_black.svg │ ├── tk_black.png │ ├── tk_gray.png │ └── tk_white.png ├── package.json ├── pnpm-lock.yaml ├── public │ └── favicon.svg ├── src │ ├── components │ │ ├── Hero.astro │ │ ├── MobileTableOfContents.astro │ │ ├── TableOfContents.astro │ │ ├── TableOfContentsList.astro │ │ ├── starlight-toc.ts │ │ └── tagline.md │ ├── content │ │ ├── config.ts │ │ └── docs │ │ │ ├── completion.md │ │ │ ├── config.md │ │ │ ├── diff-strategy.md │ │ │ ├── directory-structure.mdx │ │ │ ├── docs │ │ │ └── img │ │ │ │ └── banner.png │ │ │ ├── env-vars.md │ │ │ ├── exporting.md │ │ │ ├── faq.md │ │ │ ├── formatting.md │ │ │ ├── garbage-collection.md │ │ │ ├── helm.mdx │ │ │ ├── index.mdx │ │ │ ├── inline-environments.mdx │ │ │ ├── install.mdx │ │ │ ├── internal │ │ │ └── releasing.md │ │ │ ├── jsonnet │ │ │ ├── injecting-values.md │ │ │ ├── main.md │ │ │ ├── native.md │ │ │ └── overview.md │ │ │ ├── known-issues.md │ │ │ ├── kustomize.md │ │ │ ├── libraries │ │ │ ├── import-paths.md │ │ │ ├── install-publish.md │ │ │ └── overriding.md │ │ │ ├── namespaces.md │ │ │ ├── output-filtering.md │ │ │ ├── server-side-apply.md │ │ │ └── tutorial │ │ │ ├── abstraction.md │ │ │ ├── environments.md │ │ │ ├── jsonnet.mdx │ │ │ ├── k-lib.mdx │ │ │ ├── overview.md │ │ │ ├── parameters.md │ │ │ └── refresher.md │ ├── env.d.ts │ └── tailwind.css └── tsconfig.json ├── examples └── prom-grafana │ ├── .gitignore │ ├── environments │ └── prom-grafana │ │ ├── dev │ │ ├── main.jsonnet │ │ └── spec.json │ │ ├── patched │ │ ├── main.jsonnet │ │ └── spec.json │ │ └── prod │ │ ├── main.jsonnet │ │ └── spec.json │ ├── jsonnetfile.json │ ├── jsonnetfile.lock.json │ └── lib │ ├── k.libsonnet │ └── prom-grafana │ ├── config.libsonnet │ └── prom-grafana.libsonnet ├── go.mod ├── go.sum ├── go.work ├── go.work.sum └── pkg ├── helm ├── charts.go ├── charts_test.go ├── helm.go ├── jsonnet.go ├── jsonnet_test.go ├── spec.go └── template.go ├── jsonnet ├── eval.go ├── eval_test.go ├── evalcache.go ├── files.go ├── find_importers.go ├── find_importers_test.go ├── implementations │ ├── binary │ │ └── impl.go │ ├── goimpl │ │ ├── impl.go │ │ ├── importer.go │ │ ├── tk.libsonnet.go │ │ └── vm.go │ └── types │ │ └── types.go ├── imports.go ├── imports_test.go ├── jpath │ ├── dirs.go │ ├── dirs_test.go │ ├── errors.go │ ├── jpath.go │ ├── jpath_test.go │ └── testdata │ │ ├── noBase │ │ ├── environments │ │ │ ├── empty │ │ │ │ └── .gitkeep │ │ │ ├── filename │ │ │ │ ├── custom.jsonnet │ │ │ │ └── spec.json │ │ │ └── noMain │ │ │ │ └── spec.json │ │ └── jsonnetfile.json │ │ ├── noRoot │ │ └── environments │ │ │ └── default │ │ │ ├── main.jsonnet │ │ │ └── spec.json │ │ ├── precedence │ │ ├── README.md │ │ ├── environments │ │ │ └── default │ │ │ │ ├── baseDir.jsonnet │ │ │ │ ├── main.jsonnet │ │ │ │ ├── spec.json │ │ │ │ └── vendor │ │ │ │ ├── baseDir-vendor.jsonnet │ │ │ │ ├── baseDir.jsonnet │ │ │ │ └── lib.jsonnet │ │ ├── jsonnetfile.json │ │ ├── lib │ │ │ ├── baseDir.jsonnet │ │ │ └── lib.jsonnet │ │ └── vendor │ │ │ ├── baseDir-vendor.jsonnet │ │ │ ├── baseDir.jsonnet │ │ │ ├── lib.jsonnet │ │ │ └── vendor.jsonnet │ │ └── valid │ │ ├── environments │ │ └── default │ │ │ ├── main.jsonnet │ │ │ ├── nestedDir │ │ │ └── file.jsonnet │ │ │ └── spec.json │ │ └── jsonnetfile.json ├── lint.go ├── lint_test.go ├── native │ ├── funcs.go │ └── funcs_test.go └── testdata │ ├── findImporters │ ├── environments │ │ ├── import-other-main-file │ │ │ ├── env1 │ │ │ │ └── main.jsonnet │ │ │ └── env2 │ │ │ │ ├── file.libsonnet │ │ │ │ └── main.jsonnet │ │ ├── imports-lib-and-vendored-through-chain │ │ │ ├── chain1.libsonnet │ │ │ ├── chain2.libsonnet │ │ │ └── main.jsonnet │ │ ├── imports-locals-and-vendored │ │ │ ├── local-file1.libsonnet │ │ │ ├── local-file2.libsonnet │ │ │ └── main.jsonnet │ │ ├── imports-symlinked-vendor │ │ │ └── main.jsonnet │ │ ├── lib-import-relative-to-env │ │ │ ├── README.md │ │ │ ├── file-to-import.libsonnet │ │ │ └── folder1 │ │ │ │ └── folder2 │ │ │ │ └── main.jsonnet │ │ ├── no-imports │ │ │ └── main.jsonnet │ │ ├── relative-import │ │ │ └── main.jsonnet │ │ ├── relative-imported │ │ │ └── main.jsonnet │ │ ├── relative-imported2 │ │ │ └── main.jsonnet │ │ ├── using-deleted-stuff │ │ │ └── main.jsonnet │ │ └── vendor-override-in-env │ │ │ ├── main.jsonnet │ │ │ └── vendor │ │ │ └── vendor-override-in-env │ │ │ └── main.libsonnet │ ├── lib │ │ ├── imports-relative-to-env │ │ │ └── main.libsonnet │ │ ├── lib1 │ │ │ ├── main.libsonnet │ │ │ └── subfolder │ │ │ │ └── test.libsonnet │ │ ├── lib2 │ │ │ └── main.libsonnet │ │ └── unimported-lib │ │ │ └── main.libsonnet │ ├── other-files │ │ ├── test.txt │ │ └── test2.txt │ ├── tkrc.yaml │ └── vendor │ │ ├── unimported-vendor │ │ └── main.libsonnet │ │ ├── vendor-override-in-env │ │ └── main.libsonnet │ │ ├── vendor-symlinked │ │ └── vendored │ │ ├── main.libsonnet │ │ └── text-file.txt │ ├── importTree │ ├── README.md │ ├── jsonnetfile.json │ ├── main.jsonnet │ ├── test.svg │ ├── trees.jsonnet │ └── trees │ │ ├── apple.jsonnet │ │ ├── cherry.jsonnet │ │ ├── generic.libsonnet │ │ └── peach.jsonnet │ ├── lintingError │ ├── jsonnetfile.json │ └── main.jsonnet │ └── thisFile │ ├── jsonnetfile.json │ └── main.jsonnet ├── kubernetes ├── apply.go ├── client │ ├── apply.go │ ├── apply_test.go │ ├── client.go │ ├── context.go │ ├── delete.go │ ├── delete_test.go │ ├── diff.go │ ├── diff_test.go │ ├── errors.go │ ├── exec.go │ ├── get.go │ ├── info.go │ ├── kubectl.go │ ├── resources.go │ └── resources_test.go ├── delete.go ├── diff.go ├── diff_test.go ├── kubernetes.go ├── manifest │ ├── errors.go │ ├── manifest.go │ └── manifest_test.go ├── subsetdiff.go ├── subsetdiff_test.go └── util │ ├── diff.go │ ├── diff_test.go │ └── testdata │ ├── added-and-removed.diff │ ├── added-and-removed.stat │ ├── changed-attributes.diff │ ├── changed-attributes.stat │ ├── changed-lots-of-attributes.diff │ ├── changed-lots-of-attributes.stat │ ├── empty.diff │ └── empty.stat ├── kustomize ├── build.go ├── jsonnet.go └── kustomize.go ├── process ├── data_test.go ├── extract.go ├── extract_test.go ├── filter.go ├── namespace.go ├── namespace_test.go ├── process.go ├── process_test.go ├── resourceDefaults_test.go ├── sort.go ├── sort_test.go └── testdata │ ├── k8s.libsonnet │ ├── resources.jsonnet │ ├── tdArray.jsonnet │ ├── tdBadKindType.jsonnet │ ├── tdDeep.jsonnet │ ├── tdFlat.jsonnet │ ├── tdInvalidPrimitive.jsonnet │ ├── tdList.jsonnet │ ├── tdMissingAttribute.jsonnet │ ├── tdRegular.jsonnet │ └── utils.libsonnet ├── spec ├── depreciations_test.go ├── errors.go ├── spec.go └── v1alpha1 │ ├── environment.go │ ├── environment_test.go │ └── reflect_utils.go ├── tanka ├── errors.go ├── evaluators.go ├── evaluators_test.go ├── export.go ├── export_test.go ├── find.go ├── find_test.go ├── format.go ├── format_test.go ├── inline.go ├── load.go ├── load_test.go ├── parallel.go ├── prune.go ├── static.go ├── status.go ├── tanka.go ├── testdata │ ├── cases │ │ ├── array │ │ │ └── main.jsonnet │ │ ├── format │ │ │ ├── a.jsonnet │ │ │ ├── b.libsonnet │ │ │ ├── foo │ │ │ │ ├── a.jsonnet │ │ │ │ └── b.libsonnet │ │ │ └── vendor │ │ │ │ ├── a.jsonnet │ │ │ │ └── b.libsonnet │ │ ├── function-with-zero-params │ │ │ └── main.jsonnet │ │ ├── inline-name-conflict │ │ │ └── main.jsonnet │ │ ├── multiple-inline-envs │ │ │ └── main.jsonnet │ │ ├── object │ │ │ └── main.jsonnet │ │ ├── static-and-inline │ │ │ ├── main.jsonnet │ │ │ └── spec.json │ │ ├── with-optional-tlas │ │ │ └── main.jsonnet │ │ ├── withenv │ │ │ └── main.jsonnet │ │ ├── withspecjson │ │ │ ├── main.jsonnet │ │ │ └── spec.json │ │ └── withtlas │ │ │ └── main.jsonnet │ ├── jsonnetfile.json │ ├── test-export-envs-broken │ │ └── static-env │ │ │ ├── main.jsonnet │ │ │ └── spec.json │ └── test-export-envs │ │ ├── inline-envs │ │ └── main.jsonnet │ │ └── static-env │ │ ├── main.jsonnet │ │ └── spec.json └── workflow.go └── term ├── alert.go ├── alert_test.go ├── colordiff.go └── colordiff_test.go /.github/actions/setup-goversion/action.yml: -------------------------------------------------------------------------------- 1 | name: setup-goversion 2 | runs: 3 | using: composite 4 | steps: 5 | - id: goversion 6 | run: | 7 | cat Dockerfile | awk '/^FROM golang:.* AS build$/ {v=$2;split(v,a,":")}; END {printf("version=%s", a[2])}' >> $GITHUB_OUTPUT 8 | shell: bash 9 | - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 10 | with: 11 | go-version: "${{steps.goversion.outputs.version}}" 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - directory: / 4 | open-pull-requests-limit: 5 5 | package-ecosystem: gomod 6 | schedule: 7 | interval: weekly 8 | 9 | - directory: /dagger 10 | open-pull-requests-limit: 5 11 | package-ecosystem: gomod 12 | schedule: 13 | interval: weekly 14 | groups: 15 | dagger-dependencies: 16 | patterns: 17 | - "*" 18 | 19 | - directory: /acceptance-tests 20 | open-pull-requests-limit: 5 21 | package-ecosystem: gomod 22 | schedule: 23 | interval: weekly 24 | groups: 25 | acceptance-tests-dependencies: 26 | patterns: 27 | - "*" 28 | 29 | - directory: /docs 30 | open-pull-requests-limit: 5 31 | package-ecosystem: npm 32 | schedule: 33 | interval: weekly 34 | groups: 35 | docs-dependencies: 36 | patterns: 37 | - "*" 38 | 39 | - directory: / 40 | open-pull-requests-limit: 5 41 | package-ecosystem: docker 42 | schedule: 43 | interval: weekly 44 | 45 | - directory: / 46 | open-pull-requests-limit: 5 47 | package-ecosystem: github-actions 48 | schedule: 49 | interval: weekly 50 | -------------------------------------------------------------------------------- /.github/issue_label_bot.yaml: -------------------------------------------------------------------------------- 1 | label-alias: 2 | bug: 'kind/bug' 3 | feature_request: 'kind/feature' 4 | question: 'kind/question' 5 | -------------------------------------------------------------------------------- /.github/workflows/acceptance-tests.yml: -------------------------------------------------------------------------------- 1 | name: Acceptance tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: 8 | - edited 9 | - opened 10 | - ready_for_review 11 | - synchronize 12 | branches: 13 | - main 14 | merge_group: 15 | 16 | jobs: 17 | build: 18 | name: build 19 | runs-on: ubuntu-24.04 20 | permissions: 21 | contents: read 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | persist-credentials: false 27 | 28 | - name: Call Dagger Function 29 | id: dagger 30 | uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e # v8.0.0 31 | with: 32 | version: "0.18.5" 33 | verb: call 34 | dagger-flags: "--silent" 35 | args: "acceptance-tests --root-dir . --acceptance-tests-dir ./acceptance-tests" 36 | -------------------------------------------------------------------------------- /.github/workflows/check-dagger-drift.yml: -------------------------------------------------------------------------------- 1 | name: "Check for drift in Dagger files" 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - edited 7 | - opened 8 | - ready_for_review 9 | - synchronize 10 | branches: 11 | - main 12 | merge_group: 13 | 14 | jobs: 15 | check-dagger-drift: 16 | runs-on: ubuntu-24.04 17 | permissions: 18 | contents: read 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | with: 23 | persist-credentials: false 24 | 25 | - name: Determine Dagger version 26 | id: dagger_version 27 | run: | 28 | sudo wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq 29 | sudo chmod +x /usr/bin/yq 30 | cat .github/workflows/acceptance-tests.yml| yq -r '.jobs.build.steps[] | select(.id == "dagger") | .with.version | select(test("^([0-9]+\.[0-9]+\.[0-9]+)$"))' > .version 31 | echo "version=$(<.version)" > $GITHUB_OUTPUT 32 | rm -rf .version 33 | 34 | - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 35 | id: cache_daggercli 36 | with: 37 | path: bin 38 | key: daggercli-download-${{ steps.dagger_version.outputs.version }} 39 | 40 | - name: Install Dagger CLI 41 | if: steps.cache_daggercli.outputs.cache-hit != 'true' 42 | shell: bash 43 | run: "curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=${{ steps.dagger_version.outputs.version }} sh" # zizmor: ignore[template-injection] Covered by regex check in dagger_version step 44 | 45 | - name: Check drift 46 | run: | 47 | set -e 48 | export PATH=$PATH:$PWD/bin 49 | make dagger-develop 50 | if [[ -z "$(git status --porcelain ./dagger)" ]]; then 51 | echo "No drift detected" 52 | else 53 | echo "Drift detected. Run 'make dagger-develop' and commit the changed files." 54 | git diff 55 | exit 1 56 | fi 57 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr-title.yml: -------------------------------------------------------------------------------- 1 | name: Lint PR title 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | - ready_for_review 11 | branches: 12 | - main 13 | merge_group: 14 | 15 | permissions: 16 | contents: read 17 | pull-requests: read 18 | 19 | jobs: 20 | lint-pr-title: 21 | runs-on: ubuntu-24.04 22 | steps: 23 | - uses: grafana/shared-workflows/actions/lint-pr-title@90e72fd7b35f5d30696313aeb736a13a15eb82ad # lint-pr-title-v1.0.0 24 | env: 25 | GITHUB_TOKEN: ${{ github.token }} 26 | -------------------------------------------------------------------------------- /.github/workflows/publish-page.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/preview.yml 2 | name: Deploy Github Pages 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - "docs/**" 10 | - ".github/workflows/publish-page.yml" 11 | pull_request: 12 | types: 13 | - opened 14 | - reopened 15 | - synchronize 16 | - closed 17 | paths: 18 | - "docs/**" 19 | - ".github/workflows/publish-page.yml" 20 | 21 | concurrency: ci-${{ github.ref }} 22 | 23 | jobs: 24 | publish: 25 | permissions: 26 | contents: write 27 | pull-requests: write 28 | 29 | # Do not run this on forks: 30 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'grafana/tanka' 31 | 32 | runs-on: ubuntu-24.04 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 36 | with: 37 | persist-credentials: false 38 | 39 | - name: Install pnpm 40 | uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 41 | with: 42 | version: 9 43 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 44 | with: 45 | node-version: 20 46 | cache: 'pnpm' 47 | cache-dependency-path: 'docs' 48 | 49 | - name: Install and Build 50 | working-directory: docs 51 | env: 52 | # Main: https://tanka.dev/ 53 | # PRs: https://tanka.dev/pr-preview/pr-{number}/ 54 | PATH_PREFIX: "${{ github.event_name == 'pull_request' && format('/pr-preview/pr-{0}/', github.event.number) || '' }}" 55 | run: | 56 | pnpm install 57 | pnpm build 58 | if [ -d "./public" ]; then 59 | touch ./public/.nojekyll 60 | fi 61 | if [ -d "./dist" ]; then 62 | touch ./dist/.nojekyll 63 | fi 64 | 65 | - name: Deploy main 66 | if: github.event_name != 'pull_request' 67 | uses: JamesIves/github-pages-deploy-action@6c2d9db40f9296374acc17b90404b6e8864128c8 # v4.7.3 68 | with: 69 | clean-exclude: pr-preview/ 70 | folder: ./docs/dist/ 71 | 72 | - name: Deploy preview 73 | if: github.event_name == 'pull_request' 74 | uses: rossjrw/pr-preview-action@2fb559e4766555e23d07b73d313fe97c4f8c3cfe # v1.6.1 75 | with: 76 | deploy-repository: ${{ github.event.pull_request.head.repo.full_name }} 77 | source-dir: ./docs/dist/ 78 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | name: release-please 7 | 8 | jobs: 9 | release-please: 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | runs-on: ubuntu-24.04 14 | outputs: 15 | release_created: "${{ steps.release-please.outputs.release_created }}" 16 | release_tag: "${{ steps.release-please.outputs.tag_name }}" 17 | 18 | steps: 19 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | # https://github.com/actions/checkout/issues/1467 22 | fetch-depth: 0 23 | persist-credentials: false 24 | 25 | - uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0 26 | id: release-please 27 | with: 28 | config-file: .release-please.json 29 | manifest-file: .release-please-manifest.json 30 | 31 | release-docker-image: 32 | needs: 33 | - release-please 34 | if: needs.release-please.outputs.release_created 35 | permissions: 36 | contents: write 37 | pull-requests: write 38 | id-token: write 39 | uses: ./.github/workflows/docker.yml 40 | with: 41 | tag: ${{ needs.release-please.outputs.release_tag }} 42 | 43 | # If a release was created, also create the binaries and attach them 44 | release-binaries: 45 | runs-on: ubuntu-24.04 46 | needs: 47 | - release-please 48 | if: needs.release-please.outputs.release_created 49 | permissions: 50 | contents: write 51 | steps: 52 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 53 | with: 54 | # https://github.com/actions/checkout/issues/1467 55 | fetch-depth: 0 56 | ref: "${{ needs.release-please.outputs.release_tag }}" 57 | persist-credentials: false 58 | 59 | - uses: ./.github/actions/setup-goversion 60 | 61 | - name: Build binaries 62 | run: make cross 63 | 64 | - name: Attach binaries 65 | uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0 66 | with: 67 | token: ${{ github.token }} 68 | allowUpdates: true 69 | tag: ${{ needs.release-please.outputs.release_tag }} 70 | omitNameDuringUpdate: true 71 | omitPrereleaseDuringUpdate: true 72 | omitBodyDuringUpdate: true 73 | omitDraftDuringUpdate: true 74 | artifacts: "dist/**/*" 75 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - edited 10 | - opened 11 | - ready_for_review 12 | - synchronize 13 | branches: 14 | - "*" 15 | merge_group: 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | lint: 22 | runs-on: ubuntu-24.04 23 | steps: 24 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | persist-credentials: false 27 | - uses: ./.github/actions/setup-goversion 28 | - run: make lint 29 | 30 | test: 31 | runs-on: ubuntu-24.04 32 | steps: 33 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | persist-credentials: false 36 | - uses: ./.github/actions/setup-goversion 37 | - uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 38 | with: 39 | version: "3.13.1" 40 | - name: Install jsonnet 41 | run: go install github.com/google/go-jsonnet/cmd/jsonnet@v0.20.0 42 | - run: make test 43 | 44 | build: 45 | runs-on: ubuntu-24.04 46 | steps: 47 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 48 | with: 49 | persist-credentials: false 50 | - uses: ./.github/actions/setup-goversion 51 | - run: make cross 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | /tk 3 | /cmd/tk/tk 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - dogsled 4 | - errcheck 5 | - copyloopvar 6 | - goconst 7 | - gocritic 8 | - gofmt 9 | - goimports 10 | - gosimple 11 | - govet 12 | - ineffassign 13 | - misspell 14 | - revive 15 | - staticcheck 16 | - stylecheck 17 | - typecheck 18 | - unconvert 19 | - unused 20 | - whitespace 21 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: tanka-format 2 | name: tkfmt 3 | description: Automatically format jsonnet files. 4 | entry: tk 5 | args: [fmt] 6 | language: golang 7 | files: \.(jsonnet|libsonnet)$ 8 | minimum_pre_commit_version: 2.10.1 9 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"0.32.0"} 2 | -------------------------------------------------------------------------------- /.release-please.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "changelog-sections": [ 4 | { 5 | "section": "🎉 Features", 6 | "type": "feat" 7 | }, 8 | { 9 | "section": "🐛 Bug Fixes", 10 | "type": "fix" 11 | }, 12 | { 13 | "section": "⚡ Performance Improvements", 14 | "type": "perf" 15 | }, 16 | { 17 | "section": "🔗 Dependencies", 18 | "type": "deps" 19 | }, 20 | { 21 | "section": "📝 Documentation", 22 | "type": "docs" 23 | }, 24 | { 25 | "section": "🏗️ Build System", 26 | "type": "build" 27 | }, 28 | { 29 | "section": "🤖 Continuous Integration", 30 | "type": "ci" 31 | }, 32 | { 33 | "section": "🔧 Miscellaneous Chores", 34 | "type": "chore" 35 | }, 36 | { 37 | "section": "⏪ Reverts", 38 | "type": "revert" 39 | }, 40 | { 41 | "section": "✅ Tests", 42 | "type": "test" 43 | }, 44 | { 45 | "section": "💄 Style", 46 | "type": "style" 47 | }, 48 | { 49 | "section": "♻️ Code Refactoring", 50 | "type": "refactor" 51 | } 52 | ], 53 | "draft-pull-request": true, 54 | "include-v-in-tag": true, 55 | "bump-minor-pre-major": true, 56 | "packages": { 57 | ".": { 58 | } 59 | }, 60 | "release-type": "go" 61 | } 62 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.formatTool": "gofmt" 3 | } -------------------------------------------------------------------------------- /ADOPTERS.md: -------------------------------------------------------------------------------- 1 | * Everquote 2 | * GitLab 3 | * Grafana Labs 4 | * Yelp 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @grafana/platform-productivity 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # download kubectl 2 | FROM golang:1.24.3-alpine AS kubectl 3 | ARG KUBECTL_VERSION=1.33.1 4 | RUN apk add --no-cache curl 5 | RUN export OS=$(go env GOOS) && \ 6 | export ARCH=$(go env GOARCH) &&\ 7 | curl -o /usr/local/bin/kubectl -L https://cdn.dl.k8s.io/release/v${KUBECTL_VERSION}/bin/${OS}/${ARCH}/kubectl &&\ 8 | chmod +x /usr/local/bin/kubectl 9 | 10 | # build jsonnet-bundler 11 | FROM golang:1.24.3-alpine AS jb 12 | WORKDIR /tmp 13 | RUN apk add --no-cache git make bash &&\ 14 | git clone https://github.com/jsonnet-bundler/jsonnet-bundler &&\ 15 | ls /bin &&\ 16 | cd jsonnet-bundler &&\ 17 | make static &&\ 18 | mv _output/jb /usr/local/bin/jb 19 | 20 | FROM golang:1.24.3-alpine AS helm 21 | WORKDIR /tmp/helm 22 | ARG HELM_VERSION=3.18.1 23 | RUN apk add --no-cache jq curl 24 | RUN export OS=$(go env GOOS) && \ 25 | export ARCH=$(go env GOARCH) &&\ 26 | curl -SL "https://get.helm.sh/helm-v${HELM_VERSION}-${OS}-${ARCH}.tar.gz" > helm.tgz && \ 27 | tar -xvf helm.tgz --strip-components=1 28 | 29 | FROM golang:1.24.3-alpine AS kustomize 30 | WORKDIR /tmp/kustomize 31 | ARG KUSTOMIZE_VERSION=5.6.0 32 | RUN apk add --no-cache jq curl 33 | RUN export OS=$(go env GOOS) &&\ 34 | export ARCH=$(go env GOARCH) &&\ 35 | echo "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize/v${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_${OS}_${ARCH}.tar.gz" && \ 36 | curl -SL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize/v${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_${OS}_${ARCH}.tar.gz" > kustomize.tgz && \ 37 | tar -xvf kustomize.tgz 38 | 39 | FROM golang:1.24.3 AS build 40 | WORKDIR /app 41 | COPY . . 42 | RUN make static 43 | 44 | # assemble final container 45 | FROM alpine:3.21 46 | RUN apk add --no-cache coreutils diffutils less git openssh-client && \ 47 | apk upgrade --quiet 48 | COPY --from=build /app/tk /usr/local/bin/tk 49 | COPY --from=kubectl /usr/local/bin/kubectl /usr/local/bin/kubectl 50 | COPY --from=jb /usr/local/bin/jb /usr/local/bin/jb 51 | COPY --from=helm /tmp/helm/helm /usr/local/bin/helm 52 | COPY --from=kustomize /tmp/kustomize/kustomize /usr/local/bin/kustomize 53 | WORKDIR /app 54 | ENTRYPOINT ["/usr/local/bin/tk"] 55 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | @sh0rez is the main/default maintainer. 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint test static install uninstall cross acceptance-tests dagger-develop 2 | GOPATH := $(shell go env GOPATH) 3 | VERSION := $(shell git describe --tags --dirty --always) 4 | BIN_DIR := $(GOPATH)/bin 5 | GOX := $(BIN_DIR)/gox 6 | GOLINTER := $(GOPATH)/bin/golangci-lint 7 | 8 | 9 | $(GOLINTER): 10 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.5 11 | 12 | lint: $(GOLINTER) 13 | $(GOLINTER) run 14 | 15 | test: 16 | go test ./... -bench=. -benchmem 17 | 18 | acceptance-tests: 19 | dagger call acceptance-tests --root-dir . --acceptance-tests-dir ./acceptance-tests 20 | 21 | # Compilation 22 | dev: 23 | go build -ldflags "-X main.Version=dev-${VERSION}" ./cmd/tk 24 | 25 | LDFLAGS := '-s -w -extldflags "-static" -X github.com/grafana/tanka/pkg/tanka.CurrentVersion=${VERSION}' 26 | static: 27 | CGO_ENABLED=0 go build -ldflags=${LDFLAGS} ./cmd/tk 28 | 29 | install: 30 | CGO_ENABLED=0 go install -ldflags=${LDFLAGS} ./cmd/tk 31 | 32 | uninstall: 33 | go clean -i ./cmd/tk 34 | 35 | $(GOX): 36 | go get -u github.com/mitchellh/gox 37 | go install github.com/mitchellh/gox 38 | 39 | cross: $(GOX) 40 | CGO_ENABLED=0 $(BIN_DIR)/gox -output="dist/{{.Dir}}-{{.OS}}-{{.Arch}}" -ldflags=${LDFLAGS} -arch="amd64 arm64 arm" -os="linux" -osarch="darwin/amd64" -osarch="darwin/arm64" -osarch="windows/amd64" ./cmd/tk 41 | 42 | # Docker container 43 | container: static 44 | docker build -t grafana/tanka . 45 | 46 | dagger-develop: 47 | @cp dagger/.gitignore dagger/.gitignore.bak 48 | @dagger develop --silent 49 | @mv dagger/.gitignore.bak dagger/.gitignore 50 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security issues 2 | 3 | If you think you have found a security vulnerability, please send a report to [security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). We can accept only vulnerability reports at this address. 4 | 5 | Please encrypt your message to us; please use our PGP key. The key fingerprint is: 6 | 7 | F988 7BEA 027A 049F AE8E 5CAA D125 8932 BE24 C5CA 8 | 9 | The key is available from [keyserver.ubuntu.com](https://keyserver.ubuntu.com/pks/lookup?search=0xF9887BEA027A049FAE8E5CAAD1258932BE24C5CA&fingerprint=on&op=index). 10 | 11 | Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. 12 | 13 | **Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. 14 | 15 | ## Security announcements 16 | 17 | We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/support/security-announcements), 18 | where we will post a summary, remediation, and mitigation details for any patch containing security fixes. 19 | 20 | You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/support/security-announcements.rss). 21 | -------------------------------------------------------------------------------- /acceptance-tests/README.md: -------------------------------------------------------------------------------- 1 | # Acceptance tests 2 | 3 | These tests aim to cover some e2e use-cases like creating a new Tanka 4 | environment and pushing it up to an ephemeral Kubernetes cluster. 5 | 6 | To run these, you need to have the Dagger CLI >= 0.11 installed. Then you can 7 | execute the tests like this from the *root directory* of the project: 8 | 9 | ``` 10 | make acceptance-tests 11 | ``` 12 | -------------------------------------------------------------------------------- /acceptance-tests/apply_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | corev1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | ) 13 | 14 | func TestApplyEnvironment(t *testing.T) { 15 | tmpDir := t.TempDir() 16 | runCmd(t, tmpDir, "tk", "init") 17 | runCmd(t, tmpDir, "tk", "env", "set", "environments/default", "--server=https://kubernetes:6443") 18 | cm := corev1.ConfigMap{ 19 | TypeMeta: metav1.TypeMeta{ 20 | Kind: "ConfigMap", 21 | APIVersion: "v1", 22 | }, 23 | ObjectMeta: metav1.ObjectMeta{ 24 | Name: "demo", 25 | }, 26 | } 27 | content := fmt.Sprintf(`{config: %s}`, marshalToJSON(t, cm)) 28 | require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "environments/default/main.jsonnet"), []byte(content), 0600)) 29 | runCmd(t, tmpDir, "tk", "apply", "environments/default", "--auto-approve", "always") 30 | // Now that the configmap should be there, let's verify it 31 | runCmd(t, tmpDir, "kubectl", "--namespace", "default", "get", "configmap", "demo") 32 | } 33 | -------------------------------------------------------------------------------- /acceptance-tests/export_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestExportEnvironment(t *testing.T) { 13 | t.Run("basic", func(t *testing.T) { 14 | tmpDir := t.TempDir() 15 | runCmd(t, tmpDir, "tk", "init") 16 | runCmd(t, tmpDir, "tk", "env", "set", "environments/default", "--server=https://kubernetes:6443") 17 | content := ` 18 | { 19 | config: { 20 | apiVersion: "v1", 21 | kind: "ConfigMap", 22 | metadata : { 23 | name: "demo", 24 | }, 25 | data: {}, 26 | }, 27 | } 28 | ` 29 | require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "environments/default/main.jsonnet"), []byte(content), 0600)) 30 | runCmd(t, tmpDir, "tk", "export", "export", "environments/default") 31 | require.FileExists(t, filepath.Join(tmpDir, "export/v1.ConfigMap-demo.yaml")) 32 | }) 33 | 34 | t.Run("only-labeled", func(t *testing.T) { 35 | tmpDir := t.TempDir() 36 | runCmd(t, tmpDir, "tk", "init", "--inline") 37 | 38 | // We have two environments stored here but we only want the one with 39 | // the label "wanted" set to true: 40 | content := ` 41 | { 42 | environment(name):: { 43 | apiVersion: 'tanka.dev/v1alpha1', 44 | kind: 'Environment', 45 | metadata: { 46 | name: 'environment/%s' % (name), 47 | labels: { 48 | 'wanted': (if name == 'wanted' then 'true' else 'false'), 49 | }, 50 | }, 51 | spec: { 52 | namespace: 'test-%s' % (name), 53 | inline: "true", 54 | }, 55 | data: { 56 | config: { 57 | apiVersion: "v1", 58 | kind: "ConfigMap", 59 | metadata : { 60 | name: "demo", 61 | }, 62 | data: {}, 63 | }, 64 | }, 65 | }, 66 | envs: [$.environment('wanted'), $.environment('unwanted')], 67 | } 68 | ` 69 | require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "environments/default/main.jsonnet"), []byte(content), 0600)) 70 | runCmd(t, tmpDir, "tk", "export", "--recursive", "-l", "wanted=true", "--format", "{{ .metadata.namespace }}/{{.apiVersion}}.{{.kind}}-{{or .metadata.name .metadata.generateName}}", "export", "environments/default") 71 | assert.FileExists(t, filepath.Join(tmpDir, "export/test-wanted/v1.ConfigMap-demo.yaml")) 72 | assert.NoFileExists(t, filepath.Join(tmpDir, "export/test-unwanted/v1.ConfigMap-demo-unwanted.yaml")) 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /acceptance-tests/flag_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestJsonnetImplementationFlag(t *testing.T) { 11 | // The jsonnet implementation flag should be present for the following sub-commands 12 | supportedSubCommands := []string{ 13 | "eval", 14 | "export", 15 | "status", 16 | "apply", 17 | "diff", 18 | "delete", 19 | "show", 20 | // https://github.com/grafana/tanka/pull/1208 21 | "env list", 22 | } 23 | tmpDir := t.TempDir() 24 | for _, subcommand := range supportedSubCommands { 25 | t.Run(subcommand, func(t *testing.T) { 26 | args := []string{} 27 | command := strings.Split(subcommand, " ") 28 | args = append(args, command...) 29 | args = append(args, "--help") 30 | helpOutput := getCmdOutput(t, tmpDir, "tk", args...) 31 | require.Contains(t, helpOutput, "jsonnet-implementation") 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /acceptance-tests/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/tanka/acceptance-tests 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/stretchr/testify v1.10.0 9 | k8s.io/api v0.33.1 10 | k8s.io/apimachinery v0.33.1 11 | sigs.k8s.io/yaml v1.4.0 12 | ) 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 16 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 17 | github.com/go-logr/logr v1.4.2 // indirect 18 | github.com/gogo/protobuf v1.3.2 // indirect 19 | github.com/json-iterator/go v1.1.12 // indirect 20 | github.com/kr/text v0.2.0 // indirect 21 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 22 | github.com/modern-go/reflect2 v1.0.2 // indirect 23 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 24 | github.com/x448/float16 v0.8.4 // indirect 25 | golang.org/x/net v0.38.0 // indirect 26 | golang.org/x/text v0.23.0 // indirect 27 | gopkg.in/inf.v0 v0.9.1 // indirect 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | k8s.io/klog/v2 v2.130.1 // indirect 30 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 31 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 32 | sigs.k8s.io/randfill v1.0.0 // indirect 33 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /acceptance-tests/help_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestHelp(t *testing.T) { 10 | output := getCmdOutput(t, "/", "tk", "--help") 11 | require.Contains(t, output, "Usage") 12 | } 13 | -------------------------------------------------------------------------------- /acceptance-tests/helpers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "os/exec" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func runCmd(t *testing.T, dir string, cmd string, args ...string) { 13 | t.Helper() 14 | c := exec.Command(cmd, args...) 15 | c.Dir = dir 16 | c.Stdout = os.Stdout 17 | c.Stderr = os.Stderr 18 | err := c.Run() 19 | require.NoError(t, err) 20 | } 21 | 22 | func getCmdOutput(t *testing.T, dir string, cmd string, args ...string) string { 23 | t.Helper() 24 | c := exec.Command(cmd, args...) 25 | c.Dir = dir 26 | output, err := c.CombinedOutput() 27 | require.NoError(t, err) 28 | return string(output) 29 | } 30 | 31 | func marshalToJSON(t *testing.T, obj any) string { 32 | t.Helper() 33 | output, err := json.Marshal(obj) 34 | require.NoError(t, err) 35 | return string(output) 36 | } 37 | -------------------------------------------------------------------------------- /acceptance-tests/show_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | corev1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "sigs.k8s.io/yaml" 13 | ) 14 | 15 | func TestShow(t *testing.T) { 16 | tmpDir := t.TempDir() 17 | runCmd(t, tmpDir, "tk", "init") 18 | runCmd(t, tmpDir, "tk", "env", "set", "environments/default", "--server=https://kubernetes:6443") 19 | cm := corev1.ConfigMap{ 20 | TypeMeta: metav1.TypeMeta{ 21 | Kind: "ConfigMap", 22 | APIVersion: "v1", 23 | }, 24 | ObjectMeta: metav1.ObjectMeta{ 25 | Name: "demo", 26 | }, 27 | } 28 | content := fmt.Sprintf(`{config: %s}`, marshalToJSON(t, cm)) 29 | 30 | require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "environments/default/main.jsonnet"), []byte(content), 0600)) 31 | output := getCmdOutput(t, tmpDir, "tk", "show", "--dangerous-allow-redirect", "environments/default") 32 | outputObject := corev1.ConfigMap{} 33 | require.NoError(t, yaml.Unmarshal([]byte(output), &outputObject)) 34 | 35 | // Tanka also injects the namespace: 36 | cm.ObjectMeta.SetNamespace("default") 37 | 38 | require.Equal(t, cm, outputObject) 39 | } 40 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: tanka 5 | title: Tanka 6 | description: "Flexible, reusable and concise configuration for Kubernetes" 7 | tags: 8 | - opensource 9 | annotations: 10 | github.com/project-slug: grafana/tanka 11 | links: 12 | - url: "https://tanka.dev" 13 | title: Website 14 | spec: 15 | type: application 16 | owner: group:platform-productivity 17 | lifecycle: production 18 | -------------------------------------------------------------------------------- /cmd/tk/.gitignore: -------------------------------------------------------------------------------- 1 | test/ 2 | -------------------------------------------------------------------------------- /cmd/tk/args.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/go-clix/cli" 9 | "github.com/posener/complete" 10 | 11 | "github.com/grafana/tanka/pkg/jsonnet/jpath" 12 | "github.com/grafana/tanka/pkg/tanka" 13 | ) 14 | 15 | var workflowArgs = cli.Args{ 16 | Validator: cli.ValidateExact(1), 17 | Predictor: cli.PredictFunc(func(args complete.Args) []string { 18 | pwd, err := os.Getwd() 19 | if err != nil { 20 | return nil 21 | } 22 | 23 | root, err := jpath.FindRoot(pwd) 24 | if err != nil { 25 | return nil 26 | } 27 | 28 | envs, err := tanka.FindEnvs(pwd, tanka.FindOpts{}) 29 | if err != nil && !errors.As(err, &tanka.ErrParallel{}) { 30 | return nil 31 | } 32 | 33 | var reldirs []string 34 | for _, env := range envs { 35 | path := filepath.Join(root, env.Metadata.Namespace) // namespace == path on disk 36 | reldir, err := filepath.Rel(pwd, path) 37 | if err == nil { 38 | reldirs = append(reldirs, reldir) 39 | } 40 | } 41 | 42 | if len(reldirs) != 0 { 43 | return reldirs 44 | } 45 | 46 | return complete.PredictFiles("*").Predict(args) 47 | }), 48 | } 49 | -------------------------------------------------------------------------------- /cmd/tk/export_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/grafana/tanka/pkg/tanka" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDetermineMergeStrategy(t *testing.T) { 12 | cases := []struct { 13 | name string 14 | deprecatedFlag bool 15 | mergeStrategy string 16 | expected tanka.ExportMergeStrategy 17 | expectErr error 18 | }{ 19 | { 20 | name: "default", 21 | deprecatedFlag: false, 22 | mergeStrategy: "", 23 | expected: tanka.ExportMergeStrategyNone, 24 | }, 25 | { 26 | name: "deprecated flag set", 27 | deprecatedFlag: true, 28 | expected: tanka.ExportMergeStrategyFailConflicts, 29 | }, 30 | { 31 | name: "both values set", 32 | deprecatedFlag: true, 33 | mergeStrategy: "fail-conflicts", 34 | expectErr: errors.New("cannot use --merge and --merge-strategy at the same time"), 35 | }, 36 | { 37 | name: "fail-conflicts", 38 | mergeStrategy: "fail-on-conflicts", 39 | expected: tanka.ExportMergeStrategyFailConflicts, 40 | }, 41 | { 42 | name: "replace-envs", 43 | mergeStrategy: "replace-envs", 44 | expected: tanka.ExportMergeStrategyReplaceEnvs, 45 | }, 46 | { 47 | name: "bad value", 48 | mergeStrategy: "blabla", 49 | expectErr: errors.New("invalid merge strategy: \"blabla\""), 50 | }, 51 | } 52 | for _, tc := range cases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | result, err := determineMergeStrategy(tc.deprecatedFlag, tc.mergeStrategy) 55 | if tc.expectErr != nil { 56 | assert.EqualError(t, err, tc.expectErr.Error()) 57 | } else { 58 | assert.NoError(t, err) 59 | } 60 | assert.Equal(t, tc.expected, result) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/tk/flags_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/pflag" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCliCodeParser(t *testing.T) { 11 | fs := pflag.NewFlagSet("test-cli-code-parser", pflag.ContinueOnError) 12 | parseExt, parseTLA := cliCodeParser(fs) 13 | err := fs.Parse([]string{ 14 | "--ext-str", "es=1a \" \U0001f605 ' b\nc\u010f", 15 | "--tla-str", "ts=2a \" \U0001f605 ' b\nc\u010f", 16 | "--ext-code", "ec=1+2", 17 | "--tla-code", "tc=2+3", 18 | "-A", "ts2=ts2", // tla-str 19 | "-V", "es2=es2", // ext-str 20 | "--ext-str-file", `esf=e"sf.txt`, 21 | "--tla-str-file", `tsf=t"s"f.txt`, 22 | "--ext-code-file", `ecf=e"cf.json`, 23 | "--tla-code-file", `tcf=t"c"f.json`, 24 | }) 25 | assert.NoError(t, err) 26 | ext := parseExt() 27 | assert.Equal(t, map[string]string{ 28 | "es": `"1a \" ` + "\U0001f605" + ` ' b\nc` + "\u010f" + `"`, 29 | "ec": "1+2", 30 | "es2": `"es2"`, 31 | "esf": `importstr @"e""sf.txt"`, 32 | "ecf": `import @"e""cf.json"`, 33 | }, ext) 34 | tla := parseTLA() 35 | assert.Equal(t, map[string]string{ 36 | "ts": `"2a \" ` + "\U0001f605" + ` ' b\nc` + "\u010f" + `"`, 37 | "tc": "2+3", 38 | "ts2": `"ts2"`, 39 | "tsf": `importstr @"t""s""f.txt"`, 40 | "tcf": `import @"t""c""f.json"`, 41 | }, tla) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/tk/jsonnet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/go-clix/cli" 7 | 8 | "github.com/grafana/tanka/pkg/tanka" 9 | ) 10 | 11 | func evalCmd() *cli.Command { 12 | cmd := &cli.Command{ 13 | Short: "evaluate the jsonnet to json", 14 | Use: "eval ", 15 | Args: workflowArgs, 16 | } 17 | 18 | var jsonnetImplementation string 19 | evalPattern := cmd.Flags().StringP("eval", "e", "", "Evaluate expression on output of jsonnet") 20 | jsonnetImplementationFlag(cmd.Flags(), &jsonnetImplementation) 21 | 22 | getJsonnetOpts := jsonnetFlags(cmd.Flags()) 23 | 24 | cmd.Run = func(_ *cli.Command, args []string) error { 25 | jsonnetOpts := tanka.Opts{ 26 | JsonnetImplementation: jsonnetImplementation, 27 | JsonnetOpts: getJsonnetOpts(), 28 | } 29 | if *evalPattern != "" { 30 | jsonnetOpts.EvalScript = tanka.PatternEvalScript(*evalPattern) 31 | } 32 | raw, err := tanka.Eval(args[0], jsonnetOpts) 33 | 34 | if raw == nil && err != nil { 35 | return err 36 | } 37 | 38 | out, err := json.MarshalIndent(raw, "", " ") 39 | if err != nil { 40 | return err 41 | } 42 | 43 | return pageln(string(out)) 44 | } 45 | 46 | return cmd 47 | } 48 | -------------------------------------------------------------------------------- /cmd/tk/lint.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/go-clix/cli" 5 | "github.com/gobwas/glob" 6 | "github.com/posener/complete" 7 | 8 | "github.com/grafana/tanka/pkg/jsonnet" 9 | ) 10 | 11 | func lintCmd() *cli.Command { 12 | cmd := &cli.Command{ 13 | Use: "lint ", 14 | Short: "lint Jsonnet code", 15 | Args: cli.Args{ 16 | Validator: cli.ArgsMin(1), 17 | Predictor: complete.PredictFiles("*.*sonnet"), 18 | }, 19 | } 20 | 21 | exclude := cmd.Flags().StringSliceP("exclude", "e", []string{"**/.*", ".*", "**/vendor/**", "vendor/**"}, "globs to exclude") 22 | parallelism := cmd.Flags().IntP("parallelism", "n", 4, "amount of workers") 23 | 24 | // this is now always sent as debug logs 25 | cmd.Flags().BoolP("verbose", "v", false, "print each checked file") 26 | if err := cmd.Flags().MarkDeprecated("verbose", "logs are sent to debug now, this is unused"); err != nil { 27 | panic(err) 28 | } 29 | 30 | cmd.Run = func(_ *cli.Command, args []string) error { 31 | globs := make([]glob.Glob, len(*exclude)) 32 | for i, e := range *exclude { 33 | g, err := glob.Compile(e) 34 | if err != nil { 35 | return err 36 | } 37 | globs[i] = g 38 | } 39 | 40 | return jsonnet.Lint(args, &jsonnet.LintOpts{Excludes: globs, Parallelism: *parallelism}) 41 | } 42 | 43 | return cmd 44 | } 45 | -------------------------------------------------------------------------------- /cmd/tk/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/go-clix/cli" 9 | "github.com/rs/zerolog" 10 | "golang.org/x/term" 11 | 12 | "github.com/grafana/tanka/pkg/tanka" 13 | ) 14 | 15 | var interactive = term.IsTerminal(int(os.Stdout.Fd())) 16 | 17 | func main() { 18 | rootCmd := &cli.Command{ 19 | Use: "tk", 20 | Short: "tanka <3 jsonnet", 21 | Version: tanka.CurrentVersion, 22 | } 23 | 24 | // set default logging level early; not all commands parse --log-level 25 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 26 | 27 | // workflow commands 28 | addCommandsWithLogLevelOption( 29 | rootCmd, 30 | applyCmd(), 31 | showCmd(), 32 | diffCmd(), 33 | pruneCmd(), 34 | deleteCmd(), 35 | ) 36 | 37 | addCommandsWithLogLevelOption( 38 | rootCmd, 39 | envCmd(), 40 | statusCmd(), 41 | exportCmd(), 42 | ) 43 | 44 | // jsonnet commands 45 | addCommandsWithLogLevelOption( 46 | rootCmd, 47 | fmtCmd(), 48 | lintCmd(), 49 | evalCmd(), 50 | initCmd(), 51 | toolCmd(), 52 | ) 53 | 54 | // external commands prefixed with "tk-" 55 | addCommandsWithLogLevelOption( 56 | rootCmd, 57 | prefixCommands("tk-")..., 58 | ) 59 | 60 | // Run! 61 | if err := rootCmd.Execute(); err != nil { 62 | fmt.Fprintln(os.Stderr, "Error:", err) 63 | os.Exit(1) 64 | } 65 | } 66 | 67 | func addCommandsWithLogLevelOption(rootCmd *cli.Command, cmds ...*cli.Command) { 68 | for _, cmd := range cmds { 69 | levels := []string{zerolog.Disabled.String(), zerolog.FatalLevel.String(), zerolog.ErrorLevel.String(), zerolog.WarnLevel.String(), zerolog.InfoLevel.String(), zerolog.DebugLevel.String(), zerolog.TraceLevel.String()} 70 | cmd.Flags().String("log-level", zerolog.InfoLevel.String(), "possible values: "+strings.Join(levels, ", ")) 71 | 72 | cmdRun := cmd.Run 73 | cmd.Run = func(cmd *cli.Command, args []string) error { 74 | level, err := zerolog.ParseLevel(cmd.Flags().Lookup("log-level").Value.String()) 75 | if err != nil { 76 | return err 77 | } 78 | zerolog.SetGlobalLevel(level) 79 | 80 | return cmdRun(cmd, args) 81 | } 82 | rootCmd.AddCommand(cmd) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /cmd/tk/prefix.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/go-clix/cli" 11 | ) 12 | 13 | func prefixCommands(prefix string) (cmds []*cli.Command) { 14 | externalCommands, err := executablesOnPath(prefix) 15 | if err != nil { 16 | // soft fail if no commands found 17 | return nil 18 | } 19 | 20 | for file, path := range externalCommands { 21 | cmd := &cli.Command{ 22 | Use: fmt.Sprintf("%s --", strings.TrimPrefix(file, prefix)), 23 | Short: fmt.Sprintf("external command %s", path), 24 | Args: cli.ArgsAny(), 25 | } 26 | 27 | extCommand := exec.Command(path) 28 | if ex, err := os.Executable(); err == nil { 29 | extCommand.Env = append(os.Environ(), fmt.Sprintf("EXECUTABLE=%s", ex)) 30 | } 31 | extCommand.Stdout = os.Stdout 32 | extCommand.Stderr = os.Stderr 33 | 34 | cmd.Run = func(_ *cli.Command, args []string) error { 35 | extCommand.Args = append(extCommand.Args, args...) 36 | return extCommand.Run() 37 | } 38 | cmds = append(cmds, cmd) 39 | } 40 | if len(cmds) > 0 { 41 | return cmds 42 | } 43 | return nil 44 | } 45 | 46 | func executablesOnPath(prefix string) (map[string]string, error) { 47 | path, ok := os.LookupEnv("PATH") 48 | if !ok { 49 | // if PATH not set, soft fail 50 | return nil, fmt.Errorf("PATH not set") 51 | } 52 | 53 | executables := make(map[string]string) 54 | paths := strings.Split(path, ":") 55 | for _, p := range paths { 56 | s, err := os.Stat(p) 57 | if err != nil && os.IsNotExist(err) { 58 | continue 59 | } 60 | if err != nil { 61 | return nil, err 62 | } 63 | if !s.IsDir() { 64 | continue 65 | } 66 | 67 | files, err := filepath.Glob(fmt.Sprintf("%s/%s*", p, prefix)) 68 | if err != nil { 69 | return nil, err 70 | } 71 | for _, file := range files { 72 | base := filepath.Base(file) 73 | // guarding against a glob character in the prefix or path 74 | if !strings.HasPrefix(base, prefix) { 75 | continue 76 | } 77 | info, err := os.Stat(file) 78 | if err != nil { 79 | return nil, err 80 | } 81 | if !info.Mode().IsRegular() { 82 | continue 83 | } 84 | if info.Mode().Perm()&0111 == 0 { 85 | continue 86 | } 87 | executables[base] = file 88 | } 89 | } 90 | return executables, nil 91 | } 92 | -------------------------------------------------------------------------------- /cmd/tk/status.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "text/tabwriter" 8 | 9 | "github.com/fatih/structs" 10 | 11 | "github.com/go-clix/cli" 12 | 13 | "github.com/grafana/tanka/pkg/tanka" 14 | ) 15 | 16 | func statusCmd() *cli.Command { 17 | cmd := &cli.Command{ 18 | Use: "status ", 19 | Short: "display an overview of the environment, including contents and metadata.", 20 | Args: workflowArgs, 21 | } 22 | 23 | vars := workflowFlags(cmd.Flags()) 24 | getJsonnetOpts := jsonnetFlags(cmd.Flags()) 25 | 26 | cmd.Run = func(_ *cli.Command, args []string) error { 27 | status, err := tanka.Status(args[0], tanka.Opts{ 28 | JsonnetImplementation: vars.jsonnetImplementation, 29 | JsonnetOpts: getJsonnetOpts(), 30 | Name: vars.name, 31 | }) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | context := status.Client.Kubeconfig.Context 37 | fmt.Println("Context:", context.Name) 38 | fmt.Println("Cluster:", context.Context.Cluster) 39 | fmt.Println("Environment:") 40 | 41 | specMap := structs.Map(status.Env.Spec) 42 | var keys []string 43 | for k := range specMap { 44 | keys = append(keys, k) 45 | } 46 | sort.Strings(keys) 47 | for _, k := range keys { 48 | v := specMap[k] 49 | fmt.Printf(" %s: %v\n", k, v) 50 | } 51 | 52 | fmt.Println("Resources:") 53 | f := " %s\t%s/%s\n" 54 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) 55 | fmt.Fprintln(w, " NAMESPACE\tOBJECTSPEC") 56 | for _, r := range status.Resources { 57 | fmt.Fprintf(w, f, r.Metadata().Namespace(), r.Kind(), r.Metadata().Name()) 58 | } 59 | w.Flush() 60 | 61 | return nil 62 | } 63 | return cmd 64 | } 65 | -------------------------------------------------------------------------------- /cmd/tk/util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | 11 | "github.com/google/go-jsonnet/formatter" 12 | ) 13 | 14 | func pageln(i ...interface{}) error { 15 | return fPageln(strings.NewReader(fmt.Sprint(i...))) 16 | } 17 | 18 | // fPageln invokes the systems pager with the supplied data. 19 | // If the PAGER environment variable is empty, no pager is used. 20 | // If the PAGER environment variable is unset, use GNU less with convenience flags. 21 | func fPageln(r io.Reader) error { 22 | pager, ok := os.LookupEnv("TANKA_PAGER") 23 | if !ok { 24 | pager, ok = os.LookupEnv("PAGER") 25 | } 26 | if !ok { 27 | // --RAW-CONTROL-CHARS Honors colors from diff. Must be in all caps, otherwise display issues occur. 28 | // --quit-if-one-screen Closer to the git experience. 29 | // --no-init Don't clear the screen when exiting. 30 | pager = "less --RAW-CONTROL-CHARS --quit-if-one-screen --no-init" 31 | } 32 | 33 | if interactive && pager != "" { 34 | cmd := exec.Command("sh", "-c", pager) 35 | cmd.Stdin = r 36 | cmd.Stdout = os.Stdout 37 | cmd.Stderr = os.Stderr 38 | 39 | if err := cmd.Run(); err == nil { 40 | return nil 41 | } 42 | } 43 | 44 | _, err := io.Copy(os.Stdout, r) 45 | return err 46 | } 47 | 48 | // writeJSON writes the given object to the path as a JSON file 49 | func writeJSON(i interface{}, path string) error { 50 | out, err := json.MarshalIndent(i, "", " ") 51 | if err != nil { 52 | return fmt.Errorf("marshalling: %s", err) 53 | } 54 | 55 | if err := os.WriteFile(path, append(out, '\n'), 0644); err != nil { 56 | return fmt.Errorf("writing %s: %s", path, err) 57 | } 58 | 59 | return nil 60 | } 61 | 62 | // writeJsonnet writes the given object to the path as a formatted Jsonnet file 63 | func writeJsonnet(i interface{}, path string) error { 64 | out, err := json.MarshalIndent(i, "", " ") 65 | if err != nil { 66 | return fmt.Errorf("marshalling: %s", err) 67 | } 68 | 69 | main, err := formatter.Format(path, string(out), formatter.DefaultOptions()) 70 | if err != nil { 71 | return fmt.Errorf("formatting %s: %s", path, err) 72 | } 73 | 74 | if err := os.WriteFile(path, []byte(main), 0644); err != nil { 75 | return fmt.Errorf("writing %s: %s", path, err) 76 | } 77 | 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /cmd/tk/workflow_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/grafana/tanka/pkg/tanka" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestValidateAutoApprove(t *testing.T) { 12 | for _, tc := range []struct { 13 | name string 14 | autoApproveDeprecated bool 15 | autoApproveString string 16 | expected tanka.AutoApproveSetting 17 | expectErr error 18 | }{ 19 | { 20 | name: "default", 21 | expected: tanka.AutoApproveNever, 22 | }, 23 | { 24 | name: "deprecated bool set", 25 | autoApproveDeprecated: true, 26 | expected: tanka.AutoApproveAlways, 27 | }, 28 | { 29 | name: "both values set", 30 | autoApproveDeprecated: true, 31 | autoApproveString: "never", 32 | expectErr: errors.New("--dangerous-auto-approve and --auto-approve are mutually exclusive"), 33 | }, 34 | { 35 | name: "always", 36 | autoApproveString: "always", 37 | expected: tanka.AutoApproveAlways, 38 | }, 39 | { 40 | name: "never", 41 | autoApproveString: "never", 42 | expected: tanka.AutoApproveNever, 43 | }, 44 | { 45 | name: "if-no-changes", 46 | autoApproveString: "if-no-changes", 47 | expected: tanka.AutoApproveNoChanges, 48 | }, 49 | { 50 | name: "bad value", 51 | autoApproveString: "blabla", 52 | expectErr: errors.New("invalid value for --auto-approve: blabla"), 53 | }, 54 | } { 55 | t.Run(tc.name, func(t *testing.T) { 56 | result, err := validateAutoApprove(tc.autoApproveDeprecated, tc.autoApproveString) 57 | if tc.expectErr != nil { 58 | assert.EqualError(t, err, tc.expectErr.Error()) 59 | } else { 60 | assert.NoError(t, err) 61 | assert.Equal(t, tc.expected, result) 62 | } 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /dagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tanka", 3 | "engineVersion": "v0.18.5", 4 | "sdk": { 5 | "source": "go" 6 | }, 7 | "dependencies": [ 8 | { 9 | "name": "k3s", 10 | "source": "github.com/marcosnils/daggerverse/k3s@28eea1fcf3b6ecb38a628186107760acd717442f", 11 | "pin": "28eea1fcf3b6ecb38a628186107760acd717442f" 12 | } 13 | ], 14 | "source": "dagger" 15 | } 16 | -------------------------------------------------------------------------------- /dagger/.gitattributes: -------------------------------------------------------------------------------- 1 | /dagger.gen.go linguist-generated 2 | /internal/dagger/** linguist-generated 3 | /internal/querybuilder/** linguist-generated 4 | /internal/telemetry/** linguist-generated 5 | -------------------------------------------------------------------------------- /dagger/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/dagger/.gitignore -------------------------------------------------------------------------------- /dagger/README.md: -------------------------------------------------------------------------------- 1 | # Dagger setup for Tanka development 2 | 3 | This module includes dagger functions to be used during development of Tanka. 4 | Part of it are also auto-generated files created using `dagger develop`. When 5 | updating Dagger you might need to run this command through `make 6 | dagger-develop` to update these files. 7 | -------------------------------------------------------------------------------- /dagger/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/tanka/dagger 2 | 3 | go 1.23.8 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/99designs/gqlgen v0.17.73 9 | github.com/Khan/genqlient v0.8.1 10 | github.com/vektah/gqlparser/v2 v2.5.27 11 | go.opentelemetry.io/otel v1.35.0 12 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 13 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 14 | go.opentelemetry.io/otel/sdk v1.35.0 15 | go.opentelemetry.io/otel/trace v1.35.0 16 | golang.org/x/sync v0.14.0 17 | google.golang.org/grpc v1.72.1 18 | ) 19 | 20 | require go.opentelemetry.io/auto/sdk v1.1.0 // indirect 21 | 22 | require ( 23 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 24 | github.com/go-logr/logr v1.4.2 // indirect 25 | github.com/go-logr/stdr v1.2.2 // indirect 26 | github.com/google/uuid v1.6.0 // indirect 27 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 28 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 29 | github.com/sosodev/duration v1.3.1 // indirect 30 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 31 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 32 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 33 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 34 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 35 | go.opentelemetry.io/otel/log v0.8.0 36 | go.opentelemetry.io/otel/metric v1.35.0 37 | go.opentelemetry.io/otel/sdk/log v0.8.0 38 | go.opentelemetry.io/otel/sdk/metric v1.35.0 39 | go.opentelemetry.io/proto/otlp v1.6.0 40 | golang.org/x/net v0.39.0 // indirect 41 | golang.org/x/sys v0.32.0 // indirect 42 | golang.org/x/text v0.24.0 // indirect 43 | google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34 // indirect 44 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect 45 | google.golang.org/protobuf v1.36.6 // indirect 46 | ) 47 | 48 | replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 49 | 50 | replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 51 | 52 | replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.8.0 53 | 54 | replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.8.0 55 | -------------------------------------------------------------------------------- /dagger/internal/telemetry/env.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strings" 7 | 8 | "go.opentelemetry.io/otel/propagation" 9 | ) 10 | 11 | func PropagationEnv(ctx context.Context) []string { 12 | carrier := NewEnvCarrier(false) 13 | Propagator.Inject(ctx, carrier) 14 | return carrier.Env 15 | } 16 | 17 | type EnvCarrier struct { 18 | System bool 19 | Env []string 20 | } 21 | 22 | func NewEnvCarrier(system bool) *EnvCarrier { 23 | return &EnvCarrier{ 24 | System: system, 25 | } 26 | } 27 | 28 | var _ propagation.TextMapCarrier = (*EnvCarrier)(nil) 29 | 30 | func (c *EnvCarrier) Get(key string) string { 31 | envName := strings.ToUpper(key) 32 | for _, env := range c.Env { 33 | env, val, ok := strings.Cut(env, "=") 34 | if ok && env == envName { 35 | return val 36 | } 37 | } 38 | if c.System { 39 | if envVal := os.Getenv(envName); envVal != "" { 40 | return envVal 41 | } 42 | } 43 | return "" 44 | } 45 | 46 | func (c *EnvCarrier) Set(key, val string) { 47 | c.Env = append(c.Env, strings.ToUpper(key)+"="+val) 48 | } 49 | 50 | func (c *EnvCarrier) Keys() []string { 51 | keys := make([]string, 0, len(c.Env)) 52 | for _, env := range c.Env { 53 | env, _, ok := strings.Cut(env, "=") 54 | if ok { 55 | keys = append(keys, env) 56 | } 57 | } 58 | return keys 59 | } 60 | -------------------------------------------------------------------------------- /dagger/internal/telemetry/live.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | 6 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 7 | ) 8 | 9 | // LiveSpanProcessor is a SpanProcessor whose OnStart calls OnEnd on the 10 | // underlying SpanProcessor in order to send live telemetry. 11 | type LiveSpanProcessor struct { 12 | sdktrace.SpanProcessor 13 | } 14 | 15 | func NewLiveSpanProcessor(exp sdktrace.SpanExporter) *LiveSpanProcessor { 16 | return &LiveSpanProcessor{ 17 | SpanProcessor: sdktrace.NewBatchSpanProcessor( 18 | // NOTE: span heartbeating is handled by the Cloud exporter 19 | exp, 20 | sdktrace.WithBatchTimeout(NearlyImmediate), 21 | ), 22 | } 23 | } 24 | 25 | func (p *LiveSpanProcessor) OnStart(ctx context.Context, span sdktrace.ReadWriteSpan) { 26 | // Send a read-only snapshot of the live span downstream so it can be 27 | // filtered out by FilterLiveSpansExporter. Otherwise the span can complete 28 | // before being exported, resulting in two completed spans being sent, which 29 | // will confuse traditional OpenTelemetry services. 30 | p.SpanProcessor.OnEnd(SnapshotSpan(span)) 31 | } 32 | -------------------------------------------------------------------------------- /dagger/internal/telemetry/proxy.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | // FIXME: this file exists to plant a "tombstone" over the previously generated 4 | // proxy.go file. 5 | // 6 | // We should maybe just withoutDirectory('./internal') or something instead. 7 | -------------------------------------------------------------------------------- /dagger/internal/telemetry/span.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | 6 | "go.opentelemetry.io/otel/attribute" 7 | "go.opentelemetry.io/otel/codes" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | // Encapsulate can be applied to a span to indicate that this span should 12 | // collapse its children by default. 13 | func Encapsulate() trace.SpanStartOption { 14 | return trace.WithAttributes(attribute.Bool(UIEncapsulateAttr, true)) 15 | } 16 | 17 | // Reveal can be applied to a span to indicate that this span should 18 | // collapse its children by default. 19 | func Reveal() trace.SpanStartOption { 20 | return trace.WithAttributes(attribute.Bool(UIRevealAttr, true)) 21 | } 22 | 23 | // Encapsulated can be applied to a child span to indicate that it should be 24 | // collapsed by default. 25 | func Encapsulated() trace.SpanStartOption { 26 | return trace.WithAttributes(attribute.Bool(UIEncapsulatedAttr, true)) 27 | } 28 | 29 | func Resume(ctx context.Context) trace.SpanStartOption { 30 | return trace.WithLinks(trace.Link{SpanContext: trace.SpanContextFromContext(ctx)}) 31 | } 32 | 33 | // Internal can be applied to a span to indicate that this span should not be 34 | // shown to the user by default. 35 | func Internal() trace.SpanStartOption { 36 | return trace.WithAttributes(attribute.Bool(UIInternalAttr, true)) 37 | } 38 | 39 | // ActorEmoji sets an emoji representing the actor of the span. 40 | func ActorEmoji(emoji string) trace.SpanStartOption { 41 | return trace.WithAttributes(attribute.String(UIActorEmojiAttr, emoji)) 42 | } 43 | 44 | // Passthrough can be applied to a span to cause the UI to skip over it and 45 | // show its children instead. 46 | func Passthrough() trace.SpanStartOption { 47 | return trace.WithAttributes(attribute.Bool(UIPassthroughAttr, true)) 48 | } 49 | 50 | // Tracer returns a Tracer for the given library using the provider from 51 | // the current span. 52 | func Tracer(ctx context.Context, lib string) trace.Tracer { 53 | return trace.SpanFromContext(ctx).TracerProvider().Tracer(lib) 54 | } 55 | 56 | // End is a helper to end a span with an error if the function returns an error. 57 | // 58 | // It is optimized for use as a defer one-liner with a function that has a 59 | // named error return value, conventionally `rerr`. 60 | // 61 | // defer telemetry.End(span, func() error { return rerr }) 62 | func End(span trace.Span, fn func() error) { 63 | if err := fn(); err != nil { 64 | span.RecordError(err) 65 | span.SetStatus(codes.Error, err.Error()) 66 | } 67 | span.End() 68 | } 69 | -------------------------------------------------------------------------------- /dagger/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | "github.com/grafana/tanka/dagger/internal/dagger" 11 | ) 12 | 13 | type Tanka struct{} 14 | 15 | func (m *Tanka) Build( 16 | ctx context.Context, 17 | // +ignore=["docs/**", "dagger/**", "dist/**", ".git/**", "examples/**"] 18 | rootDir *dagger.Directory, 19 | ) *dagger.Container { 20 | buildArgs := make([]dagger.BuildArg, 0, 2) 21 | return dag.Container(). 22 | Build(rootDir, dagger.ContainerBuildOpts{ 23 | BuildArgs: buildArgs, 24 | }) 25 | } 26 | 27 | func (m *Tanka) GetGoVersion(ctx context.Context, file *dagger.File) (string, error) { 28 | versionPattern := regexp.MustCompile(`^go ((\d+)\.(\d+)(\.(\d+))?)$`) 29 | content, err := file.Contents(ctx) 30 | if err != nil { 31 | return "", err 32 | } 33 | for _, line := range strings.Split(content, "\n") { 34 | matches := versionPattern.FindStringSubmatch(line) 35 | if len(matches) < 2 { 36 | continue 37 | } 38 | return matches[1], nil 39 | } 40 | return "", fmt.Errorf("no Go version found") 41 | } 42 | 43 | func (m *Tanka) AcceptanceTests(ctx context.Context, rootDir *dagger.Directory, acceptanceTestsDir *dagger.Directory) (string, error) { 44 | goVersion, err := m.GetGoVersion(ctx, rootDir.File("go.mod")) 45 | if err != nil { 46 | return "", err 47 | } 48 | buildContainer := m.Build(ctx, rootDir) 49 | 50 | k3s := dag.K3S("k3sdemo", dagger.K3SOpts{ 51 | Image: "rancher/k3s:v1.31.8-k3s1", 52 | }) 53 | k3sSrv, err := k3s.Server().Start(ctx) 54 | if err != nil { 55 | return "", err 56 | } 57 | defer k3sSrv.Stop(ctx) 58 | 59 | goCache := dag.CacheVolume("acceptance-tests-gomodules") 60 | 61 | output, err := dag.Container(). 62 | From(fmt.Sprintf("golang:%s-alpine", goVersion)). 63 | WithExec([]string{"apk", "add", "--no-cache", "git"}). 64 | WithMountedFile("/usr/bin/tk", buildContainer.File("/usr/local/bin/tk")). 65 | WithMountedFile("/usr/bin/jb", buildContainer.File("/usr/local/bin/jb")). 66 | WithMountedFile("/usr/bin/helm", buildContainer.File("/usr/local/bin/helm")). 67 | WithMountedFile("/usr/bin/kustomize", buildContainer.File("/usr/local/bin/kustomize")). 68 | WithMountedFile("/usr/bin/kubectl", buildContainer.File("/usr/local/bin/kubectl")). 69 | WithMountedDirectory("/tests", acceptanceTestsDir). 70 | WithEnvVariable("CACHE", time.Now().String()). 71 | WithServiceBinding("kubernetes", k3sSrv). 72 | WithFile("/root/.kube/config", k3s.Config()). 73 | WithWorkdir("/tests"). 74 | WithExec([]string{"sed", "-i", `s/https:.*:6443/https:\/\/kubernetes:6443/g`, "/root/.kube/config"}). 75 | WithMountedCache("/go/pkg", goCache). 76 | WithExec([]string{"go", "test", "./...", "-v"}). 77 | Stdout(ctx) 78 | return output, err 79 | } 80 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /docs/.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "overrides": [ 4 | { 5 | "files": "*.astro", 6 | "options": { 7 | "parser": "astro" 8 | } 9 | } 10 | ], 11 | "plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"], 12 | "singleQuote": true, 13 | "tabWidth": 2 14 | } 15 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Tanka documentation & website 2 | 3 | This folder contains the documentation for Tanka which is also published to 4 | . Under the hood we are using [Starlight][Starlight] and 5 | [Astro][Astro] for this, which allows for a lot of flexiblity regarding markup 6 | and an easy preview of any changes. 7 | 8 | ## 🚀 Getting started 9 | 10 | Before making your first changes, make sure you have a recent version of NodeJS 11 | (\>= 20.x) installed. Once you have that, you can build a local previous of the 12 | docs using the following commands: 13 | 14 | ```bash 15 | # Install all dependencies 16 | pnpm install 17 | 18 | # Start preview server 19 | pnpm run dev 20 | ``` 21 | 22 | This will prompt you an URL where you can see the preview. 23 | 24 | You can find the source code for the documentation pages inside the 25 | `src/content/docs` folder. These are either Markdown files or Markdown + JSX 26 | (MDX) files. 27 | 28 | If you want to make some changes, go, for instance, to 29 | while having your development server 30 | running. Now open an editor and make some changes to 31 | `src/content/tutorial/overview.md`. The preview will reload upon saving that 32 | file. 33 | 34 | If you want to add images, place them in `src/assets` and embed them in your 35 | Markdown files with relative links. 36 | 37 | ## 👀 Want to learn more? 38 | 39 | Check out [Starlight’s docs](https://starlight.astro.build/) and the 40 | [the Astro documentation](https://docs.astro.build) 41 | 42 | [astro]: https://astro.build 43 | [starlight]: https://starlight.astro.build 44 | -------------------------------------------------------------------------------- /docs/img/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/docs/img/banner.png -------------------------------------------------------------------------------- /docs/img/community-call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/docs/img/community-call.png -------------------------------------------------------------------------------- /docs/img/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/docs/img/example.png -------------------------------------------------------------------------------- /docs/img/grafana_gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/docs/img/grafana_gopher.png -------------------------------------------------------------------------------- /docs/img/kubernetes_gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/docs/img/kubernetes_gopher.png -------------------------------------------------------------------------------- /docs/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /docs/img/logo_black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /docs/img/tk_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/docs/img/tk_black.png -------------------------------------------------------------------------------- /docs/img/tk_gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/docs/img/tk_gray.png -------------------------------------------------------------------------------- /docs/img/tk_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/docs/img/tk_white.png -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tanka-docs", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.1", 6 | "scripts": { 7 | "dev": "astro dev", 8 | "build": "astro check && astro build", 9 | "astro": "astro" 10 | }, 11 | "dependencies": { 12 | "@astrojs/check": "^0.9.4", 13 | "@astrojs/starlight": "^0.34.3", 14 | "@astrojs/starlight-tailwind": "^4.0.1", 15 | "@fontsource-variable/inter": "^5.2.5", 16 | "@tailwindcss/vite": "^4.1.8", 17 | "astro": "^5.8.1", 18 | "sharp": "^0.34.2", 19 | "tailwindcss": "^4.1.8", 20 | "typescript": "^5.8.3" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^22.15.29", 24 | "prettier": "^3.5.3" 25 | }, 26 | "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" 27 | } 28 | -------------------------------------------------------------------------------- /docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /docs/src/components/Hero.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { Props } from '@astrojs/starlight/props'; 3 | import { Code, LinkButton } from '@astrojs/starlight/components'; 4 | import { Content as Tagline } from './tagline.md'; 5 | --- 6 | 7 |
10 |
11 |
12 | 29 | 30 |
31 | Kubernetes Deployment. That's all it takes. 32 |
33 |
34 |
35 |
36 |

37 | Define. Reuse. Override. 38 |

39 | 40 |
41 | 42 |
43 | 44 |
45 | Get started 48 | Read the tutorial 51 |
52 |
53 |
54 | 55 | 60 | -------------------------------------------------------------------------------- /docs/src/components/TableOfContents.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import TableOfContentsList from './TableOfContentsList.astro'; 3 | import type { Props } from '@astrojs/starlight/props'; 4 | 5 | const { toc, slug } = Astro.props; 6 | --- 7 | 8 | { 9 | toc && ( 10 | 14 | 20 | 21 | ) 22 | } 23 | 24 | 25 | -------------------------------------------------------------------------------- /docs/src/components/tagline.md: -------------------------------------------------------------------------------- 1 | Grafana Tanka is the robust configuration utility for your [Kubernetes](https://kubernetes.io/) cluster, powered by the unique [Jsonnet](https://jsonnet.org/) language 2 | -------------------------------------------------------------------------------- /docs/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; 3 | 4 | export const collections = { 5 | docs: defineCollection({ schema: docsSchema() }), 6 | i18n: defineCollection({ type: 'data', schema: i18nSchema() }), 7 | }; 8 | -------------------------------------------------------------------------------- /docs/src/content/docs/completion.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Command-line completion 3 | sidebar: 4 | order: 4 5 | --- 6 | 7 | Tanka supports CLI completion for `bash`, `zsh` and `fish`. 8 | 9 | ```bash 10 | # Install 11 | tk complete 12 | 13 | # Uninstall 14 | tk complete --remove 15 | ``` 16 | 17 | As tanka is its own completion handler, it needs to hook into your shell's 18 | configuration file (`.bashrc`, etc). 19 | 20 | When using other shells than `bash`, Tanka relies on a _Bash compatibility 21 | mode_. It enables this automatically when installing, but please make sure no 22 | other completion (e.g. OhMyZsh) interferes with this, or your completion might 23 | not work properly. 24 | It sometimes depends on the order the completions are being loaded, so try 25 | putting Tanka before or after the others. 26 | -------------------------------------------------------------------------------- /docs/src/content/docs/config.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration Reference 3 | sidebar: 4 | order: 1 5 | --- 6 | 7 | Tanka's behavior can be customized per Environment using a file called `spec.json` 8 | 9 | ## File format 10 | 11 | ```json 12 | { 13 | // Config format revision. Currently only "v1alpha1" 14 | "apiVersion": "v1alpha1", 15 | // Always "Environment". Reserved for future use 16 | "kind": "Environment", 17 | 18 | // Descriptive fields 19 | "metadata": { 20 | // Name of the Environment. Automatically set to the relative 21 | // path from the project root 22 | "name": "", 23 | 24 | // Arbitrary key:value string pairs. Not parsed by Tanka 25 | "labels": { "": "" } 26 | }, 27 | 28 | // Properties influencing Tanka's behavior 29 | "spec": { 30 | // The Kubernetes cluster to use. 31 | // Must be the full URL, e.g. https://cluster.fqdn:6443 32 | "apiServer": "", 33 | 34 | // The Kubernetes context name(s) to use. 35 | // This field supports regular expressions and is mutually exclusive with apiServer field. 36 | "contextNames": [""], 37 | 38 | // Default namespace for objects that don't explicitely specify one 39 | "namespace": "" | default = "default", 40 | 41 | // diffStrategy to use. Automatically chosen by default based on 42 | // the availability of "kubectl diff". 43 | // - native: uses "kubectl diff". Recommended 44 | // - validate: uses "kubectl diff --server-side". Safest, but slower than "native" 45 | // - subset: fallback for k8s versions below 1.13.0 46 | "diffStrategy": "[native, validate, subset]" | default = "auto", 47 | 48 | // Whether to add a "tanka.dev/environment" label to each created resource. 49 | // Required for garbage collection ("tk prune"). 50 | "injectLabels": | default = false 51 | } 52 | } 53 | ``` 54 | 55 | ## Jsonnet access 56 | 57 | It is possible to access above data from Jsonnet: 58 | 59 | ```jsonnet 60 | local tk = import "tk"; 61 | 62 | { 63 | // The cluster IP 64 | cluster: tk.env.spec.apiServer, 65 | // The labels of your Environment 66 | labels: tk.env.metadata.labels, 67 | } 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/src/content/docs/docs/img/banner.png: -------------------------------------------------------------------------------- 1 | ../../../img/banner.png -------------------------------------------------------------------------------- /docs/src/content/docs/env-vars.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Environment variables 3 | sidebar: 4 | order: 3 5 | --- 6 | 7 | ## TANKA_JB_PATH 8 | 9 | **Description**: Path to the `jb` tool executable 10 | **Default**: `$PATH/jb` 11 | 12 | ## TANKA_KUBECTL_PATH 13 | 14 | **Description**: Path to the `kubectl` tool executable 15 | **Default**: `$PATH/kubectl` 16 | 17 | ## TANKA_KUBECTL_TRACE 18 | 19 | **Description**: Print all calls to `kubectl` 20 | **Default**: `false` 21 | 22 | ## TANKA_HELM_PATH 23 | 24 | **Description**: Path to the `helm` executable 25 | **Default**: `$PATH/helm` 26 | 27 | ## TANKA_KUSTOMIZE_PATH 28 | 29 | **Description**: Path to the `kustomize` executable 30 | **Default**: `$PATH/kustomize` 31 | 32 | ## TANKA_PAGER 33 | 34 | **Description**: Pager to use when displaying output. Set to an empty string to disable paging. 35 | **Default**: `$PAGER` 36 | 37 | ## PAGER 38 | 39 | **Description**: Pager to use when displaying output. Only used if TANKA_PAGER is not set. Set to an empty string to disable paging. 40 | **Default**: `less --RAW-CONTROL-CHARS --quit-if-one-screen --no-init` 41 | -------------------------------------------------------------------------------- /docs/src/content/docs/faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Frequently asked questions 3 | --- 4 | 5 | ## What is Jsonnet? 6 | 7 | Jsonnet is a data templating language, originally created by Google. 8 | 9 | It is a superset of JSON, which adds common structures from full programming 10 | languages to data modeling. Because it being a superset of JSON and ultimately 11 | always compiling to JSON, it is guaranteed that the output will be valid JSON 12 | (or YAML). 13 | 14 | By allowing _functions_ and _imports_, rich abstraction is possible, even across 15 | project boundaries. 16 | 17 | For more, refer to the official documentation: https://jsonnet.org/ 18 | 19 | ## How is this different from ksonnet? 20 | 21 | Tanka aims to be a fully compatible, drop-in replacement for the main workflow 22 | of `ksonnet` (`show`, `diff`, `apply`). 23 | 24 | In general, both tools are very similar when it comes to how they handle Jsonnet 25 | and apply to a Kubernetes cluster. 26 | 27 | However, `ksonnet` included a rich code generator for establishing a CLI based 28 | workflow for editing Kubernetes objects. It also used to manage dependencies 29 | itself and had a lot of concepts for different levels of abstractions. When 30 | designing Tanka, we felt these add more complexity for the user than they 31 | provide additional value. To keep Tanka as minimal as possible, these are **not 32 | available** and are not likely to be ever added. 33 | 34 | ## What about kubecfg ? 35 | 36 | Tanka development has started at the time when kubecfg was a part of 37 | already-deprecated `ksonnet` project. Although these projects are similar, Tanka 38 | aims to provide continuity for `ksonnet` users, whereas `kubecfg` is (according 39 | to the project's [README.md](https://github.com/vmware-archive/kubecfg/blob/main/README.md)) 40 | really just a thin Kubernetes-specific wrapper around jsonnet evaluation. 41 | 42 | ## Why not Helm? 43 | 44 | Helm relies heavily on _string templating_ `.yaml` files. We feel this is the 45 | wrong way to approach the absence of abstractions inside of `yaml`, because the 46 | templating part of the application has no idea of the structure and syntax of 47 | yaml. 48 | 49 | This makes debugging very hard. Furthermore, `helm` is not able to provide an 50 | adequate solution for edge cases. If I wanted to set some parameters that are 51 | not already implemented by the Chart, I have no choice but to modify the Chart 52 | first. 53 | 54 | Jsonnet on the other hand got you covered by supporting mixing (patching, 55 | deep-merging) objects on top of the libraries output if required. 56 | -------------------------------------------------------------------------------- /docs/src/content/docs/formatting.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Formatting 3 | sidebar: 4 | order: 7 5 | --- 6 | 7 | # File Formatting 8 | 9 | Tanka supports formatting for all `jsonnet` and `libsonnet` files using the `tk fmt` command. 10 | 11 | By default, the command excludes all `vendor` directories. 12 | 13 | ```bash 14 | # Run for current and child directories. Run this in the root of the project to format all your files. 15 | tk fmt . 16 | 17 | # Format a single file (myFile.jsonnet) 18 | tk fmt myFile.jsonnet 19 | 20 | # Use the `-t` tag to test (Dry run). 21 | tk fmt -t myFile.jsonnet 22 | 23 | # Format using verbose mode. 24 | tk fmt -v . 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/src/content/docs/garbage-collection.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Garbage collection 3 | sidebar: 4 | order: 1 5 | --- 6 | 7 | Tanka can automatically delete resources from your cluster once you remove them 8 | from Jsonnet. 9 | 10 | :::caution 11 | This feature is **experimental**. Please report problems at https://github.com/grafana/tanka/issues. 12 | ::: 13 | 14 | To accomplish this, it appends the `tanka.dev/environment: ` label to each created 15 | resource. This is used to identify those which are missing from the local state in the 16 | future. 17 | 18 | :::note 19 | The label value changed from the `` to a `` in v0.15.0. 20 | ::: 21 | 22 | Because the label causes a `diff` for every single object in your cluster and 23 | not everybody wants this, it needs to be explicitly enabled. To do so, add the 24 | following field to your `spec.json`: 25 | 26 | ```diff 27 | { 28 | "spec": { 29 | + "injectLabels": true, 30 | } 31 | } 32 | ``` 33 | 34 | Once added, run a `tk apply`, make sure the label is actually added and confirm 35 | by typing `yes`. 36 | 37 | From now on, you can use `tk prune` to remove old resources from your cluster. 38 | -------------------------------------------------------------------------------- /docs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Grafana Tanka' 3 | head: 4 | - tag: title 5 | content: Grafana Tanka 6 | # This needs to be here for starlight to pick up the correct Hero override 7 | hero: 8 | tagline: '' 9 | tableOfContents: false 10 | --- 11 | 12 | import { Card, CardGrid } from '@astrojs/starlight/components'; 13 | 14 | ## Why Grafana Tanka? 15 | 16 | 17 | 18 | The Jsonnet language expresses your Kubernetes apps more clearly than YAML 19 | ever did 20 | 21 | 22 | Build application libraries, import them anywhere and even share them on 23 | GitHub! 24 | 25 | 26 | Using the Kubernetes library, you will never see boilerplate again! 27 | 28 | 29 | Stop guessing and use powerful diff to know the exact changes in advance 30 | 31 | 32 | Tanka deploys Grafana Cloud and many more production setups 33 | 34 | 35 | Just like the popular Grafana and Loki projects, Tanka is fully open-source 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/src/content/docs/internal/releasing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Releasing a new version' 3 | --- 4 | 5 | For releasing Tanka we're using [release-please][]. 6 | This workflow manages a release pull-request based on the content of the `main` branch that would update the changelog et al.. 7 | Once you want to do a release, merge that prepared pull-request. 8 | release-please will then do all the tagging and GitHub Release creation. 9 | 10 | [release-please]: https://github.com/googleapis/release-please-action 11 | -------------------------------------------------------------------------------- /docs/src/content/docs/known-issues.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Known issues 3 | --- 4 | 5 | Below is a list of common errors and how to address them. 6 | 7 | ## `Evaluating jsonnet: RUNTIME ERROR: Undefined external variable: __ksonnet/components` 8 | 9 | When migrating from `ksonnet`, this error might occur, because Tanka does not 10 | provide the global `__ksonnet` variable, nor does it strictly have the concept 11 | of components. 12 | You will need to use the plain Jsonnet `import` feature instead. Note that this 13 | requires your code to be inside of one of the 14 | [import paths](./libraries/import-paths/). 15 | 16 | ## `Evaluating jsonnet: RUNTIME ERROR: couldn't open import "k.libsonnet": no match locally or in the Jsonnet library paths` 17 | 18 | This error can occur when the `k8s-libsonnet` kubernetes libraries are missing in the 19 | import paths. While `k8s-libsonnet` used to magically include them, Tanka follows a 20 | more explicit approach and requires you to install them using `jb`: 21 | 22 | ```bash 23 | jb install github.com/jsonnet-libs/k8s-libsonnet/1.21@main 24 | echo "import 'github.com/jsonnet-libs/k8s-libsonnet/1.21/main.libsonnet'" > lib/k.libsonnet 25 | ``` 26 | 27 | This does 2 things: 28 | 29 | 1. It installs the `k8s-libsonnet` library (in `vendor/github.com/jsonnet-libs/k8s-libsonnet/1.21/`). 30 | You can replace the `1.21` matching the Kubernetes version you want to run against. 31 | 32 | 2. It makes an alias for libraries importing `k.libsonnet` directly. See 33 | [Aliasing](./tutorial/k-lib/#aliasing) for the alias rationale. 34 | 35 | ## Unexpected diff if the same port number is used for UDP and TCP 36 | 37 | A [long-standing bug in `kubectl`](https://github.com/kubernetes/kubernetes/issues/39188) 38 | results in an incorrect diff output if the same port number is used multiple 39 | times in differently named ports, which commonly happens if a port is specified 40 | using both protocols, `tcp` and `udp`. Nevertheless, `tk apply` will still work 41 | correctly. 42 | -------------------------------------------------------------------------------- /docs/src/content/docs/libraries/import-paths.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Import paths 3 | sidebar: 4 | order: 1 5 | --- 6 | 7 | When using `import` or `importstr`, Tanka considers the following directories to 8 | find a suitable file for that specific import: 9 | 10 | | Rank | Path | Purpose | 11 | | ---- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | 12 | | 4 | `` | The directory of your environment, e.g. `/environments/default`.
Put things that belong to this very environment here. | 13 | | 3 | `/lib` | Project-global libraries, that are used in multiple environments, but are specific to this project. | 14 | | 2 | `/vendor` | Per-environment vendor, can be used for [`vendor` overriding](./libraries/overriding/#per-environment) | 15 | | 1 | `/vendor` | Global vendor, holds external libraries installed using `jb`. | 16 | 17 | :::note 18 | 19 | - If a file occurs in multiple paths, the one with the highest rank will be chosen. 20 | - `/` in above table means ``, which is your project root. 21 | ::: 22 | -------------------------------------------------------------------------------- /docs/src/content/docs/libraries/install-publish.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installing and publishing 3 | sidebar: 4 | order: 2 5 | --- 6 | 7 | The tool for dealing with libraries is 8 | [`jsonnet-bundler`](https://github.com/jsonnet-bundler/jsonnet-bundler). It can 9 | install packages from any git source using `ssh` and GitHub over `https`. 10 | 11 | ## Install a library 12 | 13 | To install a library from GitHub, use one of the following: 14 | 15 | ```bash 16 | jb install github.com// 17 | jb install github.com/// 18 | jb install github.com///@ 19 | ``` 20 | 21 | Otherwise, use the ssh syntax: 22 | 23 | ```bash 24 | jb install git+ssh://git@mycode.server:.git 25 | jb install git+ssh://git@mycode.server:.git/ 26 | jb install git+ssh://git@mycode.server:.git/@ 27 | ``` 28 | 29 | :::note 30 | `version` may be any git ref, such as commits, tags or branches 31 | ::: 32 | 33 | ## Publish to Git(Hub) 34 | 35 | Publishing is as easy as committing and pushing to a git remote. 36 | [GitHub](https://github.com) is recommended, as it is most common and supports 37 | faster installing using http archives. 38 | -------------------------------------------------------------------------------- /docs/src/content/docs/namespaces.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Namespaces 3 | sidebar: 4 | order: 6 5 | --- 6 | 7 | When using Tanka, namespaces are handled slightly different compared to 8 | `kubectl`, because environments offer more granular control than contexts used 9 | by `kubectl`. 10 | 11 | ## Default namespaces 12 | 13 | In the [`spec.json`](./config/#file-format) of each environment, you can set the 14 | `spec.namespace` field, which is the default namespace. The default namespace is 15 | set for every resource that **does not** have a namespace **set from Jsonnet**. 16 | 17 | | | Scenario | Action | 18 | | --- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | 19 | | 1. | Your resource **lacks namespace** information (`metadata.namespace`) unset or `""` | Tanka sets `metadata.namespace` to the value of `spec.namespace` in `spec.json` | 20 | | 2. | Your resource **already has** namespace information | Tanka does nothing, accepting the explicit namespace | 21 | 22 | While we recommend keeping environments limited to a single namespace, there are 23 | legit cases where it's handy to have them span multiple namespaces, for example: 24 | 25 | - Some other piece of software (Operators, etc) require resources to be in a specific namespace 26 | - A rarely changing "base" environment holding resources deployed for many clusters in the same way 27 | - etc. 28 | 29 | ## Cluster-wide resources 30 | 31 | Some resources in Kubernetes are cluster-wide, meaning they don't belong to a single namespace at all. 32 | 33 | Tanka will make an attempt to not add namespaces to _known_ cluster-wide types. 34 | It does this with a short list of types in [the source code](https://github.com/grafana/tanka/blob/main/pkg/process/namespace.go). 35 | 36 | Tanka cannot feasibly maintain this list for all known custom resource types. In those cases, resources will have namespaces added to their manifests, 37 | and kubectl should happily apply them as non-namespaced resources. 38 | 39 | If this presents a problem for your workflow, you can **override this** behavior 40 | per-resource, by setting the `tanka.dev/namespaced` annotation to `"false"` 41 | (must be of `string` type): 42 | 43 | ```jsonnet 44 | thing: clusterRole.new("myClusterRole") 45 | + clusterRole.mixin.metadata.withAnnotationsMixin({ "tanka.dev/namespaced": "false" }) 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/src/content/docs/output-filtering.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Output filtering 3 | sidebar: 4 | order: 4 5 | --- 6 | 7 | When a project becomes bigger over time and includes a lot of Kubernetes 8 | objects, it may become required to operate on only a subset of them (e.g. apply 9 | only a part of an application). 10 | 11 | Tanka helps you with this, by allowing you to limit the used objects on the 12 | command line using the `--target` flag. Say you are deploying an `nginx` 13 | instance with a special `nginx.conf` and want to apply the `ConfigMap` first: 14 | 15 | ```bash 16 | # show the ConfigMap 17 | tk show -t configmap/nginx . 18 | 19 | # all good? apply! 20 | tk apply -t configmap/nginx . 21 | 22 | # and apply everything else: 23 | tk apply . 24 | ``` 25 | 26 | The syntax of the `--target` / `-t` flag is `--target=/`. If 27 | multiple objects match this pattern, all of them are used. 28 | 29 | The `--target` / `-t` flag can be specified multiple times, to work with 30 | multiple objects. 31 | 32 | ## Regular Expressions 33 | 34 | The argument passed to the `--target` flag is interpreted as a 35 | [RE2](https://github.com/google/re2/wiki/Syntax) regular expression. 36 | 37 | This allows you to use all sorts of wildcards and other advanced matching 38 | functionality to select Kubernetes objects: 39 | 40 | ```bash 41 | # show all deployments 42 | tk show . -t 'deployment/.*' 43 | 44 | # show all objects named "loki" 45 | tk show . -t '.*/loki' 46 | ``` 47 | 48 | ### Gotchas 49 | 50 | When using regular expressions, there are some things to watch out for: 51 | 52 | #### Line Anchors 53 | 54 | Tanka automatically surrounds your regular expression with line anchors: 55 | 56 | ```text 57 | ^$ 58 | ``` 59 | 60 | For example, `--target 'deployment/.*'` becomes `^deployment/.*$`. 61 | 62 | #### Quoting 63 | 64 | Regular expressions may consist of characters that have special meanings in 65 | shell. Always make sure to properly quote your regular expression using **single 66 | quotes**. 67 | 68 | ```zsh 69 | # shell attempts to match the wildcard itself: 70 | zsh-5.4.2$ tk show . -t deployment/.* 71 | zsh: no matches found: deployment/.* 72 | 73 | # properly quoted: 74 | zsh-5.4.2$ tk show . -t 'deployment/.*' 75 | --- 76 | apiVersion: apps/v1 77 | kind: Deployment 78 | # ... 79 | ``` 80 | 81 | ## Excluding 82 | 83 | Sometimes it may be desirably to exclude a single object, instead of including all others. 84 | 85 | To do so, prepend the regular expression with an exclamation mark (`!`), like so: 86 | 87 | ```bash 88 | # filter out all Deployments 89 | tk show . -t '!deployment/.*' 90 | ``` 91 | -------------------------------------------------------------------------------- /docs/src/content/docs/server-side-apply.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Server-Side Apply 3 | sidebar: 4 | order: 7 5 | --- 6 | 7 | Tanka supports 8 | [server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/), 9 | which requires at least Kubernetes 1.16+, and was promoted to stable status in 1.22. 10 | 11 | To enable server-side diff in tanka, add the following field to `spec.json`: 12 | 13 | ```diff 14 | { 15 | "spec": { 16 | + "applyStrategy": "server", 17 | } 18 | } 19 | ``` 20 | 21 | This also has the effect of changing the default [diff strategy](./diff-strategy/) 22 | to `server`, but this can be overridden via command line flags or `spec.json`. 23 | 24 | While server-side apply doesn't have any effect on the resources being applied 25 | and is intended to be a general in-place upgrade to client-side apply, there are 26 | differences in how fields are managed that can make converting existing cluster 27 | resources a non-trival change. 28 | 29 | Identifying and fixing these changes are beyond the scope of this guide, but 30 | many can be found before an apply by using the `validate` or `server` 31 | [diff strategy](./diff-strategy/). 32 | 33 | ## Field conflicts 34 | 35 | As part of the changes, you may encounter error messages which 36 | recommend the use of the `--force-conflicts` flag. Using `tk apply --force` 37 | in server-side mode will enable that flag for kubectl instead of 38 | `kubectl --force`, which no longer has any effect in server-side mode. 39 | -------------------------------------------------------------------------------- /docs/src/content/docs/tutorial/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | sidebar: 4 | order: 1 5 | --- 6 | 7 | ## Learning how to use Tanka 8 | 9 | Welcome to the Tanka tutorial! 10 | The following sections will explain how to deploy an example stack, 11 | ([Grafana](https://hub.docker.com/r/grafana/grafana) and 12 | [Prometheus](https://hub.docker.com/r/prom/prometheus)), to Kubernetes. We will also deal with parameters, differences between `dev` and `prod` and how to stop worrying and love libraries. 13 | 14 | To do so, we have the following steps: 15 | 16 | 1. [Deploying **without** Tanka first](./tutorial/refresher/): Using good old `kubectl` to understand what Tanka will do for us. 17 | 2. [Using Jsonnet](./tutorial/jsonnet/): Doing the same thing once again, but this time with Tanka and Jsonnet. 18 | 3. [Parameterizing](./tutorial/parameters/): Using Variables to avoid data duplication. 19 | 4. [Abstraction](./tutorial/abstraction/): Splitting components into individual parts. 20 | 5. [Environments](./tutorial/environments/): Dealing with differences between `dev` and `prod`. 21 | 6. [`k.libsonnet`](./tutorial/k-lib/): Avoid having to remember API resources. 22 | 23 | Completing this gives a solid knowledge of Tanka's fundamentals. Let's get started! 24 | 25 | ## Resources 26 | 27 | - The final outcome of this tutorial can be seen here: 28 | [https://github.com/grafana/tanka/examples/prom-grafana](https://github.com/grafana/tanka/tree/main/examples/prom-grafana) 29 | -------------------------------------------------------------------------------- /docs/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /docs/src/tailwind.css: -------------------------------------------------------------------------------- 1 | @layer theme, base, components, utilities; 2 | @import 'tailwindcss/theme.css' layer(theme); 3 | @import 'tailwindcss/preflight.css' layer(base); 4 | @import 'tailwindcss/utilities.css' layer(utilities); 5 | 6 | @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); 7 | 8 | @theme { 9 | --color-accent-50: #fff7ed; 10 | --color-accent-100: #ffedd5; 11 | --color-accent-200: #fed7aa; 12 | --color-accent-300: #fdba74; 13 | --color-accent-400: #fb923c; 14 | --color-accent-500: #f97316; 15 | --color-accent-600: #ea580c; 16 | --color-accent-700: #c2410c; 17 | --color-accent-800: #9a3412; 18 | --color-accent-900: #7c2d12; 19 | --color-accent-950: #431407; 20 | 21 | --color-gray-50: #fafafa; 22 | --color-gray-100: #f4f4f5; 23 | --color-gray-200: #e4e4e7; 24 | --color-gray-300: #d4d4d8; 25 | --color-gray-400: #a1a1aa; 26 | --color-gray-500: #71717a; 27 | --color-gray-600: #52525b; 28 | --color-gray-700: #3f3f46; 29 | --color-gray-800: #27272a; 30 | --color-gray-900: #18181b; 31 | --color-gray-950: #09090b; 32 | 33 | --grid-template-columns-hero: 7fr 4fr; 34 | } 35 | 36 | @layer components { 37 | a { 38 | @apply text-accent-600 underline; 39 | } 40 | } 41 | 42 | @layer base { 43 | :root { 44 | --sl-font: 'Inter Variable'; 45 | } 46 | } 47 | 48 | /* TODO: this block can be removed once `@astrojs/starlight-tailwind` supports 49 | * Tailwind 4 */ 50 | :root[data-theme='light'] { 51 | --sl-color-white: var(--color-gray-900); 52 | --sl-color-gray-1: var(--color-gray-800); 53 | --sl-color-gray-2: var(--color-gray-700); 54 | --sl-color-gray-3: var(--color-gray-500); 55 | --sl-color-gray-4: var(--color-gray-400); 56 | --sl-color-gray-5: var(--color-gray-300); 57 | --sl-color-gray-6: var(--color-gray-200); 58 | --sl-color-gray-7: var(--color-gray-100); 59 | --sl-color-black: white; 60 | --sl-color-accent-low: var(--color-accent-200); 61 | --sl-color-accent: var(--color-accent-600); 62 | --sl-color-accent-high: var(--color-accent-900); 63 | } 64 | :root[data-theme='dark'] { 65 | --sl-color-gray-1: var(--color-gray-200); 66 | --sl-color-gray-2: var(--color-gray-300); 67 | --sl-color-gray-3: var(--color-gray-400); 68 | --sl-color-gray-4: var(--color-gray-600); 69 | --sl-color-gray-5: var(--color-gray-700); 70 | --sl-color-gray-6: var(--color-gray-800); 71 | --sl-color-black: var(--color-gray-900); 72 | --sl-color-accent: var(--color-accent-950); 73 | --sl-color-accent-high: var(--color-accent-200); 74 | } 75 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest" 3 | } 4 | -------------------------------------------------------------------------------- /examples/prom-grafana/.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | -------------------------------------------------------------------------------- /examples/prom-grafana/environments/prom-grafana/dev/main.jsonnet: -------------------------------------------------------------------------------- 1 | import 'prom-grafana/prom-grafana.libsonnet' 2 | -------------------------------------------------------------------------------- /examples/prom-grafana/environments/prom-grafana/dev/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "dev" 6 | }, 7 | "spec": { 8 | "apiServer": "", 9 | "namespace": "prom-grafana-dev" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/prom-grafana/environments/prom-grafana/patched/main.jsonnet: -------------------------------------------------------------------------------- 1 | (import 'prom-grafana/prom-grafana.libsonnet') + 2 | { 3 | promgrafana+: { 4 | prometheus+: { 5 | deployment+: { 6 | metadata+: { 7 | labels+: { 8 | foo: 'bar', 9 | }, 10 | }, 11 | }, 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /examples/prom-grafana/environments/prom-grafana/patched/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "patched" 6 | }, 7 | "spec": { 8 | "apiServer": "", 9 | "namespace": "default" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/prom-grafana/environments/prom-grafana/prod/main.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /examples/prom-grafana/environments/prom-grafana/prod/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "prod" 6 | }, 7 | "spec": { 8 | "apiServer": "", 9 | "namespace": "prom-grafana-prod" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/prom-grafana/jsonnetfile.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dependencies": [ 4 | { 5 | "source": { 6 | "git": { 7 | "remote": "https://github.com/grafana/jsonnet-libs.git", 8 | "subdir": "ksonnet-util" 9 | } 10 | }, 11 | "version": "master" 12 | }, 13 | { 14 | "source": { 15 | "git": { 16 | "remote": "https://github.com/jsonnet-libs/k8s-libsonnet.git", 17 | "subdir": "1.21" 18 | } 19 | }, 20 | "version": "main" 21 | } 22 | ], 23 | "legacyImports": true 24 | } 25 | -------------------------------------------------------------------------------- /examples/prom-grafana/jsonnetfile.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dependencies": [ 4 | { 5 | "source": { 6 | "git": { 7 | "remote": "https://github.com/grafana/jsonnet-libs.git", 8 | "subdir": "ksonnet-util" 9 | } 10 | }, 11 | "version": "84686ea681cd35c15f5ecd66c0d1eee3cc4a0981", 12 | "sum": "jelt5QWEerVPLHHZN6Ga0B4OQ/MLBl+OLj3kVzTET+Y=" 13 | }, 14 | { 15 | "source": { 16 | "git": { 17 | "remote": "https://github.com/jsonnet-libs/k8s-libsonnet.git", 18 | "subdir": "1.21" 19 | } 20 | }, 21 | "version": "e02dc383505f699ba525861303d167387912278e", 22 | "sum": "nwLFNxWjkftavkDSIFcme+t3KowULjBJn/lgcghru+o=" 23 | } 24 | ], 25 | "legacyImports": false 26 | } 27 | -------------------------------------------------------------------------------- /examples/prom-grafana/lib/k.libsonnet: -------------------------------------------------------------------------------- 1 | import 'github.com/jsonnet-libs/k8s-libsonnet/1.21/main.libsonnet' 2 | -------------------------------------------------------------------------------- /examples/prom-grafana/lib/prom-grafana/config.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | // +:: is important (we don't want to override the 3 | // _config object, just add to it) 4 | _config+:: { 5 | // define a namespace for this library 6 | promgrafana: { 7 | grafana: { 8 | port: 3000, 9 | name: "grafana", 10 | }, 11 | prometheus: { 12 | port: 9090, 13 | name: "prometheus" 14 | } 15 | } 16 | }, 17 | 18 | // again, make sure to use +:: 19 | _images+:: { 20 | promgrafana: { 21 | grafana: "grafana/grafana", 22 | prometheus: "prom/prometheus", 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/prom-grafana/lib/prom-grafana/prom-grafana.libsonnet: -------------------------------------------------------------------------------- 1 | local k = import 'ksonnet-util/kausal.libsonnet'; 2 | 3 | (import './config.libsonnet') + 4 | { 5 | local deployment = k.apps.v1.deployment, 6 | local container = k.core.v1.container, 7 | local port = k.core.v1.containerPort, 8 | local service = k.core.v1.service, 9 | 10 | // alias our params, too long to type every time 11 | local c = $._config.promgrafana, 12 | 13 | promgrafana: { 14 | prometheus: { 15 | deployment: deployment.new( 16 | name=c.prometheus.name, 17 | replicas=1, 18 | containers=[ 19 | container.new(c.prometheus.name, $._images.promgrafana.prometheus) 20 | + container.withPorts([port.new('api', c.prometheus.port)]), 21 | ], 22 | ), 23 | service: k.util.serviceFor(self.deployment), 24 | }, 25 | 26 | grafana: { 27 | deployment: deployment.new( 28 | name=c.grafana.name, 29 | replicas=1, 30 | containers=[ 31 | container.new(c.grafana.name, $._images.promgrafana.grafana) 32 | + container.withPorts([port.new('ui', c.grafana.port)]), 33 | ], 34 | ), 35 | service: 36 | k.util.serviceFor(self.deployment) 37 | + service.mixin.spec.withType('NodePort'), 38 | }, 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/tanka 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/Masterminds/semver v1.5.0 9 | github.com/Masterminds/sprig/v3 v3.3.0 10 | github.com/fatih/color v1.18.0 11 | github.com/fatih/structs v1.1.0 12 | github.com/go-clix/cli v0.2.0 13 | github.com/gobwas/glob v0.2.3 14 | github.com/google/go-cmp v0.7.0 15 | github.com/google/go-jsonnet v0.21.0 16 | github.com/pkg/errors v0.9.1 17 | github.com/posener/complete v1.2.3 18 | github.com/rs/zerolog v1.34.0 19 | github.com/spf13/pflag v1.0.6 20 | github.com/stretchr/objx v0.5.2 21 | github.com/stretchr/testify v1.10.0 22 | github.com/thoas/go-funk v0.9.3 23 | golang.org/x/term v0.32.0 24 | golang.org/x/text v0.25.0 25 | gopkg.in/yaml.v2 v2.4.0 26 | gopkg.in/yaml.v3 v3.0.1 27 | k8s.io/apimachinery v0.33.1 28 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 29 | sigs.k8s.io/yaml v1.4.0 30 | ) 31 | 32 | require ( 33 | dario.cat/mergo v1.0.1 // indirect 34 | github.com/Masterminds/goutils v1.1.1 // indirect 35 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 36 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 37 | github.com/go-logr/logr v1.4.2 // indirect 38 | github.com/google/uuid v1.6.0 // indirect 39 | github.com/hashicorp/errwrap v1.1.0 // indirect 40 | github.com/hashicorp/go-multierror v1.1.1 // indirect 41 | github.com/huandu/xstrings v1.5.0 // indirect 42 | github.com/mattn/go-colorable v0.1.13 // indirect 43 | github.com/mattn/go-isatty v0.0.20 // indirect 44 | github.com/mitchellh/copystructure v1.2.0 // indirect 45 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 46 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 47 | github.com/shopspring/decimal v1.4.0 // indirect 48 | github.com/spf13/cast v1.7.0 // indirect 49 | golang.org/x/crypto v0.36.0 // indirect 50 | golang.org/x/sys v0.33.0 // indirect 51 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 52 | k8s.io/klog/v2 v2.130.1 // indirect 53 | ) 54 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.24.0 2 | 3 | toolchain go1.24.2 4 | 5 | use ( 6 | . 7 | ./acceptance-tests 8 | ./dagger 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/jsonnet/evalcache.go: -------------------------------------------------------------------------------- 1 | package jsonnet 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // FileEvalCache is an evaluation cache that stores its data on the local filesystem 9 | type FileEvalCache struct { 10 | Directory string 11 | } 12 | 13 | func NewFileEvalCache(cachePath string) *FileEvalCache { 14 | return &FileEvalCache{ 15 | Directory: cachePath, 16 | } 17 | } 18 | 19 | func (c *FileEvalCache) cachePath(hash string) (string, error) { 20 | return filepath.Abs(filepath.Join(c.Directory, hash+".json")) 21 | } 22 | 23 | func (c *FileEvalCache) Get(hash string) (string, error) { 24 | cachePath, err := c.cachePath(hash) 25 | if err != nil { 26 | return "", err 27 | } 28 | 29 | if bytes, err := os.ReadFile(cachePath); err == nil { 30 | return string(bytes), err 31 | } else if !os.IsNotExist(err) { 32 | return "", err 33 | } 34 | return "", nil 35 | } 36 | 37 | func (c *FileEvalCache) Store(hash, content string) error { 38 | if err := os.MkdirAll(c.Directory, os.ModePerm); err != nil { 39 | return err 40 | } 41 | 42 | cachePath, err := c.cachePath(hash) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return os.WriteFile(cachePath, []byte(content), 0644) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/jsonnet/files.go: -------------------------------------------------------------------------------- 1 | package jsonnet 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/gobwas/glob" 9 | ) 10 | 11 | // FindFiles takes a file / directory and finds all Jsonnet files 12 | func FindFiles(target string, excludes []glob.Glob) ([]string, error) { 13 | // if it's a file, don't try to find children 14 | fi, err := os.Stat(target) 15 | if err != nil { 16 | return nil, err 17 | } 18 | if fi.Mode().IsRegular() { 19 | return []string{target}, nil 20 | } 21 | 22 | var files []string 23 | 24 | err = filepath.WalkDir(target, func(path string, d fs.DirEntry, err error) error { 25 | if err != nil { 26 | return err 27 | } 28 | path = filepath.ToSlash(path) 29 | if d.IsDir() { 30 | return nil 31 | } 32 | 33 | // excluded? 34 | for _, g := range excludes { 35 | if g.Match(path) { 36 | return nil 37 | } 38 | } 39 | 40 | // only .jsonnet or .libsonnet 41 | if ext := filepath.Ext(path); ext == ".jsonnet" || ext == ".libsonnet" { 42 | files = append(files, path) 43 | } 44 | return nil 45 | }) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | return files, nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/jsonnet/implementations/binary/impl.go: -------------------------------------------------------------------------------- 1 | package binary 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/grafana/tanka/pkg/jsonnet/implementations/types" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | type JsonnetBinaryRunner struct { 14 | binPath string 15 | args []string 16 | } 17 | 18 | func (r *JsonnetBinaryRunner) EvaluateAnonymousSnippet(snippet string) (string, error) { 19 | cmd := exec.Command(r.binPath, append(r.args, "-e", snippet)...) 20 | 21 | var errbuf strings.Builder 22 | cmd.Stderr = &errbuf 23 | 24 | out, err := cmd.Output() 25 | 26 | for _, line := range strings.Split(errbuf.String(), "\n\n") { 27 | log.Info().Msg(line) 28 | } 29 | 30 | if err != nil { 31 | return "", fmt.Errorf("error running anonymous snippet: %w\n%s", err, string(out)) 32 | } 33 | 34 | return string(out), nil 35 | } 36 | 37 | func (r *JsonnetBinaryRunner) EvaluateFile(filename string) (string, error) { 38 | cmd := exec.Command(r.binPath, append(r.args, filename)...) 39 | 40 | var errbuf strings.Builder 41 | cmd.Stderr = &errbuf 42 | 43 | out, err := cmd.Output() 44 | 45 | for _, line := range strings.Split(errbuf.String(), "\n\n") { 46 | log.Info().Msg(line) 47 | } 48 | 49 | if err != nil { 50 | return "", fmt.Errorf("error running file %s: %w\n%s", filename, err, string(out)) 51 | } 52 | 53 | return string(out), nil 54 | } 55 | 56 | // JsonnetBinaryImplementation runs Jsonnet in a subprocess. It doesn't support native functions. 57 | // The interface of the binary has to compatible with the official Jsonnet CLI. 58 | // It has to support the following flags: 59 | // -J for specifying import paths 60 | // --ext-code = for specifying external variables 61 | // --tla-code = for specifying top-level arguments 62 | // --max-stack for specifying the maximum stack size 63 | // -e for evaluating code snippets 64 | // positional arg for evaluating files 65 | type JsonnetBinaryImplementation struct { 66 | BinPath string 67 | } 68 | 69 | func (i *JsonnetBinaryImplementation) MakeEvaluator(importPaths []string, extCode map[string]string, tlaCode map[string]string, maxStack int) types.JsonnetEvaluator { 70 | args := []string{} 71 | for _, p := range importPaths { 72 | args = append(args, "-J", p) 73 | } 74 | if maxStack > 0 { 75 | args = append(args, "--max-stack", strconv.Itoa(maxStack)) 76 | } 77 | for k, v := range extCode { 78 | args = append(args, "--ext-code", k+"="+v) 79 | } 80 | for k, v := range tlaCode { 81 | args = append(args, "--tla-code", k+"="+v) 82 | } 83 | 84 | return &JsonnetBinaryRunner{ 85 | binPath: i.BinPath, 86 | args: args, 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/jsonnet/implementations/goimpl/impl.go: -------------------------------------------------------------------------------- 1 | package goimpl 2 | 3 | import ( 4 | "github.com/google/go-jsonnet" 5 | "github.com/grafana/tanka/pkg/jsonnet/implementations/types" 6 | ) 7 | 8 | type JsonnetGoVM struct { 9 | vm *jsonnet.VM 10 | 11 | path string 12 | } 13 | 14 | func (vm *JsonnetGoVM) EvaluateAnonymousSnippet(snippet string) (string, error) { 15 | return vm.vm.EvaluateAnonymousSnippet(vm.path, snippet) 16 | } 17 | 18 | func (vm *JsonnetGoVM) EvaluateFile(filename string) (string, error) { 19 | return vm.vm.EvaluateFile(filename) 20 | } 21 | 22 | type JsonnetGoImplementation struct { 23 | Path string 24 | } 25 | 26 | func (i *JsonnetGoImplementation) MakeEvaluator(importPaths []string, extCode map[string]string, tlaCode map[string]string, maxStack int) types.JsonnetEvaluator { 27 | return &JsonnetGoVM{ 28 | vm: MakeRawVM(importPaths, extCode, tlaCode, maxStack), 29 | 30 | path: i.Path, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/jsonnet/implementations/goimpl/tk.libsonnet.go: -------------------------------------------------------------------------------- 1 | package goimpl 2 | 3 | import jsonnet "github.com/google/go-jsonnet" 4 | 5 | var tkLibsonnet = jsonnet.MakeContents(` 6 | { 7 | env: std.extVar("tanka.dev/environment"), 8 | } 9 | `) 10 | -------------------------------------------------------------------------------- /pkg/jsonnet/implementations/goimpl/vm.go: -------------------------------------------------------------------------------- 1 | package goimpl 2 | 3 | import ( 4 | "github.com/google/go-jsonnet" 5 | "github.com/grafana/tanka/pkg/jsonnet/native" 6 | ) 7 | 8 | // MakeRawVM returns a Jsonnet VM with some extensions of Tanka, including: 9 | // - extended importer 10 | // - extCode and tlaCode applied 11 | // - native functions registered 12 | // This is exposed because Go is used for advanced use cases, like finding transitive imports or linting. 13 | func MakeRawVM(importPaths []string, extCode map[string]string, tlaCode map[string]string, maxStack int) *jsonnet.VM { 14 | vm := jsonnet.MakeVM() 15 | vm.Importer(newExtendedImporter(importPaths)) 16 | 17 | for k, v := range extCode { 18 | vm.ExtCode(k, v) 19 | } 20 | for k, v := range tlaCode { 21 | vm.TLACode(k, v) 22 | } 23 | 24 | for _, nf := range native.Funcs() { 25 | vm.NativeFunction(nf) 26 | } 27 | 28 | if maxStack > 0 { 29 | vm.MaxStack = maxStack 30 | } 31 | 32 | return vm 33 | } 34 | -------------------------------------------------------------------------------- /pkg/jsonnet/implementations/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // JsonnetEvaluator represents a struct that can evaluate Jsonnet code 4 | // It is configured with import paths, external code and top-level arguments 5 | type JsonnetEvaluator interface { 6 | EvaluateAnonymousSnippet(snippet string) (string, error) 7 | EvaluateFile(filename string) (string, error) 8 | } 9 | 10 | // JsonnetImplementation is a factory for JsonnetEvaluator 11 | type JsonnetImplementation interface { 12 | MakeEvaluator(importPaths []string, extCode map[string]string, tlaCode map[string]string, maxStack int) JsonnetEvaluator 13 | } 14 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/errors.go: -------------------------------------------------------------------------------- 1 | package jpath 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // ErrorNoRoot means no rootDir was found in the parent directories 9 | var ErrorNoRoot = errors.New(`unable to identify the project root. 10 | Tried to find 'tkrc.yaml' or 'jsonnetfile.json' in the parent directories. 11 | Please refer to https://tanka.dev/directory-structure for more information`) 12 | 13 | // ErrorNoBase means no baseDir was found in the parent directories 14 | type ErrorNoBase struct { 15 | filename string 16 | } 17 | 18 | func (e ErrorNoBase) Error() string { 19 | return fmt.Sprintf(`Unable to identify the environments base directory. 20 | Tried to find '%s' in the parent directories. 21 | Please refer to https://tanka.dev/directory-structure for more information`, e.filename) 22 | } 23 | 24 | // ErrorFileNotFound means that the searched file was not found 25 | type ErrorFileNotFound struct { 26 | filename string 27 | } 28 | 29 | func (e ErrorFileNotFound) Error() string { 30 | return e.filename + " not found" 31 | } 32 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/jpath.go: -------------------------------------------------------------------------------- 1 | package jpath 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | const DefaultEntrypoint = "main.jsonnet" 9 | 10 | // Resolve the given path and resolves the jPath around it. This means it: 11 | // - figures out the project root (the one with .jsonnetfile, vendor/ and lib/) 12 | // - figures out the environments base directory (usually the main.jsonnet) 13 | // 14 | // It then constructs a jPath with the base directory, vendor/ and lib/. 15 | // This results in predictable imports, as it doesn't matter whether the user called 16 | // called the command further down tree or not. A little bit like git. 17 | func Resolve(path string, allowMissingBase bool) (jpath []string, base, root string, err error) { 18 | root, err = FindRoot(path) 19 | if err != nil { 20 | return nil, "", "", err 21 | } 22 | 23 | base, err = FindBase(path, root) 24 | if err != nil && allowMissingBase { 25 | base, err = FsDir(path) 26 | if err != nil { 27 | return nil, "", "", err 28 | } 29 | } else if err != nil { 30 | return nil, "", "", err 31 | } 32 | 33 | // The importer iterates through this list in reverse order 34 | return []string{ 35 | filepath.Join(root, "vendor"), 36 | filepath.Join(base, "vendor"), // Look for a vendor folder in the base dir before using the root vendor 37 | filepath.Join(root, "lib"), 38 | base, 39 | }, base, root, nil 40 | } 41 | 42 | // Filename returns the name of the entrypoint file. 43 | // It DOES NOT return an absolute path, only a plain name like "main.jsonnet" 44 | // To obtain an absolute path, use Entrypoint() instead. 45 | func Filename(path string) (string, error) { 46 | fi, err := os.Stat(path) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | if fi.IsDir() { 52 | return DefaultEntrypoint, nil 53 | } 54 | 55 | return filepath.Base(fi.Name()), nil 56 | } 57 | 58 | // Entrypoint returns the absolute path of the environments entrypoint file (the 59 | // one passed to jsonnet.EvaluateFile) 60 | func Entrypoint(path string) (string, error) { 61 | root, err := FindRoot(path) 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | base, err := FindBase(path, root) 67 | if err != nil { 68 | return "", err 69 | } 70 | 71 | filename, err := Filename(path) 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | return filepath.Join(base, filename), nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/jpath_test.go: -------------------------------------------------------------------------------- 1 | package jpath_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/grafana/tanka/pkg/jsonnet" 11 | "github.com/grafana/tanka/pkg/jsonnet/implementations/goimpl" 12 | ) 13 | 14 | var jsonnetImpl = &goimpl.JsonnetGoImplementation{} 15 | 16 | func TestResolvePrecedence(t *testing.T) { 17 | s, err := jsonnet.EvaluateFile(jsonnetImpl, "./testdata/precedence/environments/default/main.jsonnet", jsonnet.Opts{}) 18 | require.NoError(t, err) 19 | 20 | want := map[string]string{ 21 | "baseDir": "baseDir", 22 | "lib": "/lib", 23 | "baseDir-vendor": "baseDir-vendor", 24 | "vendor": "/vendor", 25 | } 26 | 27 | w, err := json.Marshal(want) 28 | require.NoError(t, err) 29 | 30 | assert.JSONEq(t, string(w), s) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/noBase/environments/empty/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/pkg/jsonnet/jpath/testdata/noBase/environments/empty/.gitkeep -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/noBase/environments/filename/custom.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/noBase/environments/filename/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "environments/filename" 6 | }, 7 | "spec": { 8 | "apiServer": "", 9 | "namespace": "default", 10 | "resourceDefaults": {}, 11 | "expectVersions": {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/noBase/environments/noMain/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "environments/noMain" 6 | }, 7 | "spec": { 8 | "apiServer": "", 9 | "namespace": "default", 10 | "resourceDefaults": {}, 11 | "expectVersions": {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/noBase/jsonnetfile.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/noRoot/environments/default/main.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/noRoot/environments/default/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "environments/default" 6 | }, 7 | "spec": { 8 | "apiServer": "", 9 | "namespace": "default", 10 | "resourceDefaults": {}, 11 | "expectVersions": {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/README.md: -------------------------------------------------------------------------------- 1 | ## `precedence` test 2 | 3 | This directory contains some Jsonnet testdata for testing correct import precedence. 4 | 5 | The desired precedence looks like the following (most specific wins): 6 | 7 | 1. consider baseDir (dir containing `main.jsonnet`) first: The current 8 | environment is most important 9 | 2. then `/lib`: project libraries are more specific than any vendors 10 | 3. then `/vendor`: to allow overriding project vendor on a environment level 11 | 4. finally `/vendor`: external packages are least specific 12 | 13 | ## internals 14 | 15 | How the test works: 16 | 17 | We basically put the same jsonnet file in multiple locations: 18 | 19 | ```jsonnet 20 | { 21 | value: "" 22 | } 23 | ``` 24 | 25 | For example to check for `/lib` to precede both vendor folders, the following is used: 26 | 27 | - `/vendor/project_lib.jsonnet`: `value: "/vendor"` 28 | - `//vendor/project_lib.jsonnet`: `value: "/baseDir-vendor"` 29 | - `/lib/project_lib.jsonnet`: `value: "/lib"` 30 | 31 | Then in `/main.jsonnet` we put `import "project_lib.jsonnet"` and 32 | expect `value` to be `/lib`. 33 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/environments/default/baseDir.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "baseDir", 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/environments/default/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | baseDir: (import "baseDir.jsonnet").value, 3 | lib: (import "lib.jsonnet").value, 4 | "baseDir-vendor": (import "baseDir-vendor.jsonnet").value, 5 | vendor: (import "vendor.jsonnet").value, 6 | } 7 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/environments/default/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "env-vendor" 6 | }, 7 | "spec": { 8 | "apiServer": "", 9 | "namespace": "default" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/environments/default/vendor/baseDir-vendor.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "baseDir-vendor" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/environments/default/vendor/baseDir.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "/baseDir-vendor" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/environments/default/vendor/lib.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "/baseDir-vendor" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/jsonnetfile.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/lib/baseDir.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "/lib" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/lib/lib.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "/lib" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/vendor/baseDir-vendor.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "/vendor" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/vendor/baseDir.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "/vendor" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/vendor/lib.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "/vendor" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/precedence/vendor/vendor.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | value: "/vendor" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/valid/environments/default/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | apiVersion: 'v1', 3 | kind: 'ConfigMap', 4 | metadata: { name: 'myConfig' }, 5 | data: (import "nestedDir/file.jsonnet"), 6 | } 7 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/valid/environments/default/nestedDir/file.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | "foo.yml": "foo: bar\nbar: baz" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/valid/environments/default/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "environments/default" 6 | }, 7 | "spec": { 8 | "apiServer": "", 9 | "namespace": "default", 10 | "resourceDefaults": {}, 11 | "expectVersions": {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/jsonnet/jpath/testdata/valid/jsonnetfile.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /pkg/jsonnet/lint_test.go: -------------------------------------------------------------------------------- 1 | package jsonnet 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestLint(t *testing.T) { 11 | t.Run("no error", func(t *testing.T) { 12 | opts := &LintOpts{Parallelism: 4} 13 | err := Lint([]string{"testdata/importTree"}, opts) 14 | assert.NoError(t, err) 15 | }) 16 | 17 | t.Run("error", func(t *testing.T) { 18 | buf := &bytes.Buffer{} 19 | opts := &LintOpts{Out: buf, Parallelism: 4} 20 | err := Lint([]string{"testdata/lintingError"}, opts) 21 | assert.EqualError(t, err, "Linting has failed for at least one file") 22 | assert.Equal(t, absPath(t, "testdata/lintingError/main.jsonnet")+`:1:7-22 Unused variable: unused 23 | 24 | local unused = 'test'; 25 | 26 | 27 | `, buf.String()) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/import-other-main-file/env1/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | otherMain: import '../env2/main.jsonnet', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/import-other-main-file/env2/file.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/import-other-main-file/env2/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | myfile: import 'file.libsonnet', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/chain1.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | chain: import 'chain2.libsonnet', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/chain2.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | chain: import 'lib1/main.libsonnet', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | chain: import 'chain1.libsonnet', 3 | 4 | } 5 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/local-file1.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/local-file2.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | attr1: import 'local-file1.libsonnet', 3 | attr2: import '././../imports-locals-and-vendored/./local-file2.libsonnet', 4 | vendored1: import 'vendored/main.libsonnet', 5 | } 6 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/imports-symlinked-vendor/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | symlinked: import 'vendor-symlinked/main.libsonnet', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/lib-import-relative-to-env/README.md: -------------------------------------------------------------------------------- 1 | # Explanation 2 | 3 | This tests the weird case where relative imports can be resolved either from the place where they are defined or from the main.jsonnet being run. 4 | The code has to consider all files within the environment context as being potentially imported and used by itself 5 | 6 | *Note that this is not a good practice, but Tanka's behavior should be consistent with the Jsonnet interpreter* 7 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/lib-import-relative-to-env/file-to-import.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | attribute: 'test', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/lib-import-relative-to-env/folder1/folder2/main.jsonnet: -------------------------------------------------------------------------------- 1 | local lib = import 'imports-relative-to-env/main.libsonnet'; 2 | 3 | { 4 | attribute: lib.attribute, 5 | } 6 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/no-imports/main.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/relative-import/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | // jsonnet supports going one level lower than files really are 3 | first: import '../relative-imported/main.jsonnet', 4 | second: import '../../relative-imported2/main.jsonnet', 5 | 6 | externalFile: importstr '../../other-files/test.txt', 7 | externalFile2: importstr '../../../other-files/test2.txt', 8 | } 9 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/relative-imported/main.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/relative-imported2/main.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/using-deleted-stuff/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | myimport: import 'my-import-dir/main.libsonnet', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/vendor-override-in-env/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | assert self.imported.test == 'env-vendor', 3 | imported: (import 'vendor-override-in-env/main.libsonnet'), 4 | } 5 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/environments/vendor-override-in-env/vendor/vendor-override-in-env/main.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | test: 'env-vendor', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/lib/imports-relative-to-env/main.libsonnet: -------------------------------------------------------------------------------- 1 | // See environments/lib-import-relative-to-env/README.md for more information. 2 | 3 | import '../../file-to-import.libsonnet' 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/lib/lib1/main.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | vendored: import 'vendored/main.libsonnet', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/lib/lib1/subfolder/test.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/lib/lib2/main.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/lib/unimported-lib/main.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/other-files/test.txt: -------------------------------------------------------------------------------- 1 | test1 -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/other-files/test2.txt: -------------------------------------------------------------------------------- 1 | test2 -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/tkrc.yaml: -------------------------------------------------------------------------------- 1 | # This is the root! -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/vendor/unimported-vendor/main.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/vendor/vendor-override-in-env/main.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | test: 'global-vendor', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/vendor/vendor-symlinked: -------------------------------------------------------------------------------- 1 | vendored -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/vendor/vendored/main.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | text: importstr 'text-file.txt', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/findImporters/vendor/vendored/text-file.txt: -------------------------------------------------------------------------------- 1 | I am text -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/importTree/README.md: -------------------------------------------------------------------------------- 1 | # Importing testdata 2 | 3 | This directory contains some jsonnet files importing each other, to test if the import analysis tooling works correctly. 4 | 5 | ![](test.svg) 6 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/importTree/jsonnetfile.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/importTree/main.jsonnet: -------------------------------------------------------------------------------- 1 | local trees = import 'trees.jsonnet'; 2 | 3 | // a list of trees 4 | [ 5 | trees.apple, 6 | trees.cherry, 7 | trees.peach, 8 | ] 9 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/importTree/trees.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | apple: import 'trees/apple.jsonnet', 3 | peach: import 'trees/peach.jsonnet', 4 | cherry: import 'trees/cherry.jsonnet', 5 | } 6 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/importTree/trees/apple.jsonnet: -------------------------------------------------------------------------------- 1 | local t = import 'generic.libsonnet'; 2 | t.new('apple', 'red') 3 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/importTree/trees/cherry.jsonnet: -------------------------------------------------------------------------------- 1 | local t = import 'generic.libsonnet'; 2 | t.new('cherry', 'red', 'xs') 3 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/importTree/trees/generic.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | new(breed, color, size="m"):: { 3 | kind: "tree", 4 | 5 | breed: breed, 6 | color: color, 7 | size: size, 8 | 9 | needs: "water", 10 | eats: "co2", 11 | creates: "o2", 12 | keeps: "the world healthy" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/importTree/trees/peach.jsonnet: -------------------------------------------------------------------------------- 1 | local t = import 'generic.libsonnet'; 2 | t.new('peach', 'orange', 's') 3 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/lintingError/jsonnetfile.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/lintingError/main.jsonnet: -------------------------------------------------------------------------------- 1 | local unused = 'test'; 2 | {} 3 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/thisFile/jsonnetfile.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/jsonnet/testdata/thisFile/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | test: std.thisFile, 3 | } 4 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/apply.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/Masterminds/semver" 10 | 11 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 12 | ) 13 | 14 | // Test-ability: isolate applyCtl to build and return exec.Cmd from ApplyOpts 15 | func (k Kubectl) applyCtl(_ manifest.List, opts ApplyOpts) *exec.Cmd { 16 | argv := []string{"-f", "-"} 17 | serverSide := (opts.ApplyStrategy == "server") 18 | if serverSide { 19 | argv = append(argv, "--server-side") 20 | if k.info.ClientVersion.GreaterThan(semver.MustParse("1.19.0")) { 21 | argv = append(argv, "--field-manager=tanka") 22 | } 23 | } 24 | if opts.Force { 25 | if serverSide { 26 | argv = append(argv, "--force-conflicts") 27 | } else { 28 | argv = append(argv, "--force") 29 | } 30 | } 31 | 32 | if !opts.Validate { 33 | argv = append(argv, "--validate=false") 34 | } 35 | 36 | if opts.DryRun != "" { 37 | dryRun := fmt.Sprintf("--dry-run=%s", opts.DryRun) 38 | argv = append(argv, dryRun) 39 | } 40 | 41 | return k.ctl("apply", argv...) 42 | } 43 | 44 | // Apply applies the given yaml to the cluster 45 | func (k Kubectl) Apply(data manifest.List, opts ApplyOpts) error { 46 | cmd := k.applyCtl(data, opts) 47 | 48 | cmd.Stdout = os.Stdout 49 | cmd.Stderr = os.Stderr 50 | 51 | cmd.Stdin = strings.NewReader(data.String()) 52 | 53 | return cmd.Run() 54 | } 55 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/apply_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "testing" 5 | 6 | "k8s.io/apimachinery/pkg/util/sets" 7 | 8 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 9 | ) 10 | 11 | func TestKubectl_applyCtl(t *testing.T) { 12 | info := Info{ 13 | Kubeconfig: Config{ 14 | Context: Context{ 15 | Name: "foo-context", 16 | }, 17 | }, 18 | } 19 | 20 | type args struct { 21 | data manifest.List 22 | opts ApplyOpts 23 | } 24 | tests := []struct { 25 | name string 26 | args args 27 | expectedArgs []string 28 | unExpectedArgs []string 29 | }{ 30 | { 31 | name: "test default", 32 | args: args{ 33 | opts: ApplyOpts{Validate: true}, 34 | }, 35 | expectedArgs: []string{"--context", info.Kubeconfig.Context.Name}, 36 | unExpectedArgs: []string{"--force", "--dry-run=server", "--validate=false"}, 37 | }, 38 | { 39 | name: "test force", 40 | args: args{ 41 | opts: ApplyOpts{Validate: true, Force: true}, 42 | }, 43 | expectedArgs: []string{"--force"}, 44 | unExpectedArgs: []string{"--validate=false"}, 45 | }, 46 | { 47 | name: "test validate", 48 | args: args{ 49 | opts: ApplyOpts{Validate: false}, 50 | }, 51 | expectedArgs: []string{"--validate=false"}, 52 | }, 53 | { 54 | name: "test dry-run", 55 | args: args{ 56 | opts: ApplyOpts{Validate: true, DryRun: "server"}, 57 | }, 58 | expectedArgs: []string{"--dry-run=server"}, 59 | unExpectedArgs: []string{"--validate=false"}, 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | k := Kubectl{ 66 | info: info, 67 | } 68 | got := k.applyCtl(tt.args.data, tt.args.opts) 69 | gotSet := sets.NewString(got.Args...) 70 | if !gotSet.HasAll(tt.expectedArgs...) { 71 | t.Errorf("Kubectl.applyCtl() = %v doesn't have (all) expectedArgs='%v'", got.Args, tt.expectedArgs) 72 | } 73 | if gotSet.HasAny(tt.unExpectedArgs...) { 74 | t.Errorf("Kubectl.applyCtl() = %v has (any) unExpectedArgs='%v'", got.Args, tt.unExpectedArgs) 75 | } 76 | }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 5 | ) 6 | 7 | // Client for working with Kubernetes 8 | type Client interface { 9 | // Get the specified object(s) from the cluster 10 | Get(namespace, kind, name string) (manifest.Manifest, error) 11 | GetByLabels(namespace, kind string, labels map[string]string) (manifest.List, error) 12 | GetByState(data manifest.List, opts GetByStateOpts) (manifest.List, error) 13 | 14 | // Apply the configuration to the cluster. `data` must contain a plaintext 15 | // format that is `kubectl-apply(1)` compatible 16 | Apply(data manifest.List, opts ApplyOpts) error 17 | 18 | // DiffServerSide runs the diff operation on the server and returns the 19 | // result in `diff(1)` format 20 | DiffServerSide(data manifest.List) (*string, error) 21 | 22 | // Delete the specified object(s) from the cluster 23 | Delete(namespace, apiVersion, kind, name string, opts DeleteOpts) error 24 | 25 | // Namespaces the cluster currently has 26 | Namespaces() (map[string]bool, error) 27 | 28 | // Namespace retrieves a namespace from the cluster 29 | Namespace(namespace string) (manifest.Manifest, error) 30 | 31 | // Resources returns all known api-resources of the cluster 32 | Resources() (Resources, error) 33 | 34 | // Info returns known informational data about the client. Best effort based, 35 | // fields of `Info` that cannot be stocked with valuable data, e.g. 36 | // due to an error, shall be left nil. 37 | Info() Info 38 | 39 | // Close may run tasks once the client is no longer needed. 40 | Close() error 41 | } 42 | 43 | // ApplyOpts allow to specify additional parameter for apply operations 44 | type ApplyOpts struct { 45 | // force allows to ignore checks and force the operation 46 | Force bool 47 | 48 | // validate allows to enable/disable kubectl validation 49 | Validate bool 50 | 51 | // autoApprove allows to skip the interactive approval 52 | AutoApprove bool 53 | 54 | // DryRun string passed to kubectl as --dry-run= 55 | DryRun string 56 | 57 | // ApplyStrategy to pick a final method for deploying generated objects 58 | ApplyStrategy string 59 | } 60 | 61 | // DeleteOpts allow to specify additional parameters for delete operations 62 | // Currently not different from ApplyOpts, but may be required in the future 63 | type DeleteOpts ApplyOpts 64 | 65 | // GetByStateOpts allow to specify additional parameters for GetByState function 66 | // Currently there is just ignoreNotFound parameter which is only useful for 67 | // GetByState() so we only have GetByStateOpts instead of more generic GetOpts 68 | // for all get operations 69 | type GetByStateOpts struct { 70 | // ignoreNotFound allows to ignore errors caused by missing objects 71 | IgnoreNotFound bool 72 | } 73 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/delete.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | func buildFullType(group, version, kind string) string { 14 | output := strings.Builder{} 15 | output.WriteString(kind) 16 | // Unfortunately, kubectl does not support `Type.Version` for things like 17 | // `Service` in v1. In this case, we cannot provide anything but the kind 18 | // name: 19 | if version != "" && group != "" { 20 | output.WriteString(".") 21 | output.WriteString(version) 22 | output.WriteString(".") 23 | output.WriteString(group) 24 | } 25 | return output.String() 26 | } 27 | 28 | // Test-ability: isolate deleteCtl to build and return exec.Cmd from DeleteOpts 29 | func (k Kubectl) deleteCtl(namespace, group, version, kind, name string, opts DeleteOpts) *exec.Cmd { 30 | fullType := buildFullType(group, version, kind) 31 | argv := []string{ 32 | "-n", namespace, 33 | fullType, name, 34 | } 35 | log.Debug().Str("name", name).Str("group", group).Str("version", version).Str("kind", kind).Str("namespace", namespace).Msg("Preparing to delete") 36 | if opts.Force { 37 | argv = append(argv, "--force") 38 | } 39 | 40 | if opts.DryRun != "" { 41 | dryRun := fmt.Sprintf("--dry-run=%s", opts.DryRun) 42 | argv = append(argv, dryRun) 43 | } 44 | 45 | return k.ctl("delete", argv...) 46 | } 47 | 48 | // Delete deletes the given Kubernetes resource from the cluster 49 | func (k Kubectl) Delete(namespace, apiVersion, kind, name string, opts DeleteOpts) error { 50 | apiVersionElements := strings.SplitN(apiVersion, "/", 2) 51 | if len(apiVersionElements) < 1 { 52 | return fmt.Errorf("apiVersion does not follow the group/version or version format: %s", apiVersion) 53 | } 54 | var group string 55 | var version string 56 | if len(apiVersionElements) == 1 { 57 | group = "" 58 | version = apiVersionElements[0] 59 | } else { 60 | group = apiVersionElements[0] 61 | version = apiVersionElements[1] 62 | } 63 | 64 | cmd := k.deleteCtl(namespace, group, version, kind, name, opts) 65 | 66 | var stdout bytes.Buffer 67 | var stderr bytes.Buffer 68 | 69 | cmd.Stdout = &stdout 70 | cmd.Stderr = &stderr 71 | cmd.Stdin = os.Stdin 72 | 73 | if err := cmd.Run(); err != nil { 74 | if strings.Contains(stderr.String(), "Error from server (NotFound):") { 75 | print("Delete failed: " + stderr.String()) 76 | return nil 77 | } 78 | log.Trace().Msgf("Delete failed: %s", stderr.String()) 79 | return err 80 | } 81 | if opts.DryRun != "" { 82 | print(stdout.String()) 83 | } 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/delete_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "testing" 5 | 6 | "k8s.io/apimachinery/pkg/util/sets" 7 | ) 8 | 9 | func TestKubectl_deleteCtl(t *testing.T) { 10 | info := Info{ 11 | Kubeconfig: Config{ 12 | Context: Context{ 13 | Name: "foo-context", 14 | }, 15 | }, 16 | } 17 | 18 | type args struct { 19 | ns string 20 | group string 21 | version string 22 | kind string 23 | name string 24 | opts DeleteOpts 25 | } 26 | 27 | tests := []struct { 28 | name string 29 | args args 30 | expectedArgs []string 31 | unExpectedArgs []string 32 | }{ 33 | { 34 | name: "test default", 35 | args: args{ 36 | ns: "foo-ns", 37 | group: "example.org", 38 | version: "v1", 39 | kind: "deploy", 40 | name: "foo-deploy", 41 | opts: DeleteOpts{}, 42 | }, 43 | expectedArgs: []string{"--context", info.Kubeconfig.Context.Name, "-n", "foo-ns", "deploy.v1.example.org", "foo-deploy"}, 44 | unExpectedArgs: []string{"--force", "--dry-run=server"}, 45 | }, 46 | { 47 | name: "test no apiVersion group", 48 | args: args{ 49 | ns: "foo-ns", 50 | // Since there is no group, we should also not include the version since 51 | // kubectl does not support something like `Service.v1` or 52 | // `Service.v1.core`: 53 | group: "", 54 | version: "v1", 55 | kind: "deploy", 56 | name: "foo-deploy", 57 | opts: DeleteOpts{}, 58 | }, 59 | expectedArgs: []string{"--context", info.Kubeconfig.Context.Name, "-n", "foo-ns", "deploy", "foo-deploy"}, 60 | unExpectedArgs: []string{"--force", "--dry-run=server"}, 61 | }, 62 | { 63 | name: "test dry-run", 64 | args: args{ 65 | opts: DeleteOpts{DryRun: "server"}, 66 | }, 67 | expectedArgs: []string{"--dry-run=server"}, 68 | }, 69 | { 70 | name: "test force", 71 | args: args{ 72 | opts: DeleteOpts{Force: true}, 73 | }, 74 | expectedArgs: []string{"--force"}, 75 | }, 76 | } 77 | 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | k := Kubectl{ 81 | info: info, 82 | } 83 | got := k.deleteCtl(tt.args.ns, tt.args.group, tt.args.version, tt.args.kind, tt.args.name, tt.args.opts) 84 | gotSet := sets.NewString(got.Args...) 85 | if !gotSet.HasAll(tt.expectedArgs...) { 86 | t.Errorf("Kubectl.applyCtl() = %v doesn't have (all) expectedArgs='%v'", got.Args, tt.expectedArgs) 87 | } 88 | if gotSet.HasAny(tt.unExpectedArgs...) { 89 | t.Errorf("Kubectl.applyCtl() = %v has (any) unExpectedArgs='%v'", got.Args, tt.unExpectedArgs) 90 | } 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/diff_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/Masterminds/semver" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParseDiffError(t *testing.T) { 12 | tests := map[string]struct { 13 | err error 14 | stderr string 15 | version *semver.Version 16 | expectedErr error 17 | }{ 18 | // If this is not an exit error, then we just pass it through: 19 | "no-exiterr": { 20 | err: fmt.Errorf("something else"), 21 | stderr: "error-details", 22 | version: semver.MustParse("1.17.0"), 23 | expectedErr: fmt.Errorf("something else"), 24 | }, 25 | // If kubectl returns with an exit code other than 1 its an indicator 26 | // that it is not an internal error and so we return it as is: 27 | "return-internal-as-is": { 28 | err: &dummyExitError{ 29 | exitCode: 123, 30 | }, 31 | stderr: "error-details", 32 | version: semver.MustParse("1.17.0"), 33 | expectedErr: fmt.Errorf("ExitError"), 34 | }, 35 | // If kubectl is is < 1.18.0, then the error should contain then stderr 36 | // content: 37 | "lt-1.18.0-contains-stderr": { 38 | err: &dummyExitError{ 39 | exitCode: 1, 40 | }, 41 | stderr: "error-details", 42 | version: semver.MustParse("1.17.0"), 43 | expectedErr: fmt.Errorf("diff failed: ExitError (error-details)"), 44 | }, 45 | } 46 | 47 | for testName, test := range tests { 48 | t.Run(testName, func(t *testing.T) { 49 | err := parseDiffErr(test.err, test.stderr, test.version) 50 | if test.expectedErr == nil { 51 | require.NoError(t, err) 52 | return 53 | } 54 | require.Error(t, err) 55 | require.Equal(t, test.expectedErr.Error(), err.Error()) 56 | }) 57 | } 58 | } 59 | 60 | type dummyExitError struct { 61 | exitCode int 62 | } 63 | 64 | func (e *dummyExitError) Error() string { 65 | return "ExitError" 66 | } 67 | 68 | func (e *dummyExitError) ExitCode() int { 69 | return e.exitCode 70 | } 71 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/errors.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "fmt" 4 | 5 | // ErrorNotFound means that the requested object is not found on the server 6 | type ErrorNotFound struct { 7 | errOut string 8 | } 9 | 10 | func (e ErrorNotFound) Error() string { 11 | return e.errOut 12 | } 13 | 14 | // ErrorUnknownResource means that the requested resource type is unknown to the 15 | // server 16 | type ErrorUnknownResource struct { 17 | errOut string 18 | } 19 | 20 | func (e ErrorUnknownResource) Error() string { 21 | return e.errOut 22 | } 23 | 24 | // ErrorNoContext means that the context that was searched for couldn't be found 25 | type ErrorNoContext string 26 | 27 | func (e ErrorNoContext) Error() string { 28 | return fmt.Sprintf("no context named `%s` was found. Please check your $KUBECONFIG", string(e)) 29 | } 30 | 31 | // ErrorNoCluster means that the cluster that was searched for couldn't be found 32 | type ErrorNoCluster string 33 | 34 | func (e ErrorNoCluster) Error() string { 35 | return fmt.Sprintf("no cluster that matches the apiServer `%s` was found. Please check your $KUBECONFIG", string(e)) 36 | } 37 | 38 | // ErrorNothingReturned means that there was no output returned 39 | type ErrorNothingReturned struct{} 40 | 41 | func (e ErrorNothingReturned) Error() string { 42 | return "kubectl returned no output" 43 | } 44 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/exec.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | // kubectlCmd returns command a object that will launch kubectl at an appropriate path. 10 | func kubectlCmd(args ...string) *exec.Cmd { 11 | binary := "kubectl" 12 | if env := os.Getenv("TANKA_KUBECTL_PATH"); env != "" { 13 | binary = env 14 | } 15 | 16 | return exec.Command(binary, args...) 17 | } 18 | 19 | // ctl returns an `exec.Cmd` for `kubectl`. It also forces the correct context 20 | // and injects our patched $KUBECONFIG for the default namespace. 21 | func (k Kubectl) ctl(action string, args ...string) *exec.Cmd { 22 | // prepare the arguments 23 | argv := []string{action, 24 | "--context", k.info.Kubeconfig.Context.Name, 25 | } 26 | argv = append(argv, args...) 27 | 28 | // prepare the cmd 29 | cmd := kubectlCmd(argv...) 30 | 31 | if os.Getenv("TANKA_KUBECTL_TRACE") != "" { 32 | fmt.Println(cmd.String()) 33 | } 34 | 35 | return cmd 36 | } 37 | -------------------------------------------------------------------------------- /pkg/kubernetes/client/info.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | 8 | "github.com/Masterminds/semver" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // Info contains metadata about the client and its environment 13 | type Info struct { 14 | // versions 15 | ClientVersion *semver.Version 16 | ServerVersion *semver.Version 17 | 18 | // kubeconfig (chosen context + cluster) 19 | Kubeconfig Config 20 | } 21 | 22 | // Config represents a single KUBECONFIG entry (single context + cluster) 23 | // Omits the arrays of the original schema, to ease using. 24 | type Config struct { 25 | Cluster Cluster `json:"cluster"` 26 | Context Context `json:"context"` 27 | } 28 | 29 | // Context is a kubectl context 30 | type Context struct { 31 | Context struct { 32 | Cluster string `json:"cluster"` 33 | User string `json:"user"` 34 | Namespace string `json:"namespace"` 35 | } `json:"context"` 36 | Name string `json:"name"` 37 | } 38 | 39 | // Cluster is a kubectl cluster 40 | type Cluster struct { 41 | Cluster struct { 42 | Server string `json:"server"` 43 | } `json:"cluster"` 44 | Name string `json:"name"` 45 | } 46 | 47 | // Version returns the version of kubectl and the Kubernetes api server 48 | func (k Kubectl) version() (client, server *semver.Version, err error) { 49 | cmd := k.ctl("version", "-o", "json") 50 | 51 | var buf bytes.Buffer 52 | cmd.Stdout = &buf 53 | cmd.Stderr = os.Stderr 54 | 55 | if err := cmd.Run(); err != nil { 56 | return nil, nil, err 57 | } 58 | 59 | // parse the result 60 | type ver struct { 61 | GitVersion string `json:"gitVersion"` 62 | } 63 | var got struct { 64 | ClientVersion ver `json:"clientVersion"` 65 | ServerVersion ver `json:"serverVersion"` 66 | } 67 | 68 | if err := json.Unmarshal(buf.Bytes(), &got); err != nil { 69 | return nil, nil, err 70 | } 71 | 72 | // parse the versions 73 | client, err = semver.NewVersion(got.ClientVersion.GitVersion) 74 | if err != nil { 75 | return nil, nil, errors.Wrap(err, "client version") 76 | } 77 | 78 | server, err = semver.NewVersion(got.ServerVersion.GitVersion) 79 | if err != nil { 80 | return nil, nil, errors.Wrap(err, "server version") 81 | } 82 | 83 | return client, server, nil 84 | } 85 | -------------------------------------------------------------------------------- /pkg/kubernetes/delete.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "github.com/grafana/tanka/pkg/kubernetes/client" 5 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 6 | "github.com/grafana/tanka/pkg/process" 7 | ) 8 | 9 | type DeleteOpts client.DeleteOpts 10 | 11 | func (k *Kubernetes) Delete(state manifest.List, opts DeleteOpts) error { 12 | // Sort and reverse the manifests to avoid cascading deletions 13 | process.Sort(state) 14 | for i := 0; i < len(state)/2; i++ { 15 | state[i], state[len(state)-1-i] = state[len(state)-1-i], state[i] 16 | } 17 | 18 | for _, m := range state { 19 | if err := k.ctl.Delete(m.Metadata().Namespace(), m.APIVersion(), m.Kind(), m.Metadata().Name(), client.DeleteOpts(opts)); err != nil { 20 | return err 21 | } 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/kubernetes/kubernetes.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "github.com/Masterminds/semver" 5 | 6 | "github.com/grafana/tanka/pkg/kubernetes/client" 7 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 8 | "github.com/grafana/tanka/pkg/spec/v1alpha1" 9 | ) 10 | 11 | // Kubernetes exposes methods to work with the Kubernetes orchestrator 12 | type Kubernetes struct { 13 | Env v1alpha1.Environment 14 | 15 | // Client (kubectl) 16 | ctl client.Client 17 | 18 | // Diffing 19 | differs map[string]Differ // List of diff strategies 20 | } 21 | 22 | // Differ is responsible for comparing the given manifests to the cluster and 23 | // returning differences (if any) in `diff(1)` format. 24 | type Differ func(manifest.List) (*string, error) 25 | 26 | // New creates a new Kubernetes with an initialized client 27 | func New(env v1alpha1.Environment) (*Kubernetes, error) { 28 | // setup client 29 | var ctl *client.Kubectl 30 | var err error 31 | if len(env.Spec.ContextNames) < 1 { 32 | ctl, err = client.New(env.Spec.APIServer) 33 | } else { 34 | ctl, err = client.NewFromNames(env.Spec.ContextNames) 35 | } 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | // setup diffing 41 | if env.Spec.DiffStrategy == "" { 42 | if env.Spec.ApplyStrategy == "server" { 43 | env.Spec.DiffStrategy = "server" 44 | } else { 45 | env.Spec.DiffStrategy = "native" 46 | } 47 | 48 | if ctl.Info().ServerVersion.LessThan(semver.MustParse("1.13.0")) { 49 | env.Spec.DiffStrategy = "subset" 50 | } 51 | } 52 | 53 | k := Kubernetes{ 54 | Env: env, 55 | ctl: ctl, 56 | differs: map[string]Differ{ 57 | "native": ctl.DiffClientSide, 58 | "validate": ctl.ValidateServerSide, 59 | "server": ctl.DiffServerSide, 60 | "subset": SubsetDiffer(ctl), 61 | }, 62 | } 63 | 64 | return &k, nil 65 | } 66 | 67 | // Close runs final cleanup 68 | func (k *Kubernetes) Close() error { 69 | return k.ctl.Close() 70 | } 71 | 72 | // DiffOpts allow to specify additional parameters for diff operations 73 | type DiffOpts struct { 74 | // Create a histogram of the changes instead 75 | Summarize bool 76 | // Find orphaned resources and include them in the diff 77 | WithPrune bool 78 | 79 | // Set the diff-strategy. If unset, the value set in the spec is used 80 | Strategy string 81 | } 82 | 83 | // Info about the client, etc. 84 | func (k *Kubernetes) Info() client.Info { 85 | return k.ctl.Info() 86 | } 87 | -------------------------------------------------------------------------------- /pkg/kubernetes/manifest/errors.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/fatih/color" 8 | ) 9 | 10 | // SchemaError means that some expected fields were missing 11 | type SchemaError struct { 12 | Fields map[string]error 13 | Name string 14 | Manifest Manifest 15 | } 16 | 17 | var ( 18 | redf = color.New(color.FgRed, color.Bold, color.Underline).Sprintf 19 | yellowf = color.New(color.FgYellow).Sprintf 20 | bluef = color.New(color.FgBlue, color.Bold).Sprintf 21 | ) 22 | 23 | // Error returns the fields the manifest at the path is missing 24 | func (s *SchemaError) Error() string { 25 | if s.Name == "" { 26 | s.Name = "Resource" 27 | } 28 | 29 | msg := fmt.Sprintf("%s has missing or invalid fields:\n", redf(s.Name)) 30 | 31 | for k, err := range s.Fields { 32 | if err == nil { 33 | continue 34 | } 35 | 36 | msg += fmt.Sprintf(" - %s: %s\n", yellowf(k), err) 37 | } 38 | 39 | if s.Manifest != nil { 40 | msg += bluef("\nPlease check below object:\n") 41 | msg += SampleString(s.Manifest.String()).Indent(2) 42 | } 43 | 44 | return msg 45 | } 46 | 47 | // SampleString is used for displaying code samples for error messages. It 48 | // truncates the output to 10 lines 49 | type SampleString string 50 | 51 | func (s SampleString) String() string { 52 | lines := strings.Split(strings.TrimSpace(string(s)), "\n") 53 | truncate := len(lines) >= 10 54 | if truncate { 55 | lines = lines[0:10] 56 | } 57 | out := strings.Join(lines, "\n") 58 | if truncate { 59 | out += "\n..." 60 | } 61 | return out 62 | } 63 | 64 | func (s SampleString) Indent(n int) string { 65 | indent := strings.Repeat(" ", n) 66 | lines := strings.Split(s.String(), "\n") 67 | return indent + strings.Join(lines, "\n"+indent) 68 | } 69 | 70 | // ErrorDuplicateName means two resources share the same name using the given 71 | // nameFormat. 72 | type ErrorDuplicateName struct { 73 | name string 74 | format string 75 | } 76 | 77 | func (e ErrorDuplicateName) Error() string { 78 | return fmt.Sprintf("Two resources share the same name '%s'. Please adapt the name template '%s'.", e.name, e.format) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/kubernetes/util/diff_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestDiffStat(t *testing.T) { 12 | cases := []string{ 13 | "empty", 14 | "added-and-removed", 15 | "changed-attributes", 16 | "changed-lots-of-attributes", 17 | } 18 | for _, c := range cases { 19 | t.Run(c, func(t *testing.T) { 20 | content, err := os.ReadFile("testdata/" + c + ".diff") 21 | require.NoError(t, err) 22 | expected, err := os.ReadFile("testdata/" + c + ".stat") 23 | require.NoError(t, err) 24 | 25 | got, err := DiffStat(string(content)) 26 | require.NoError(t, err) 27 | 28 | assert.Equal(t, string(expected), got) 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/kubernetes/util/testdata/added-and-removed.stat: -------------------------------------------------------------------------------- 1 | apps.v1.Deployment.bors-ng.bors-ng | 2 +- 2 | apps.v1.Deployment.bors-ng.bors-ngtest | 52 ++++++++++++++++++++++++++++++++++++++++ 3 | networking.k8s.io.v1.Ingress.bors-ng.bors-ng | 2 +- 4 | v1.Service.bors-ng.bors-ng | 16 ------------- 5 | 4 files changed, 54 insertions(+), 18 deletions(-) -------------------------------------------------------------------------------- /pkg/kubernetes/util/testdata/changed-attributes.diff: -------------------------------------------------------------------------------- 1 | diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng 2 | --- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng 2022-09-29 11:30:26.173560807 -0400 3 | +++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng 2022-09-29 11:30:26.177099345 -0400 4 | @@ -216,7 +229,7 @@ 5 | - name: DATABASE_USE_SSL 6 | value: "false" 7 | - name: DATABASE_AUTO_MIGRATE 8 | - value: "true" 9 | + value: tru 10 | - name: COMMAND_TRIGGER 11 | value: bors 12 | - name: DASHBOARD_HEADER_HTML 13 | diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 14 | --- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 2022-09-29 11:30:26.451654927 -0400 15 | +++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng 2022-09-29 11:30:26.452224214 -0400 16 | @@ -58,7 +65,7 @@ 17 | port: 18 | number: 80 19 | path: / 20 | - pathType: Prefix 21 | + pathType: Exact 22 | tls: 23 | - hosts: 24 | - bors-ng.test.net 25 | -------------------------------------------------------------------------------- /pkg/kubernetes/util/testdata/changed-attributes.stat: -------------------------------------------------------------------------------- 1 | apps.v1.Deployment.bors-ng.bors-ng | 2 +- 2 | networking.k8s.io.v1.Ingress.bors-ng.bors-ng | 2 +- 3 | 2 files changed, 2 insertions(+), 2 deletions(-) -------------------------------------------------------------------------------- /pkg/kubernetes/util/testdata/changed-lots-of-attributes.stat: -------------------------------------------------------------------------------- 1 | apps.v1.Deployment.bors-ng.bors-ng | 2 +- 2 | networking.k8s.io.v1.Ingress.bors-ng.bors-ng | 66 +++++++++++++++++++++------------------- 3 | 2 files changed, 36 insertions(+), 32 deletions(-) -------------------------------------------------------------------------------- /pkg/kubernetes/util/testdata/empty.diff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/tanka/ba6bd90f46ad1c6eb40abe27275cc418976d9bc2/pkg/kubernetes/util/testdata/empty.diff -------------------------------------------------------------------------------- /pkg/kubernetes/util/testdata/empty.stat: -------------------------------------------------------------------------------- 1 | 0 files changed, 0 insertions(+), 0 deletions(-) -------------------------------------------------------------------------------- /pkg/kustomize/build.go: -------------------------------------------------------------------------------- 1 | package kustomize 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | 8 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 9 | "github.com/pkg/errors" 10 | yaml "gopkg.in/yaml.v3" 11 | ) 12 | 13 | // Build expands a Kustomize into a regular manifest.List using the `kustomize 14 | // build` command 15 | func (k ExecKustomize) Build(path string) (manifest.List, error) { 16 | cmd := k.cmd("build", path) 17 | var buf bytes.Buffer 18 | cmd.Stdout = &buf 19 | cmd.Stderr = os.Stderr 20 | 21 | if err := cmd.Run(); err != nil { 22 | return nil, errors.Wrap(err, "Expanding Kustomize") 23 | } 24 | 25 | var list manifest.List 26 | d := yaml.NewDecoder(&buf) 27 | for { 28 | var m manifest.Manifest 29 | if err := d.Decode(&m); err != nil { 30 | if err == io.EOF { 31 | break 32 | } 33 | return nil, errors.Wrap(err, "Parsing Kustomize output") 34 | } 35 | 36 | list = append(list, m) 37 | } 38 | 39 | return list, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/kustomize/kustomize.go: -------------------------------------------------------------------------------- 1 | package kustomize 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | 7 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 8 | ) 9 | 10 | // Kustomize provides high level access to some Kustomize operations 11 | type Kustomize interface { 12 | // Build returns the individual resources of a Kustomize 13 | Build(path string) (manifest.List, error) 14 | } 15 | 16 | // ExecKustomize is a Kustomize implementation powered by the `kustomize` 17 | // command line utility 18 | type ExecKustomize struct{} 19 | 20 | // cmd returns a prepared exec.Cmd to use the `kustomize` binary 21 | func (k ExecKustomize) cmd(action string, args ...string) *exec.Cmd { 22 | argv := []string{action} 23 | argv = append(argv, args...) 24 | 25 | cmd := kustomizeCmd(argv...) 26 | cmd.Stderr = os.Stderr 27 | 28 | return cmd 29 | } 30 | 31 | // kustomizeCmd returns a bare exec.Cmd pointed at the local kustomize binary 32 | func kustomizeCmd(args ...string) *exec.Cmd { 33 | bin := "kustomize" 34 | if env := os.Getenv("TANKA_KUSTOMIZE_PATH"); env != "" { 35 | bin = env 36 | } 37 | 38 | return exec.Command(bin, args...) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/process/data_test.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "path/filepath" 7 | 8 | "github.com/grafana/tanka/pkg/jsonnet/implementations/goimpl" 9 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 10 | ) 11 | 12 | // testData holds data for tests 13 | type testData struct { 14 | Deep interface{} `json:"deep"` 15 | Flat map[string]manifest.Manifest `json:"flat"` 16 | } 17 | 18 | func loadFixture(name string) testData { 19 | filename := filepath.Join("./testdata", name) 20 | 21 | vm := goimpl.MakeRawVM([]string{"./testdata"}, nil, nil, 0) 22 | 23 | data, err := vm.EvaluateFile(filename) 24 | if err != nil { 25 | panic(fmt.Sprint("loading fixture:", err)) 26 | } 27 | 28 | var d testData 29 | if err := json.Unmarshal([]byte(data), &d); err != nil { 30 | panic(fmt.Sprint("loading fixture:", err)) 31 | } 32 | 33 | return d 34 | } 35 | 36 | // testDataRegular is a regular output of jsonnet without special things, but it 37 | // is nested. 38 | func testDataRegular() testData { 39 | return loadFixture("tdRegular.jsonnet") 40 | } 41 | 42 | // testDataFlat is a flat manifest that does not need reconciliation 43 | func testDataFlat() testData { 44 | return loadFixture("tdFlat.jsonnet") 45 | } 46 | 47 | // testDataPrimitive is an invalid manifest, because it ends with a primitive 48 | // without including required fields 49 | func testDataPrimitive() testData { 50 | return loadFixture("tdInvalidPrimitive.jsonnet") 51 | } 52 | 53 | // testBadKindType is an invalid manifest, because it has an invalid `kind` value 54 | func testBadKindType() testData { 55 | return loadFixture("tdBadKindType.jsonnet") 56 | } 57 | 58 | // testMissingAttribute is an invalid manifest, because it is missing the `kind` 59 | func testMissingAttribute() testData { 60 | return loadFixture("tdMissingAttribute.jsonnet") 61 | } 62 | 63 | // testDataDeep is super deeply nested on multiple levels 64 | func testDataDeep() testData { 65 | return loadFixture("tdDeep.jsonnet") 66 | } 67 | 68 | // testDataArray is an array of (deeply nested) dicts that should be fully 69 | // flattened 70 | func testDataArray() testData { 71 | return loadFixture("tdArray.jsonnet") 72 | } 73 | -------------------------------------------------------------------------------- /pkg/process/extract_test.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | var extractTestCases = []struct { 11 | name string 12 | data testData 13 | errMessage string 14 | }{ 15 | { 16 | name: "regular", 17 | data: testDataRegular(), 18 | }, 19 | { 20 | name: "flat", 21 | data: testDataFlat(), 22 | }, 23 | { 24 | name: "primitive", 25 | data: testDataPrimitive(), 26 | errMessage: `found invalid Kubernetes object (at .service): missing attribute "apiVersion" 27 | 28 | note: invalid because apiVersion and kind are missing 29 | `, 30 | }, 31 | { 32 | name: "missing kind", 33 | data: testMissingAttribute(), 34 | errMessage: `found invalid Kubernetes object (at .service): missing attribute "kind" 35 | 36 | apiVersion: v1 37 | spec: 38 | ports: 39 | - port: 80 40 | protocol: TCP 41 | targetPort: 8080 42 | selector: 43 | app: deep 44 | `, 45 | }, 46 | { 47 | name: "bad kind", 48 | data: testBadKindType(), 49 | errMessage: `found invalid Kubernetes object (at .deployment): attribute "kind" is not a string, it is a float64 50 | 51 | apiVersion: apps/v1 52 | kind: 3000 53 | metadata: 54 | name: grafana 55 | spec: 56 | replicas: 1 57 | template: 58 | containers: 59 | - image: grafana/grafana 60 | name: grafana 61 | metadata: 62 | labels: 63 | app: grafana 64 | `, 65 | }, 66 | { 67 | name: "deep", 68 | data: testDataDeep(), 69 | }, 70 | { 71 | name: "array", 72 | data: testDataArray(), 73 | }, 74 | { 75 | name: "nil", 76 | data: func() testData { 77 | d := testDataRegular() 78 | d.Deep.(map[string]interface{})["disabledObject"] = nil 79 | return d 80 | }(), 81 | errMessage: "", // we expect no error, just the result of testDataRegular 82 | }, 83 | } 84 | 85 | func TestExtract(t *testing.T) { 86 | for _, c := range extractTestCases { 87 | t.Run(c.name, func(t *testing.T) { 88 | extracted, err := Extract(c.data.Deep) 89 | 90 | if c.errMessage != "" { 91 | require.Error(t, err) 92 | assert.Equal(t, c.errMessage, err.Error()) 93 | return 94 | } 95 | 96 | require.NoError(t, err) 97 | assert.EqualValues(t, c.data.Flat, extracted) 98 | }) 99 | } 100 | } 101 | 102 | func BenchmarkExtract(b *testing.B) { 103 | for _, c := range extractTestCases { 104 | if c.errMessage != "" { 105 | continue 106 | } 107 | b.Run(c.name, func(b *testing.B) { 108 | for i := 0; i < b.N; i++ { 109 | // nolint:errcheck 110 | Extract(c.data.Deep) 111 | } 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pkg/process/namespace.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 5 | ) 6 | 7 | const ( 8 | // AnnotationNamespaced can be set on any resource to override the decision 9 | // whether 'metadata.namespace' is set by Tanka 10 | AnnotationNamespaced = MetadataPrefix + "/namespaced" 11 | ) 12 | 13 | // This is a list of "cluster-wide" resources harvested from `kubectl api-resources --namespaced=false` 14 | // This helps us to know which objects we should NOT apply namespaces to automatically. 15 | // We can add to this list periodically if new types are added. 16 | // This only applies to built-in kubernetes types. CRDs will need to be handled with annotations. 17 | var clusterWideKinds = map[string]bool{ 18 | "APIService": true, 19 | "CertificateSigningRequest": true, 20 | "ClusterRole": true, 21 | "ClusterRoleBinding": true, 22 | "ComponentStatus": true, 23 | "CSIDriver": true, 24 | "CSINode": true, 25 | "CustomResourceDefinition": true, 26 | "MutatingWebhookConfiguration": true, 27 | "Namespace": true, 28 | "Node": true, 29 | "NodeMetrics": true, 30 | "PersistentVolume": true, 31 | "PodSecurityPolicy": true, 32 | "PriorityClass": true, 33 | "RuntimeClass": true, 34 | "SelfSubjectAccessReview": true, 35 | "SelfSubjectRulesReview": true, 36 | "StorageClass": true, 37 | "SubjectAccessReview": true, 38 | "TokenReview": true, 39 | "ValidatingWebhookConfiguration": true, 40 | "VolumeAttachment": true, 41 | } 42 | 43 | // Namespace injects the default namespace of the environment into each 44 | // resources, that does not already define one. AnnotationNamespaced can be used 45 | // to disable this per resource 46 | func Namespace(list manifest.List, def string) manifest.List { 47 | if def == "" { 48 | return list 49 | } 50 | 51 | for i, m := range list { 52 | namespaced := true 53 | if clusterWideKinds[m.Kind()] { 54 | namespaced = false 55 | } 56 | // check for annotation override 57 | if s, ok := m.Metadata().Annotations()[AnnotationNamespaced]; ok { 58 | namespaced = s == "true" 59 | } 60 | 61 | if namespaced && !m.Metadata().HasNamespace() { 62 | m.Metadata()["namespace"] = def 63 | } 64 | 65 | // remove annotations if empty (we always create those by accessing them) 66 | if len(m.Metadata().Annotations()) == 0 { 67 | delete(m.Metadata(), "annotations") 68 | } 69 | 70 | list[i] = m 71 | } 72 | 73 | return list 74 | } 75 | -------------------------------------------------------------------------------- /pkg/process/namespace_test.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 8 | ) 9 | 10 | func TestNamespace(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | namespace string 14 | before, after manifest.Manifest 15 | }{ 16 | // resource without a namespace: set it 17 | { 18 | name: "simple/namespaced", 19 | namespace: "testing", 20 | before: manifest.Manifest{ 21 | "kind": "Deployment", 22 | }, 23 | after: manifest.Manifest{ 24 | "kind": "Deployment", 25 | "metadata": map[string]interface{}{ 26 | "namespace": "testing", 27 | }, 28 | }, 29 | }, 30 | 31 | // resource with a namespace: ignore it 32 | { 33 | name: "already-present", 34 | namespace: "ignored", 35 | before: manifest.Manifest{ 36 | "kind": "Deployment", 37 | "metadata": map[string]interface{}{"namespace": "mycoolnamespace"}, 38 | }, 39 | after: manifest.Manifest{ 40 | "kind": "Deployment", 41 | "metadata": map[string]interface{}{"namespace": "mycoolnamespace"}, 42 | }, 43 | }, 44 | 45 | // empty default ns: do nothing 46 | { 47 | name: "no-default", 48 | namespace: "", 49 | before: manifest.Manifest{ 50 | "kind": "Deployment", 51 | "metadata": map[string]interface{}{}, 52 | }, 53 | after: manifest.Manifest{ 54 | "kind": "Deployment", 55 | "metadata": map[string]interface{}{}, 56 | }, 57 | }, 58 | } 59 | 60 | for _, c := range cases { 61 | t.Run(c.name, func(t *testing.T) { 62 | result := Namespace(manifest.List{c.before}, c.namespace) 63 | 64 | if diff := cmp.Diff(manifest.List{c.after}, result); diff != "" { 65 | t.Error(diff) 66 | } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /pkg/process/sort.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 7 | ) 8 | 9 | // Order in which install different kinds of Kubernetes objects. 10 | // Inspired by https://github.com/helm/helm/blob/8c84a0bc0376650bc3d7334eef0c46356c22fa36/pkg/releaseutil/kind_sorter.go 11 | var kindOrder = []string{ 12 | "Namespace", 13 | "NetworkPolicy", 14 | "ResourceQuota", 15 | "LimitRange", 16 | "PodSecurityPolicy", 17 | "PodDisruptionBudget", 18 | "ServiceAccount", 19 | "Secret", 20 | "ConfigMap", 21 | "StorageClass", 22 | "PersistentVolume", 23 | "PersistentVolumeClaim", 24 | "CustomResourceDefinition", 25 | "ClusterRole", 26 | "ClusterRoleList", 27 | "ClusterRoleBinding", 28 | "ClusterRoleBindingList", 29 | "Role", 30 | "RoleList", 31 | "RoleBinding", 32 | "RoleBindingList", 33 | "Service", 34 | "DaemonSet", 35 | "Pod", 36 | "ReplicationController", 37 | "ReplicaSet", 38 | "Deployment", 39 | "HorizontalPodAutoscaler", 40 | "StatefulSet", 41 | "Job", 42 | "CronJob", 43 | "Ingress", 44 | "APIService", 45 | } 46 | 47 | // Sort orders manifests in a stable order, taking order-dependencies of these 48 | // into consideration. This is best-effort based: 49 | // - Use the static kindOrder list if possible 50 | // - Sort alphabetically by kind otherwise 51 | // - If kind equal, sort alphabetically by name 52 | func Sort(list manifest.List) { 53 | sort.SliceStable(list, func(i int, j int) bool { 54 | var io, jo int 55 | 56 | // anything that is not in kindOrder will get to the end of the install list. 57 | for io = 0; io < len(kindOrder); io++ { 58 | if list[i].Kind() == kindOrder[io] { 59 | break 60 | } 61 | } 62 | 63 | for jo = 0; jo < len(kindOrder); jo++ { 64 | if list[j].Kind() == kindOrder[jo] { 65 | break 66 | } 67 | } 68 | 69 | // If Kind of both objects are at different indexes of kindOrder, sort by them 70 | if io != jo { 71 | return io < jo 72 | } 73 | 74 | // If the Kinds themselves are different (e.g. both of the Kinds are not in 75 | // the kindOrder), order alphabetically. 76 | if list[i].Kind() != list[j].Kind() { 77 | return list[i].Kind() < list[j].Kind() 78 | } 79 | 80 | // If namespaces differ, sort by the namespace. 81 | if list[i].Metadata().Namespace() != list[j].Metadata().Namespace() { 82 | return list[i].Metadata().Namespace() < list[j].Metadata().Namespace() 83 | } 84 | 85 | if list[i].Metadata().Name() != list[j].Metadata().Name() { 86 | return list[i].Metadata().Name() < list[j].Metadata().Name() 87 | } 88 | 89 | return list[i].Metadata().GenerateName() < list[j].Metadata().GenerateName() 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /pkg/process/testdata/k8s.libsonnet: -------------------------------------------------------------------------------- 1 | // a very basic ripoff from `k.libsonnet`, because we can't vendor in tests 2 | 3 | { 4 | deployment(name='grafana', image='grafana/grafana'):: { 5 | apiVersion: 'apps/v1', 6 | kind: 'Deployment', 7 | metadata: { name: name }, 8 | spec: { 9 | replicas: 1, 10 | template: { 11 | containers: [{ 12 | name: name, 13 | image: image, 14 | }], 15 | metadata: { labels: { app: name } }, 16 | }, 17 | }, 18 | }, 19 | service(name='grafana', image='grafana/grafana'):: { 20 | apiVersion: 'v1', 21 | kind: 'Service', 22 | metadata: { name: name }, 23 | spec: { 24 | selector: { app: name }, 25 | ports: [{ 26 | name: name, 27 | port: 3000, 28 | targetPort: 3000, 29 | }], 30 | }, 31 | }, 32 | namespace(name='default'):: { 33 | apiVersion: 'v1', 34 | kind: 'Namespace', 35 | metadata: { name: name }, 36 | }, 37 | list(items, kind=""):: { 38 | apiVersion: "v1", 39 | kind: "%sList" % kind, 40 | items: items, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pkg/process/testdata/resources.jsonnet: -------------------------------------------------------------------------------- 1 | local k = (import './k8s.libsonnet'); 2 | { 3 | deployment: k.deployment(), 4 | service: k.service(), 5 | namespace: k.namespace(), 6 | } 7 | -------------------------------------------------------------------------------- /pkg/process/testdata/tdArray.jsonnet: -------------------------------------------------------------------------------- 1 | local utils = import './utils.libsonnet'; 2 | 3 | local deep = import './tdDeep.jsonnet'; 4 | local flat = import './tdFlat.jsonnet'; 5 | 6 | { 7 | deep: [deep.deep, [flat.deep]], 8 | flat: utils.indexify(deep.flat, [0]) + 9 | utils.indexify(flat.flat, [1, 0]), 10 | } 11 | -------------------------------------------------------------------------------- /pkg/process/testdata/tdBadKindType.jsonnet: -------------------------------------------------------------------------------- 1 | local deployment = (import './resources.jsonnet').deployment; 2 | 3 | { 4 | deep: { 5 | deployment: deployment { 6 | kind: 3000, 7 | }, 8 | }, 9 | flat: { 10 | '.deployment': deployment, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /pkg/process/testdata/tdDeep.jsonnet: -------------------------------------------------------------------------------- 1 | local backendDeploy = (import './resources.jsonnet').deployment; 2 | local namespace = (import './resources.jsonnet').namespace; 3 | local frontendService = (import './k8s.libsonnet').service(name='frontend'); 4 | local frontendDeploy = (import './k8s.libsonnet').deployment(name='frontend'); 5 | 6 | { 7 | deep: { 8 | app: { 9 | namespace: namespace, 10 | web: { 11 | backend: { server: { grafana: { 12 | deployment: backendDeploy, 13 | } } }, 14 | frontend: { nodejs: { express: { 15 | service: frontendService, 16 | deployment: frontendDeploy, 17 | } } }, 18 | }, 19 | }, 20 | }, 21 | flat: { 22 | '.app.web.backend.server.grafana.deployment': backendDeploy, 23 | '.app.web.frontend.nodejs.express.service': frontendService, 24 | '.app.web.frontend.nodejs.express.deployment': frontendDeploy, 25 | '.app.namespace': namespace, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /pkg/process/testdata/tdFlat.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | deep: (import './resources.jsonnet').deployment, 3 | flat: { 4 | '.': $.deep, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /pkg/process/testdata/tdInvalidPrimitive.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | deep: { 3 | deploy: (import './resources.jsonnet').deployment, 4 | service: { 5 | note: 'invalid because apiVersion and kind are missing', 6 | }, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /pkg/process/testdata/tdList.jsonnet: -------------------------------------------------------------------------------- 1 | local k = (import "./k8s.libsonnet"); 2 | 3 | local deployment = (import './resources.jsonnet').deployment; 4 | local service = (import './resources.jsonnet').service; 5 | 6 | // NOTE: This testdata also needs Unwrap() in addition to Process() 7 | { 8 | deep: { 9 | foo: k.list([deployment, service]), 10 | }, 11 | flat: { 12 | "foo.items[0]": deployment, 13 | "foo.items[1]": service, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /pkg/process/testdata/tdMissingAttribute.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | deep: { 3 | deploy: (import './resources.jsonnet').deployment, 4 | service: { 5 | // Missing kind 6 | apiVersion: 'v1', 7 | spec: { 8 | selector: { 9 | app: 'deep', 10 | }, 11 | ports: [ 12 | { 13 | protocol: 'TCP', 14 | port: 80, 15 | targetPort: 8080, 16 | }, 17 | ], 18 | }, 19 | }, 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /pkg/process/testdata/tdRegular.jsonnet: -------------------------------------------------------------------------------- 1 | local deployment = (import './resources.jsonnet').deployment; 2 | local service = (import './resources.jsonnet').service; 3 | 4 | { 5 | deep: { 6 | deployment: deployment, 7 | service: service, 8 | }, 9 | flat: { 10 | '.deployment': deployment, 11 | '.service': service, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /pkg/process/testdata/utils.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | mkIndex(arr):: 3 | local idxs = ['[%s]' % i for i in arr]; 4 | '.' + std.join('.', idxs), 5 | 6 | mkKey(k):: if k == '.' then '' else k, 7 | 8 | indexify(obj, index):: { 9 | [$.mkIndex(index) + $.mkKey(key)]: obj[key] 10 | for key in std.objectFields(obj) 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /pkg/spec/depreciations_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/grafana/tanka/pkg/spec/v1alpha1" 10 | ) 11 | 12 | // TestDeprecated checks that deprecated fields are still respected, but can be 13 | // overwritten by the newer format. 14 | func TestDeprecated(t *testing.T) { 15 | data := []byte(` 16 | { 17 | "metadata": { 18 | "name": "test" 19 | }, 20 | "spec": { 21 | "namespace": "new" 22 | }, 23 | "server": "https://127.0.0.1", 24 | "team": "cool", 25 | "namespace": "old" 26 | } 27 | `) 28 | 29 | got, err := Parse(data, "test") 30 | require.Equal(t, ErrDeprecated{ 31 | {old: "server", new: "spec.apiServer"}, 32 | {old: "team", new: "metadata.labels.team"}, 33 | }, err) 34 | 35 | want := v1alpha1.New() 36 | want.Spec.APIServer = "https://127.0.0.1" 37 | want.Spec.Namespace = "new" 38 | want.Metadata.Labels["team"] = "cool" 39 | want.Metadata.Name = "test" 40 | 41 | assert.Equal(t, want, got) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/spec/errors.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "fmt" 4 | 5 | type depreciation struct { 6 | old, new string 7 | } 8 | 9 | // ErrDeprecated is a non-fatal error that occurs when deprecated fields are 10 | // used in the spec.json 11 | type ErrDeprecated []depreciation 12 | 13 | func (e ErrDeprecated) Error() string { 14 | buf := "" 15 | for _, d := range e { 16 | buf += fmt.Sprintf("Warning: `%s` is deprecated, use `%s` instead.\n", d.old, d.new) 17 | } 18 | return buf 19 | } 20 | 21 | // ErrMistypedField occurs that the field of the given name has the wrong type 22 | type ErrMistypedField struct { 23 | name string 24 | t interface{} 25 | } 26 | 27 | func (e ErrMistypedField) Error() string { 28 | return fmt.Sprintf("`%s` is of type %T but should be string", e.name, e.t) 29 | } 30 | 31 | // ErrNoSpec means that the given directory has no spec.json 32 | // This must not be fatal, some operations work without 33 | type ErrNoSpec struct { 34 | name string 35 | } 36 | 37 | func (e ErrNoSpec) Error() string { 38 | return fmt.Sprintf("unable to find a spec.json for environment `%s`.\nRefer to https://tanka.dev/directory-structure#environments for instructions", e.name) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/spec/v1alpha1/reflect_utils.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func getDeepFieldAsString(obj interface{}, keyPath []string) (string, error) { 11 | if !isSupportedType(obj, []reflect.Kind{reflect.Struct, reflect.Pointer, reflect.Map}) { 12 | return "", errors.New("intermediary objects must be object types") 13 | } 14 | 15 | objValue := reflectValue(obj) 16 | objType := objValue.Type() 17 | 18 | var nextFieldValue reflect.Value 19 | 20 | switch objType.Kind() { 21 | case reflect.Struct, reflect.Pointer: 22 | fieldsCount := objType.NumField() 23 | 24 | for i := 0; i < fieldsCount; i++ { 25 | candidateType := objType.Field(i) 26 | candidateValue := objValue.Field(i) 27 | jsonTag := candidateType.Tag.Get("json") 28 | 29 | if strings.Split(jsonTag, ",")[0] == keyPath[0] { 30 | nextFieldValue = candidateValue 31 | break 32 | } 33 | } 34 | 35 | case reflect.Map: 36 | for _, key := range objValue.MapKeys() { 37 | nextFieldValue = objValue.MapIndex(key) 38 | } 39 | } 40 | 41 | if len(keyPath) == 1 { 42 | return getReflectValueAsString(nextFieldValue) 43 | } 44 | 45 | if nextFieldValue.Type().Kind() == reflect.Pointer { 46 | nextFieldValue = nextFieldValue.Elem() 47 | } 48 | 49 | return getDeepFieldAsString(nextFieldValue.Interface(), keyPath[1:]) 50 | } 51 | 52 | func getReflectValueAsString(val reflect.Value) (string, error) { 53 | switch val.Type().Kind() { 54 | case reflect.String: 55 | return val.String(), nil 56 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 57 | return strconv.FormatInt(val.Int(), 10), nil 58 | case reflect.Float32: 59 | return strconv.FormatFloat(val.Float(), 'f', -1, 32), nil 60 | case reflect.Float64: 61 | return strconv.FormatFloat(val.Float(), 'f', -1, 64), nil 62 | case reflect.Bool: 63 | return strconv.FormatBool(val.Bool()), nil 64 | default: 65 | return "", errors.New("unsupported value type") 66 | } 67 | } 68 | 69 | func reflectValue(obj interface{}) reflect.Value { 70 | var val reflect.Value 71 | 72 | if reflect.TypeOf(obj).Kind() == reflect.Pointer { 73 | val = reflect.ValueOf(obj).Elem() 74 | } else { 75 | val = reflect.ValueOf(obj) 76 | } 77 | 78 | return val 79 | } 80 | 81 | func isSupportedType(obj interface{}, types []reflect.Kind) bool { 82 | for _, t := range types { 83 | if reflect.TypeOf(obj).Kind() == t { 84 | return true 85 | } 86 | } 87 | 88 | return false 89 | } 90 | -------------------------------------------------------------------------------- /pkg/tanka/errors.go: -------------------------------------------------------------------------------- 1 | package tanka 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // ErrNoEnv means that the given jsonnet has no Environment object 9 | // This must not be fatal, some operations work without 10 | type ErrNoEnv struct { 11 | path string 12 | } 13 | 14 | func (e ErrNoEnv) Error() string { 15 | return fmt.Sprintf("unable to find an Environment in '%s'", e.path) 16 | } 17 | 18 | // ErrMultipleEnvs means that the given jsonnet has multiple Environment objects 19 | type ErrMultipleEnvs struct { 20 | path string 21 | givenName string 22 | foundEnvs []string 23 | } 24 | 25 | func (e ErrMultipleEnvs) Error() string { 26 | if e.givenName != "" { 27 | return fmt.Sprintf("found multiple Environments in %q matching %q. Provide a more specific name that matches a single one: \n - %s", e.path, e.givenName, strings.Join(e.foundEnvs, "\n - ")) 28 | } 29 | 30 | return fmt.Sprintf("found multiple Environments in %q. Use `--name` to select a single one: \n - %s", e.path, strings.Join(e.foundEnvs, "\n - ")) 31 | } 32 | 33 | // ErrParallel is an array of errors collected while processing in parallel 34 | type ErrParallel struct { 35 | errors []error 36 | } 37 | 38 | func (e ErrParallel) Error() string { 39 | returnErr := "Errors occurred during parallel processing:\n\n" 40 | for _, err := range e.errors { 41 | returnErr = fmt.Sprintf("%s- %s\n\n", returnErr, err.Error()) 42 | } 43 | return returnErr 44 | } 45 | -------------------------------------------------------------------------------- /pkg/tanka/format.go: -------------------------------------------------------------------------------- 1 | package tanka 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/gobwas/glob" 8 | "github.com/google/go-jsonnet/formatter" 9 | "github.com/grafana/tanka/pkg/jsonnet" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // FormatOpts modify the behaviour of Format 14 | type FormatOpts struct { 15 | // Excludes are a list of globs to exclude files while searching for Jsonnet 16 | // files 17 | Excludes []glob.Glob 18 | 19 | // OutFn receives the formatted file and it's name. If left nil, the file 20 | // will be formatted in place. 21 | OutFn OutFn 22 | 23 | // PrintNames causes all filenames to be printed 24 | PrintNames bool 25 | } 26 | 27 | // OutFn is a function that receives the formatted file for further action, 28 | // like persisting to disc 29 | type OutFn func(name, content string) error 30 | 31 | // FormatFiles takes a list of files and directories, processes them and returns 32 | // which files were formatted and perhaps an error. 33 | func FormatFiles(fds []string, opts *FormatOpts) ([]string, error) { 34 | var paths []string 35 | for _, f := range fds { 36 | fs, err := jsonnet.FindFiles(f, opts.Excludes) 37 | if err != nil { 38 | return nil, errors.Wrap(err, "finding Jsonnet files") 39 | } 40 | paths = append(paths, fs...) 41 | } 42 | 43 | // if nothing defined, default to save inplace 44 | outFn := opts.OutFn 45 | if outFn == nil { 46 | outFn = func(name, content string) error { 47 | return os.WriteFile(name, []byte(content), 0644) 48 | } 49 | } 50 | 51 | // print each file? 52 | printFn := func(...interface{}) {} 53 | if opts.PrintNames { 54 | printFn = func(i ...interface{}) { fmt.Println(i...) } 55 | } 56 | 57 | var changed []string 58 | for _, p := range paths { 59 | content, err := os.ReadFile(p) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | formatted, err := Format(p, string(content)) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | if string(content) != formatted { 70 | printFn("fmt", p) 71 | changed = append(changed, p) 72 | } else { 73 | printFn("ok ", p) 74 | } 75 | 76 | if err := outFn(p, formatted); err != nil { 77 | return nil, err 78 | } 79 | } 80 | 81 | return changed, nil 82 | } 83 | 84 | // Format takes a file's name and contents and returns them in properly 85 | // formatted. The file does not have to exist on disk. 86 | func Format(filename string, content string) (string, error) { 87 | return formatter.Format(filename, content, formatter.DefaultOptions()) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/tanka/format_test.go: -------------------------------------------------------------------------------- 1 | package tanka 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gobwas/glob" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestFormatFiles_IgnoresVendor(t *testing.T) { 12 | var files []string 13 | _, err := FormatFiles([]string{"./testdata/cases/format/"}, &FormatOpts{ 14 | Excludes: []glob.Glob{glob.MustCompile("**/vendor/**")}, 15 | OutFn: func(f, _ string) error { 16 | files = append(files, f) 17 | return nil 18 | }, 19 | 20 | PrintNames: false, 21 | }) 22 | 23 | require.NoError(t, err) 24 | assert.Contains(t, files, "testdata/cases/format/a.jsonnet") 25 | assert.Contains(t, files, "testdata/cases/format/b.libsonnet") 26 | assert.Contains(t, files, "testdata/cases/format/foo/a.jsonnet") 27 | assert.Contains(t, files, "testdata/cases/format/foo/b.libsonnet") 28 | assert.NotContains(t, files, "testdata/cases/format/vendor/a.jsonnet") 29 | assert.NotContains(t, files, "testdata/cases/format/vendor/b.libsonnet") 30 | } 31 | -------------------------------------------------------------------------------- /pkg/tanka/prune.go: -------------------------------------------------------------------------------- 1 | package tanka 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | 9 | "github.com/grafana/tanka/pkg/kubernetes" 10 | "github.com/grafana/tanka/pkg/term" 11 | ) 12 | 13 | // PruneOpts specify additional properties for the Prune action 14 | type PruneOpts struct { 15 | ApplyBaseOpts 16 | } 17 | 18 | // Prune deletes all resources from the cluster, that are no longer present in 19 | // Jsonnet. It uses the `tanka.dev/environment` label to identify those. 20 | func Prune(baseDir string, opts PruneOpts) error { 21 | // parse jsonnet, init k8s client 22 | p, err := Load(baseDir, opts.Opts) 23 | if err != nil { 24 | return err 25 | } 26 | kube, err := p.Connect() 27 | if err != nil { 28 | return err 29 | } 30 | defer kube.Close() 31 | 32 | // find orphaned resources 33 | orphaned, err := kube.Orphaned(p.Resources) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if len(orphaned) == 0 { 39 | fmt.Fprintln(os.Stderr, "Nothing found to prune.") 40 | return nil 41 | } 42 | 43 | // print diff 44 | diff, err := kubernetes.StaticDiffer(false)(orphaned) 45 | if err != nil { 46 | // static diff can't fail normally, so unlike in apply, this is fatal 47 | // here 48 | return err 49 | } 50 | fmt.Print(term.Colordiff(*diff).String()) 51 | 52 | // print namespace removal warning 53 | namespaces := []string{} 54 | for _, obj := range orphaned { 55 | if obj.Kind() == "Namespace" { 56 | namespaces = append(namespaces, obj.Metadata().Name()) 57 | } 58 | } 59 | if len(namespaces) > 0 { 60 | warning := color.New(color.FgHiYellow, color.Bold).FprintfFunc() 61 | warning(color.Error, "WARNING: This will delete following namespaces and all resources in them:\n") 62 | for _, ns := range namespaces { 63 | fmt.Fprintf(os.Stderr, " - %s\n", ns) 64 | } 65 | fmt.Fprintln(os.Stderr, "") 66 | } 67 | 68 | // prompt for confirm 69 | if opts.AutoApprove != AutoApproveAlways { 70 | if err := confirmPrompt("Pruning from", p.Env.Spec.Namespace, kube.Info()); err != nil { 71 | return err 72 | } 73 | } 74 | 75 | // delete resources 76 | return kube.Delete(orphaned, kubernetes.DeleteOpts{ 77 | Force: opts.Force, 78 | DryRun: opts.DryRun, 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/tanka/status.go: -------------------------------------------------------------------------------- 1 | package tanka 2 | 3 | import ( 4 | "github.com/grafana/tanka/pkg/kubernetes/client" 5 | "github.com/grafana/tanka/pkg/kubernetes/manifest" 6 | "github.com/grafana/tanka/pkg/spec/v1alpha1" 7 | ) 8 | 9 | // Info holds information about a particular environment, including its Config, 10 | // the individual resources of the desired state and also the status of the 11 | // client. 12 | type Info struct { 13 | Env *v1alpha1.Environment 14 | Resources manifest.List 15 | Client client.Info 16 | } 17 | 18 | // Status returns information about the particular environment 19 | func Status(baseDir string, opts Opts) (*Info, error) { 20 | r, err := Load(baseDir, opts) 21 | if err != nil { 22 | return nil, err 23 | } 24 | kube, err := r.Connect() 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | r.Env.Spec.DiffStrategy = kube.Env.Spec.DiffStrategy 30 | 31 | return &Info{ 32 | Env: r.Env, 33 | Resources: r.Resources, 34 | Client: kube.Info(), 35 | }, nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/tanka/tanka.go: -------------------------------------------------------------------------------- 1 | // Package tanka allows to use most of Tanka's features available on the 2 | // command line programmatically as a Golang library. Keep in mind that the API 3 | // is still experimental and may change without and signs of warnings while 4 | // Tanka is still in alpha. Nevertheless, we try to avoid breaking changes. 5 | package tanka 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/Masterminds/semver" 11 | 12 | "github.com/grafana/tanka/pkg/jsonnet" 13 | "github.com/grafana/tanka/pkg/process" 14 | ) 15 | 16 | type JsonnetOpts = jsonnet.Opts 17 | 18 | // Opts specify general, optional properties that apply to all actions 19 | type Opts struct { 20 | JsonnetOpts 21 | JsonnetImplementation string 22 | 23 | // Filters are used to optionally select a subset of the resources 24 | Filters process.Matchers 25 | 26 | // Name is used to extract a single environment from multiple environments 27 | Name string 28 | } 29 | 30 | // defaultDevVersion is the placeholder version used when no actual semver is 31 | // provided using ldflags 32 | const defaultDevVersion = "dev" 33 | 34 | // CurrentVersion is the current version of the running Tanka code 35 | var CurrentVersion = defaultDevVersion 36 | 37 | func checkVersion(constraint string) error { 38 | if constraint == "" { 39 | return nil 40 | } 41 | if CurrentVersion == defaultDevVersion { 42 | return nil 43 | } 44 | 45 | c, err := semver.NewConstraint(constraint) 46 | if err != nil { 47 | return fmt.Errorf("parsing version constraint: '%w'. Please check 'spec.expectVersions.tanka'", err) 48 | } 49 | 50 | v, err := semver.NewVersion(CurrentVersion) 51 | if err != nil { 52 | return fmt.Errorf("'%s' is not a valid semantic version: '%w'.\nThis likely means your build of Tanka is broken, as this is a compile-time value. When in doubt, please raise an issue", CurrentVersion, err) 53 | } 54 | 55 | if !c.Check(v) { 56 | return fmt.Errorf("current version '%s' does not satisfy the version required by the environment: '%s'. You likely need to use another version of Tanka", CurrentVersion, constraint) 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/array/main.jsonnet: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | { 4 | testCase: 'nestedArray[0][0]', 5 | }, 6 | { 7 | testCase: 'nestedArray[0][1]', 8 | }, 9 | ], 10 | [ 11 | { 12 | testCase: 'nestedArray[1][0]', 13 | }, 14 | { 15 | testCase: 'nestedArray[1][1]', 16 | }, 17 | ], 18 | ] 19 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/format/a.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/format/b.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/format/foo/a.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/format/foo/b.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/format/vendor/a.jsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/format/vendor/b.libsonnet: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/function-with-zero-params/main.jsonnet: -------------------------------------------------------------------------------- 1 | function() { 2 | apiVersion: 'tanka.dev/v1alpha1', 3 | kind: 'Environment', 4 | metadata: { 5 | name: 'inline', 6 | }, 7 | spec: { 8 | apiServer: 'https://localhost', 9 | namespace: 'inline', 10 | }, 11 | data: { 12 | apiVersion: 'v1', 13 | kind: 'ConfigMap', 14 | metadata: { name: 'config' }, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/inline-name-conflict/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | base_env: { 3 | apiVersion: 'tanka.dev/v1alpha1', 4 | kind: 'Environment', 5 | metadata: { 6 | name: 'base', 7 | }, 8 | spec: { 9 | apiServer: 'https://localhost', 10 | namespace: 'base', 11 | }, 12 | data: { 13 | apiVersion: 'v1', 14 | kind: 'ConfigMap', 15 | metadata: { name: 'config' }, 16 | }, 17 | }, 18 | other_env: { 19 | apiVersion: 'tanka.dev/v1alpha1', 20 | kind: 'Environment', 21 | metadata: { 22 | name: 'base-and-more', 23 | }, 24 | spec: { 25 | apiServer: 'https://localhost', 26 | namespace: 'base-and-more', 27 | }, 28 | data: { 29 | apiVersion: 'v1', 30 | kind: 'ConfigMap', 31 | metadata: { name: 'config' }, 32 | }, 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/multiple-inline-envs/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | project1_env1: { 3 | apiVersion: 'tanka.dev/v1alpha1', 4 | kind: 'Environment', 5 | metadata: { 6 | name: 'project1-env1', 7 | }, 8 | spec: { 9 | apiServer: 'https://localhost', 10 | namespace: 'project1-env1', 11 | }, 12 | data: { 13 | apiVersion: 'v1', 14 | kind: 'ConfigMap', 15 | metadata: { name: 'config' }, 16 | }, 17 | }, 18 | project1_env2: { 19 | apiVersion: 'tanka.dev/v1alpha1', 20 | kind: 'Environment', 21 | metadata: { 22 | name: 'project1-env2', 23 | }, 24 | spec: { 25 | apiServer: 'https://localhost', 26 | namespace: 'project1-env2', 27 | }, 28 | data: { 29 | apiVersion: 'v1', 30 | kind: 'ConfigMap', 31 | metadata: { name: 'config' }, 32 | }, 33 | }, 34 | project2_env1: { 35 | apiVersion: 'tanka.dev/v1alpha1', 36 | kind: 'Environment', 37 | metadata: { 38 | name: 'project2-env1', 39 | }, 40 | spec: { 41 | apiServer: 'https://localhost', 42 | namespace: 'project2-env1', 43 | }, 44 | data: { 45 | apiVersion: 'v1', 46 | kind: 'ConfigMap', 47 | metadata: { name: 'config' }, 48 | }, 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/object/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | testCase: 'object', 3 | } 4 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/static-and-inline/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | apiVersion: 'tanka.dev/v1alpha1', 3 | kind: 'Environment', 4 | metadata: { 5 | name: 'inline', 6 | }, 7 | spec: { 8 | apiServer: 'https://localhost', 9 | namespace: 'inline', 10 | }, 11 | data: { 12 | apiVersion: 'v1', 13 | kind: 'ConfigMap', 14 | metadata: { name: 'config' }, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/static-and-inline/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "static" 6 | }, 7 | "spec": { 8 | "apiServer": "https://localhost", 9 | "namespace": "static" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/with-optional-tlas/main.jsonnet: -------------------------------------------------------------------------------- 1 | function(bar='bar', baz='baz') { 2 | apiVersion: 'tanka.dev/v1alpha1', 3 | kind: 'Environment', 4 | metadata: { 5 | name: bar + '-' + baz, 6 | }, 7 | spec: { 8 | apiServer: 'https://localhost', 9 | namespace: 'inline', 10 | }, 11 | data: { 12 | apiVersion: 'v1', 13 | kind: 'ConfigMap', 14 | metadata: { name: 'config' }, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/withenv/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | apiVersion: 'tanka.dev/v1alpha1', 3 | kind: 'Environment', 4 | metadata: { 5 | name: 'withenv', 6 | }, 7 | spec: { 8 | apiServer: 'https://localhost', 9 | namespace: 'withenv', 10 | }, 11 | data: { 12 | apiVersion: 'v1', 13 | kind: 'ConfigMap', 14 | metadata: { name: 'config' }, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/withspecjson/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | apiVersion: 'v1', 3 | kind: 'ConfigMap', 4 | metadata: { name: 'config' }, 5 | } 6 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/withspecjson/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "withspec" 6 | }, 7 | "spec": { 8 | "apiServer": "https://localhost", 9 | "namespace": "withspec" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/cases/withtlas/main.jsonnet: -------------------------------------------------------------------------------- 1 | local o = { 2 | data: 'foovalue', 3 | }; 4 | 5 | function(foo, bar, baz) o[foo] 6 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/jsonnetfile.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/test-export-envs-broken/static-env/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | deployment: { 3 | apiVersion: 'apps/v1', 4 | kind: 'Deployment', 5 | metadata: { 6 | name: 'foo', 7 | }, 8 | }, 9 | service: { 10 | apiVersion: 'v1', 11 | kind: 'Service', 12 | metadata: { 13 | // Error, this should be a string 14 | name: true, 15 | }, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/test-export-envs-broken/static-env/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "static", 6 | "labels": { 7 | "type": "static" 8 | } 9 | }, 10 | "spec": { 11 | "apiServer": "https://localhost", 12 | "namespace": "static" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/test-export-envs/inline-envs/main.jsonnet: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | apiVersion: 'tanka.dev/v1alpha1', 4 | kind: 'Environment', 5 | metadata: { 6 | name: env.namespace, 7 | labels: { 8 | type: 'inline', 9 | }, 10 | }, 11 | spec: { 12 | apiServer: 'https://localhost', 13 | namespace: env.namespace, 14 | }, 15 | data: 16 | { 17 | deployment: { 18 | apiVersion: 'apps/v1', 19 | kind: 'Deployment', 20 | metadata: { 21 | name: 'my-deployment', 22 | }, 23 | }, 24 | service: { 25 | apiVersion: 'v1', 26 | kind: 'Service', 27 | metadata: { 28 | name: 'my-service', 29 | }, 30 | }, 31 | } + 32 | (if env.hasConfigMap then { 33 | configMap: { 34 | apiVersion: 'v1', 35 | kind: 'ConfigMap', 36 | metadata: { 37 | name: 'my-configmap', 38 | }, 39 | }, 40 | } else {}), 41 | } 42 | 43 | for env in [ 44 | { 45 | namespace: 'inline-namespace1', 46 | hasConfigMap: true, 47 | }, 48 | { 49 | namespace: 'inline-namespace2', 50 | hasConfigMap: false, 51 | }, 52 | ] 53 | ] 54 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/test-export-envs/static-env/main.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | deployment: { 3 | apiVersion: 'apps/v1', 4 | kind: 'Deployment', 5 | metadata: { 6 | name: std.extVar('deploymentName'), 7 | }, 8 | }, 9 | service: { 10 | apiVersion: 'v1', 11 | kind: 'Service', 12 | metadata: { 13 | name: std.extVar('serviceName'), 14 | }, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /pkg/tanka/testdata/test-export-envs/static-env/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "tanka.dev/v1alpha1", 3 | "kind": "Environment", 4 | "metadata": { 5 | "name": "static", 6 | "labels": { 7 | "type": "static" 8 | } 9 | }, 10 | "spec": { 11 | "apiServer": "https://localhost", 12 | "namespace": "static" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/term/alert.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | var ErrConfirmationFailed = errors.New("aborted by user") 13 | 14 | // Confirm asks the user for confirmation 15 | func Confirm(msg, approval string) error { 16 | return confirmFrom(os.Stdin, os.Stdout, msg, approval) 17 | } 18 | 19 | func confirmFrom(r io.Reader, w io.Writer, msg, approval string) error { 20 | reader := bufio.NewScanner(r) 21 | _, err := fmt.Fprintln(w, msg) 22 | if err != nil { 23 | return errors.Wrap(err, "writing to stdout") 24 | } 25 | 26 | _, err = fmt.Fprintf(w, "Please type '%s' to confirm: ", approval) 27 | if err != nil { 28 | return errors.Wrap(err, "writing to stdout") 29 | } 30 | 31 | if !reader.Scan() { 32 | if err := reader.Err(); err != nil { 33 | return errors.Wrap(err, "reading from stdin") 34 | } 35 | 36 | return ErrConfirmationFailed 37 | } 38 | 39 | if reader.Text() != approval { 40 | return ErrConfirmationFailed 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/term/alert_test.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestConfirm(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | input string 14 | expected error 15 | }{ 16 | {name: "linux yes", input: "yes\n", expected: nil}, 17 | {name: "windows yes", input: "yes\r\n", expected: nil}, 18 | {name: "linux no", input: "no\n", expected: ErrConfirmationFailed}, 19 | {name: "windows no", input: "no\r\n", expected: ErrConfirmationFailed}, 20 | } 21 | 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | in := strings.NewReader(tt.input) 25 | out := &strings.Builder{} 26 | 27 | err := confirmFrom(in, out, "foo", "yes") 28 | 29 | assert.Equal(t, "foo\nPlease type 'yes' to confirm: ", out.String()) 30 | 31 | if tt.expected != nil { 32 | assert.EqualError(t, err, tt.expected.Error()) 33 | } else { 34 | assert.NoError(t, err) 35 | } 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/term/colordiff.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | ) 11 | 12 | // Colordiff colorizes unified diff output (diff -u -N) 13 | func Colordiff(d string) *bytes.Buffer { 14 | exps := map[string]func(s string) bool{ 15 | "add": regexp.MustCompile(`^\+.*`).MatchString, 16 | "del": regexp.MustCompile(`^\-.*`).MatchString, 17 | "head": regexp.MustCompile(`^diff -u -N.*`).MatchString, 18 | "hid": regexp.MustCompile(`^@.*`).MatchString, 19 | } 20 | 21 | buf := bytes.Buffer{} 22 | lines := strings.Split(d, "\n") 23 | 24 | for _, l := range lines { 25 | switch { 26 | case exps["add"](l): 27 | color.New(color.FgGreen).Fprintln(&buf, l) 28 | case exps["del"](l): 29 | color.New(color.FgRed).Fprintln(&buf, l) 30 | case exps["head"](l): 31 | color.New(color.FgBlue, color.Bold).Fprintln(&buf, l) 32 | case exps["hid"](l): 33 | color.New(color.FgMagenta, color.Bold).Fprintln(&buf, l) 34 | default: 35 | fmt.Fprintln(&buf, l) 36 | } 37 | } 38 | 39 | return &buf 40 | } 41 | --------------------------------------------------------------------------------