Repository: dadav/helm-schema Branch: main Commit: 72b95654c2af Files: 77 Total size: 300.2 KB Directory structure: gitextract_wnyi6ji5/ ├── .editorconfig ├── .github/ │ └── workflows/ │ ├── build_and_test.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── CLAUDE.md ├── Dockerfile ├── LICENSE ├── README.md ├── cliff.toml ├── cmd/ │ └── helm-schema/ │ ├── cli.go │ ├── main.go │ ├── main_test.go │ └── version.go ├── examples/ │ ├── Chart.yaml │ ├── values.schema.json │ └── values.yaml ├── go.mod ├── go.sum ├── install-binary.sh ├── pkg/ │ ├── chart/ │ │ ├── chart.go │ │ ├── chart_test.go │ │ └── searching/ │ │ └── dependency_charts.go │ ├── schema/ │ │ ├── annotate.go │ │ ├── annotate_test.go │ │ ├── err.go │ │ ├── err_test.go │ │ ├── root_schema_test.go │ │ ├── schema.go │ │ ├── schema_test.go │ │ ├── toposort.go │ │ ├── toposort_test.go │ │ ├── values_merge.go │ │ ├── worker.go │ │ └── worker_test.go │ └── util/ │ ├── file.go │ └── file_test.go ├── plugin.yaml ├── renovate.json ├── sign-plugin.sh ├── signing-key.asc └── tests/ ├── .gitignore ├── charts/ │ ├── Chart.yaml │ ├── ref_input.json │ ├── test_annotate_expected.yaml │ ├── test_annotate_input.yaml │ ├── test_helm_defaults.yaml │ ├── test_helm_defaults_expected.schema.json │ ├── test_ref.yaml │ ├── test_ref_expected.schema.json │ ├── test_ref_properties.yaml │ ├── test_ref_properties_expected.schema.json │ ├── test_ref_toplevel.yaml │ ├── test_ref_toplevel_expected.schema.json │ ├── test_simple.yaml │ └── test_simple_expected.schema.json ├── import-values/ │ ├── child/ │ │ ├── Chart.yaml │ │ └── values.yaml │ ├── child-complex/ │ │ ├── Chart.yaml │ │ └── values.yaml │ ├── parent/ │ │ ├── Chart.yaml │ │ ├── values.schema.expected.json │ │ └── values.yaml │ └── parent-complex/ │ ├── Chart.yaml │ ├── values.schema.expected.json │ └── values.yaml ├── preexisting-schema/ │ ├── dep-with-schema/ │ │ ├── Chart.yaml │ │ ├── values.schema.json │ │ └── values.yaml │ └── parent/ │ ├── Chart.yaml │ ├── values.schema.default-expected.json │ ├── values.schema.expected.json │ └── values.yaml ├── run.sh └── test-sign-plugin.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false [*.go] indent_size = 4 ================================================ FILE: .github/workflows/build_and_test.yml ================================================ --- name: build and test on: push jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: ^1.21 - name: Run GoReleaser uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7 with: distribution: goreleaser version: latest args: release --snapshot --clean - name: Upload result uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: helm-schema-tarball path: dist/helm-schema_*-next_Linux_x86_64.tar.gz test: runs-on: ubuntu-latest needs: goreleaser steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Download helm-schema uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: helm-schema-tarball path: . - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq - shell: bash run: |- tar xf helm-schema_*-next_Linux_x86_64.tar.gz -C tests helm-schema cd tests && ./run.sh test-sign-plugin-script: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5 - name: Test plugin signing run: ./tests/test-sign-plugin.sh ================================================ FILE: .github/workflows/release.yml ================================================ --- name: release on: push: tags: - "*" permissions: packages: write contents: write jobs: goreleaser: runs-on: ubuntu-latest env: HAS_GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY != '' }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: ^1.21 - name: Generate release notes uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4 with: config: cliff.toml args: --latest --strip header --verbose env: OUTPUT: RELEASE_NOTES.md GITHUB_REPO: ${{ github.repository }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Import GPG key for plugin signing id: import_gpg if: ${{ env.HAS_GPG_KEY == 'true' }} uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Run GoReleaser uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7 with: distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint || '' }} - name: Sign plugin tarballs for Helm v4 verification if: ${{ env.HAS_GPG_KEY == 'true' }} env: GPG_SIGNING_KEY: ${{ steps.import_gpg.outputs.fingerprint }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | echo "Signing plugin tarballs with GPG for Helm v4 verification..." chmod +x sign-plugin.sh # Get version from tag VERSION="${GITHUB_REF#refs/tags/}" VERSION="${VERSION#v}" # Remove 'v' prefix if present # Sign all tar.gz files (plugin packages) for tarball in dist/*.tar.gz; do if [ -f "$tarball" ]; then echo "Signing: $tarball" ./sign-plugin.sh "$VERSION" "$tarball" "$GPG_SIGNING_KEY" || { echo "Warning: Failed to sign $tarball" } fi done # List created .prov files echo "Created provenance files:" ls -lh dist/*.prov || echo "No .prov files created" - name: Set up Helm v4 uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5 - name: Test signed plugin tarballs with Helm v4 if: ${{ env.HAS_GPG_KEY == 'true' }} run: | echo "Testing signed plugin tarballs with Helm..." # Create keyring from the public key in the repo KEYRING=$(mktemp) gpg --dearmor < signing-key.asc > "$KEYRING" FAILED=0 for prov_file in dist/*.prov; do if [ -f "$prov_file" ]; then tarball="${prov_file%.prov}" if [ -f "$tarball" ]; then echo "Verifying: $tarball" helm plugin verify "$tarball" --keyring "$KEYRING" || { echo "FAIL: Verification failed for $tarball" cat "$prov_file" FAILED=1 } fi fi done if [ "$FAILED" -eq 1 ]; then exit 1 fi - name: Update GitHub release notes uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: body_path: RELEASE_NOTES.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload provenance files to release if: ${{ env.HAS_GPG_KEY == 'true' }} uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: files: dist/*.prov env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ ./values.yaml ./values.schema.json dist/ RELEASE_NOTES.md .vscode /helm-schema /tests/helm-schema ================================================ FILE: .goreleaser.yaml ================================================ --- # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj version: 2 before: hooks: - go mod tidy - go test ./... builds: - main: ./cmd/helm-schema env: - CGO_ENABLED=0 goarch: - amd64 - arm - arm64 goarm: - "6" - "7" goos: - linux - windows - darwin archives: - formats: tar.gz # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ {{- .Version }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} # use zip for windows archives format_overrides: - goos: windows formats: zip # Include plugin files for Helm plugin installation files: - plugin.yaml - install-binary.sh - README.md - LICENSE checksum: name_template: checksums.txt # Sign artifacts for Helm v4 plugin verification # The actual .prov file creation happens in GitHub Actions workflow # after goreleaser completes, using sign-plugin.sh script # Checksum signing is optional and only happens when GPG_FINGERPRINT is set signs: - cmd: sh args: - -c - | if [ -n "${GPG_FINGERPRINT}" ]; then gpg --batch --local-user "${GPG_FINGERPRINT}" --output "${signature}" --detach-sign "${artifact}" else echo "Skipping signing: GPG_FINGERPRINT not set" touch "${signature}" fi signature: "${artifact}.sig" artifacts: checksum snapshot: version_template: "{{ .Tag }}-next" dockers_v2: - images: - "ghcr.io/dadav/helm-schema" tags: - "v{{ .Version }}" - "{{ if .IsNightly }}nightly{{ end }}" - "{{ if not .IsNightly }}latest{{ end }}" labels: "org.opencontainers.image.created": "{{.Date}}" "org.opencontainers.image.authors": dadav "org.opencontainers.image.url": "{{.GitURL}}" "org.opencontainers.image.title": "{{.ProjectName}}" "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.version": "{{.Version}}" changelog: sort: asc filters: include: - "^feat:" - "^fix:" ================================================ FILE: .pre-commit-config.yaml ================================================ --- repos: - repo: https://github.com/dadav/helm-schema rev: 0.22.0 hooks: - id: helm-schema # for all available options: helm-schema -h args: # directory to search recursively within for charts - --chart-search-root=. # don't analyze dependencies - --no-dependencies # add references to values file if not exist - --add-schema-reference # list of fields to skip from being created by default # e.g. generate a relatively permissive schema # - "--skip-auto-generation=required,additionalProperties" ================================================ FILE: .pre-commit-hooks.yaml ================================================ --- - id: helm-schema description: Uses helm-schema to create a jsonschema. entry: helm-schema files: (Chart|values)\.yaml$ language: golang name: Generate jsonschema require_serial: true - id: helm-schema-system description: Uses pre-installed helm-schema to create a jsonschema. entry: helm-schema files: (Chart|values)\.yaml$ language: system name: Generate jsonschema require_serial: true - id: helm-schema-container args: [] description: Uses the container image of 'helm-schema' to create schema from the Helm chart's 'values.yaml' file, and inserts the result into a corresponding 'values.schema.json' file. entry: ghcr.io/dadav/helm-schema:latest files: values.yaml language: docker_image name: Helm Schema Container require_serial: true ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview `helm-schema` is a Go tool that generates JSON Schema (Draft 7) files for Helm chart values. It traverses directories to find `Chart.yaml` files, reads associated `values.yaml` files, parses special `@schema` annotations in comments, and generates `values.schema.json` files that enable IDE auto-completion and validation for Helm values. ## Build and Test Commands ### Build ```bash # Build the binary go build -o helm-schema ./cmd/helm-schema # Build with goreleaser (for releases) goreleaser release --snapshot --clean ``` ### Test ```bash # Run all tests go test ./... # Run tests for a specific package go test ./pkg/schema go test ./pkg/chart # Run a specific test go test ./pkg/schema -run TestTopoSort # Run tests with verbose output go test -v ./... # Integration tests (requires helm-schema binary in tests/) cd tests && ./run.sh ``` ### Linting and Formatting ```bash # Format code go fmt ./... # Tidy dependencies go mod tidy ``` ## Code Architecture ### High-Level Flow 1. **Chart Discovery** (`pkg/chart/searching/`): Recursively searches for `Chart.yaml` files starting from a root directory. Also extracts `.tgz` chart archives if found. 2. **Parallel Processing** (`cmd/helm-schema/main.go`): Uses a worker pool (2x CPU cores) to process charts concurrently. Each worker receives chart paths via a channel. 3. **Schema Generation** (`pkg/schema/worker.go`): For each chart, reads the `values.yaml` file and parses YAML with annotations to build a JSON Schema object. 4. **Annotation Parsing** (`pkg/schema/schema.go`): Parses `# @schema` and `# @schema.root` comment blocks to extract JSON Schema properties (type, description, enum, pattern, etc.). 5. **Dependency Resolution** (`cmd/helm-schema/main.go`): After all charts are processed, topologically sorts them by dependencies and merges dependency schemas into parent charts. Library charts (type: library) have their properties merged at the top level. 6. **Output** (`cmd/helm-schema/main.go`): Writes `values.schema.json` files to each chart directory. 7. **Annotation Mode** (`pkg/schema/annotate.go`): With `--annotate` / `-A` flag, writes inferred `@schema` type annotation blocks into `values.yaml` files for keys that don't already have them. This is a separate execution mode that modifies values files instead of generating JSON schema. Keys that already have `@schema` blocks are skipped. ### Key Components #### Schema Parsing (`pkg/schema/schema.go`) - **`ParseValues()`**: Main entry point that parses a values.yaml file and returns a Schema - **`parseYamlNode()`**: Recursively traverses YAML nodes, extracting schema annotations and inferring types - **Annotation blocks**: Comments between `# @schema` markers are parsed as YAML to extract JSON Schema properties - **Root annotations**: Comments between `# @schema.root` markers apply to the root schema object itself - **Type inference**: If no type is specified, the tool infers it from YAML tags (!!str, !!int, !!bool, etc.) #### Worker Pattern (`pkg/schema/worker.go`) - Workers pull chart paths from a channel and process them independently - Each worker: 1. Reads Chart.yaml 2. For schema generation, finds all configured values files that exist for the chart and merges them in CLI order 3. Parses merged values into a Schema 4. Sends Result to results channel - When multiple values files are present, later files override earlier files using Helm-style nested map merge precedence. - `--annotate` and `--add-schema-reference` still operate on the first matching values file only; they do not merge multiple files. #### Dependency Graph (`pkg/schema/toposort.go`) - **TopoSort()**: Uses DFS-based topological sorting to ensure dependencies are processed before dependents - Detects circular dependencies and can either fail or warn based on `allowCircular` flag - Returns charts in dependency order (dependencies first, parents last) #### Chart Models (`pkg/chart/chart.go`) - **ChartFile**: Represents Chart.yaml structure - **Dependency**: Represents a chart dependency with name, version, alias, condition #### Schema Merging (in `main.go`) - Regular dependencies: Nested under dependency name (or alias) in parent schema - Library charts: Properties merged directly into parent schema at top level - Import-values: Properties from dependency's `exports` section (or custom paths) merged into parent at specified location - Conditional dependencies: If a dependency has a `condition` field, the corresponding boolean property is auto-created in the dependency's schema - Skip validation flag (`-m`): Can disable strict validation for dependencies by setting `additionalProperties: true` #### Import-Values Processing (in `main.go`) The `processImportValues()` function handles Helm's `import-values` directive: - **Simple form** (`import-values: [defaults]`): Imports from `exports.defaults` in dependency to parent's root - **Complex form** (`import-values: [{child: "path", parent: "path"}]`): Explicit source and target paths - Properties are merged using `mergeSchemaProperties()`, which skips "global" and warns on conflicts - When import-values is used on a non-library dependency, the dependency is NOT auto-nested (user controls what's imported) #### Schema Path Navigation (`pkg/schema/schema.go`) - **`GetPropertyAtPath(path string)`**: Navigates dot-separated paths (e.g., "exports.defaults") and returns the schema at that location, or nil if not found - **`SetPropertyAtPath(path string)`**: Creates intermediate object schemas as needed and returns the schema at the target path ### Important Patterns 1. **BoolOrArrayOfString**: The `required` field in JSON Schema can be either a boolean or an array of strings. This custom type handles both cases during marshaling/unmarshaling. 2. **SchemaOrBool**: Some JSON Schema fields like `additionalProperties` can be either a boolean or a Schema object. This is represented as `interface{}`. 3. **Annotation Comments**: The tool looks for comments in specific formats: - `# @schema` / `# @schema` blocks for field-level annotations - `# @schema.root` / `# @schema.root` blocks for root-level annotations - Comments outside these blocks become descriptions (unless `description` is explicitly set) 4. **Helm-docs compatibility**: With `-p` flag, parses `-- helm-docs description` and `@default` annotations from helm-docs format. - Helm-docs type `tpl` is mapped to JSON Schema `string` in `helmDocsTypeToSchemaType`. ## Testing Strategy - **Unit tests**: Each package has `*_test.go` files testing individual functions - **Integration tests**: `tests/run.sh` compares generated schemas against expected outputs - **Test files**: `tests/test_*.yaml` are input values files, `tests/test_*_expected.schema.json` are expected outputs ## Plugin Verification (Helm v4) The project implements Helm v4 plugin verification through GPG signing: ### Signing Infrastructure 1. **sign-plugin.sh**: Script that creates `.prov` (provenance) files for plugin tarballs - Takes version, tarball path, and GPG key as arguments - Creates a signed provenance file containing metadata and SHA256 hash - Uses GPG to sign the provenance 2. **GitHub Actions Workflow**: `.github/workflows/release.yml` - Generates release notes with `orhun/git-cliff-action@v4` using `cliff.toml` - Imports GPG private key from secrets (`GPG_PRIVATE_KEY`, `GPG_PASSPHRASE`) - Runs goreleaser to build and package binaries - Updates the GitHub release body from `RELEASE_NOTES.md` - Signs all `.tar.gz` files with `sign-plugin.sh` - Uploads `.prov` files to GitHub releases 3. **GoReleaser Config**: `.goreleaser.yaml` - Archives include plugin files: `plugin.yaml`, `install-binary.sh`, `README.md`, `LICENSE` - Configured to sign checksums with GPG 4. **git-cliff Config**: `cliff.toml` - Groups conventional commits into release note sections - Uses GitHub metadata to link pull requests in generated release notes - Generates only the latest tagged release notes in CI via `--latest --strip header` ### Setup for Maintainers - See `.github/SETUP_SIGNING.md` for initial GPG key setup - Public key should be in `signing-key.asc` (currently a template) - Key details must be updated in `VERIFICATION.md` ### Verification Process Users can verify plugins with: ```bash helm plugin install --verify helm plugin verify schema ``` ## Supported JSON Schema Draft 7 Keywords The Schema struct (`pkg/schema/schema.go`) supports the following JSON Schema Draft 7 keywords: ### Core Keywords - `$schema`, `$id`, `$ref`, `$comment` - `type` (single type or array of types) - `title`, `description` - `default`, `examples` - `definitions` (also supports `$defs` from Draft 2019-09+ - automatically converted to `definitions`) ### Validation Keywords #### Numeric (number, integer) - `minimum`, `maximum` (float64 - supports decimal values like `1.5`) - `exclusiveMinimum`, `exclusiveMaximum` (float64) - `multipleOf` (float64 - supports `0.1`, `0.01`, etc.) #### String - `minLength`, `maxLength` - `pattern` (regex pattern) - `format` (date-time, email, uri, ipv4, ipv6, uuid, etc.) - `contentEncoding`, `contentMediaType` #### Array - `items` (single schema or handled via anyOf for arrays) - `additionalItems` (boolean or schema) - `minItems`, `maxItems` - `uniqueItems` - `contains` #### Object - `properties`, `patternProperties` - `additionalProperties` (boolean or schema) - `required` (boolean or array of strings) - `minProperties`, `maxProperties` - `propertyNames` - `dependencies` ### Composition Keywords - `allOf`, `anyOf`, `oneOf`, `not` - `if`, `then`, `else` ### Annotation Keywords - `deprecated`, `readOnly`, `writeOnly` - `enum`, `const` - `const-from-value` copies the YAML value into the generated `const` keyword and must not be combined with explicit `const` ### Custom Annotations - Any key prefixed with `x-` is treated as a custom annotation ## Documentation Notes - README plugin verification examples should use a `vX.Y.Z` placeholder to avoid version drift. - GPG public key fingerprint (from `signing-key.asc`) is `806F 70D2 5667 D42A AE4E 07CE F587 0796 9D0F BFA5`; key ID is `F58707969D0FBFA5`. ## Validation Behavior The schema validation (`Validate()` method) performs type-specific constraint checks: - Numeric constraints (`minimum`, `maximum`, etc.) require `type: number` or `type: integer` - String constraints (`minLength`, `maxLength`, `pattern`, `format`, `contentEncoding`) require `type: string` - Array constraints (`items`, `minItems`, `maxItems`, `contains`, `additionalItems`) require `type: array` - Object constraints (`minProperties`, `maxProperties`, `propertyNames`, `additionalProperties`) require `type: object` Some keywords like `uniqueItems` are accepted on any type per the JSON Schema spec (keywords are ignored if the type doesn't match). ## Common Gotchas 1. **Draft 7 limitation**: The tool uses JSON Schema Draft 7 because Helm's validation library only supports that version. When referencing external schemas that use `$defs` (Draft 2019-09+), they are automatically converted to `definitions` and `$ref` paths are rewritten from `#/$defs/` to `#/definitions/`. 2. **Root annotations placement**: `@schema.root` blocks must be at the top of values.yaml with no blank lines after (unless using `-s` flag). 3. **Dependency schema immutability**: You cannot modify a dependency's schema using annotations in the parent chart's values.yaml. The schema comes from the dependency's own values.yaml. 4. **Library chart merging**: When a library chart property name conflicts with a parent property, the parent takes precedence (with a warning logged). 5. **Import-values behavior**: When `import-values` is used on a non-library dependency, the dependency's full schema is NOT auto-nested under its name. Only explicitly imported properties appear in the parent schema. This matches Helm's behavior where import-values gives the user explicit control over what's imported. 6. **Comment parsing**: By default, descriptions are cut at the first empty line in comments. Use `-s` to keep full comments. 7. **Plugin signing**: Signing only works if GPG secrets are configured in GitHub. Missing secrets cause signing steps to be skipped gracefully. 8. **Dependency schema regeneration**: By default every discovered chart — including those declared as dependencies of another chart — has its `values.schema.json` regenerated from its own `values.yaml`. Pass `--keep-existing-dep-schemas` (`-K`) to instead reuse a dependency chart's pre-existing `values.schema.json` (useful for third-party charts pulled via `helm dep up`, so their rich constraints and `x-*` annotations are preserved). When the flag is set, such dependency schemas are merged into the parent as-is and are not overwritten on disk. ================================================ FILE: Dockerfile ================================================ FROM alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 ARG TARGETPLATFORM RUN adduser -k /dev/null -u 10001 -D helm-schema \ && chgrp 0 /home/helm-schema \ && chmod -R g+rwX /home/helm-schema COPY $TARGETPLATFORM/helm-schema / USER 10001 VOLUME [ "/home/helm-schema" ] WORKDIR /home/helm-schema ENTRYPOINT ["/helm-schema"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 dadav Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # helm-schema


Latest Release Go Docs Build Status MIT LICENSE pre-commit Go Report

This tool tries to help you to easily create some nice JSON schema for your helm chart.

By default it will traverse the current directory and look for `Chart.yaml` files. For every file, helm-schema will try to find one of the given value filenames. The first files found will be read and a jsonschema will be created. For every dependency defined in the `Chart.yaml` file, a reference to the dependencies JSON schema will be created. > [!NOTE] > The tool uses `jsonschema` Draft 7, because the library helm uses only supports that version. ## Installation Via `go` install: ```sh go install github.com/dadav/helm-schema/cmd/helm-schema@latest ``` From `aur`: ```sh paru -S helm-schema ``` Via `podman/docker`: ```sh podman run --rm -v $PWD:/home/helm-schema ghcr.io/dadav/helm-schema:latest ``` As `helm plugin`: ```sh helm plugin install https://github.com/dadav/helm-schema ``` ### Plugin Verification (Helm v4+) Helm v4 introduced plugin verification for enhanced security. All helm-schema releases are signed with GPG and include provenance files (`.prov`) for verification. **Automatic Verification (Recommended)** Helm v4 verifies plugin signatures by default: ```sh # Install from a specific release with automatic verification helm plugin install https://github.com/dadav/helm-schema/releases/download/vX.Y.Z/helm-schema_X.Y.Z_Linux_x86_64.tar.gz ``` **Manual Verification** Before installing, import the signing key: ```sh # Import the public signing key (from file) gpg --import signing-key.asc # Or import by key ID (last 16 hex chars of the fingerprint) gpg --keyserver keyserver.ubuntu.com --recv-keys F58707969D0FBFA5 # Verify the imported key fingerprint (expect: 806F 70D2 5667 D42A AE4E 07CE F587 0796 9D0F BFA5) gpg --fingerprint F58707969D0FBFA5 # Export from kdx and save in old gpg format gpg --export F58707969D0FBFA5 > ~/.gnupg/pubring.gpg # Install with explicit verification helm plugin install https://github.com/dadav/helm-schema/releases/download/vX.Y.Z/helm-schema_X.Y.Z_Linux_x86_64.tar.gz --verify ``` **Verify Installed Plugin** ```sh # Verify an already installed plugin helm plugin verify schema ``` > [!NOTE] > Plugin verification requires Helm v4 or later. If using Helm v3, signatures will be ignored. ## Usage ### Pre-commit hook If you want to automatically generate a new `values.schema.json` if you change the `values.yaml` file, you can do the following: 1. Install [`pre-commit`](https://pre-commit.com/#install) 2. Copy the [`.pre-commit-config.yaml`](./.pre-commit-config.yaml) to your helm chart repository. 3. Then run these commands: ```sh pre-commit install pre-commit install-hooks ``` ### Running the binary directly You can also just run the binary yourself: ```sh helm-schema ``` ### Options The binary has the following options: ```sh Flags: -A, --annotate "write inferred @schema type blocks into the first matching values file instead of generating schema" -r, --add-schema-reference "add reference to schema in values.yaml if not found" -w, --allow-circular-dependencies "allow circular dependencies between charts (will log a warning instead of failing)" -a, --append-newline "append newline to generated jsonschema at the end of the file" -c, --chart-search-root string "directory to search recursively within for charts (default ".")" -i, --dependencies-filter strings "only generate schema for specified dependencies (comma-separated list of dependency names)" -g, --dont-add-global "don't auto add global property" -x, --dont-strip-helm-docs-prefix "disable the removal of the helm-docs prefix (--)" -d, --dry-run "don't actually create files just print to stdout passed" -p, --helm-docs-compatibility-mode "parse and use helm-docs comments" -h, --help "help for helm-schema" -K, --keep-existing-dep-schemas "use dependency charts' pre-existing values.schema.json instead of regenerating from values.yaml" -s, --keep-full-comment "keep the whole leading comment (default: cut at empty line)" -l, --log-level string "level of logs that should be printed, one of (panic, fatal, error, warning, info, debug, trace) (default "info")" -n, --no-dependencies "skip dependency charts: don't merge them into parents and don't generate their schemas" -o, --output-file string "jsonschema file path relative to each chart directory to which jsonschema will be written (default 'values.schema.json')" -m, --skip-dependencies-schema-validation "skip schema validation for dependencies by setting additionalProperties to true and removing from required" -f, --value-files strings "filenames to look for chart values; schema generation merges all matches in the order provided (default [values.yaml])" -k, --skip-auto-generation strings "skip the auto generation for these fields (default [])" -u, --uncomment "consider yaml which is commented out" -v, --version "version for helm-schema" ``` For schema generation, `helm-schema` checks each `--value-files` entry for the chart, keeps the ones that exist, and merges them in the order provided. Later files take precedence over earlier files, following Helm's `-f/--values` behavior. `--annotate` does not merge multiple files. It only annotates the first matching values file. `--add-schema-reference` also targets the first matching values file. ### Annotate mode Use `--annotate` to add inferred `# @schema` type blocks to a values file instead of generating `values.schema.json`. - Keys that already have `@schema` annotations are left unchanged. - With `-d, --dry-run`, the annotated file is printed to stdout instead of being written back. - When multiple `--value-files` entries are configured, annotate mode uses only the first matching file. ## Annotations The `jsonschema` must be between two entries of `# @schema` : ```yaml # @schema # my: annotation # @schema # you can add comment here as well foo: bar ``` > [!WARNING] > It must be written just above the key you want to annotate. > [!NOTE] > If you don't use the `properties` option on hashes/objects or don't use `items` on arrays, it will be parsed from the values and their annotations instead. ### Root-level annotations You can apply schema annotations to the root schema object itself using `# @schema.root`: ```yaml # @schema.root # title: My Chart Values # description: Configuration values for my Helm chart # x-custom-field: custom-value # @schema.root # @schema # enum: [dev, staging, prod] # @schema # Example description foo baz stage: dev ``` > [!NOTE] > The `@schema.root` block must be placed before the first key in your `values.yaml` file, without blank lines after it (unless you use the `-s` flag to keep full comments). ### Available annotations | Key| Description | Values | |-|-|-| | [`type`](#type) | Defines the [jsonschema-type](https://json-schema.org/understanding-json-schema/reference/type.html) of the object. Multiple values are supported (e.g. `[string, integer]`) as a shortcut to `anyOf` | `object`, `array`, `string`, `number`, `integer`, `boolean` or `null` | | [`title`](#title) | Defines the [title field](https://json-schema.org/understanding-json-schema/reference/generic.html?highlight=title) of the object | Defaults to the key itself | | [`description`](#description) | Defines the [description field](https://json-schema.org/understanding-json-schema/reference/generic.html?highlight=description) of the object. | Defaults to the comments just above or below the `@schema` annotations block | | [`default`](#default) | Sets the default value and will be displayed first on the users IDE| Takes a `string` | | [`properties`](#properties) | Contains a map with keys as property names and values as schema | Takes an `object` | | [`pattern`](#pattern) | Regex pattern to test the value | Takes an `string` | | [`format`](#format) | The [format keyword](https://json-schema.org/understanding-json-schema/reference/string.html#format) allows for basic semantic identification of certain kinds of string values | Takes a [keyword](https://json-schema.org/understanding-json-schema/reference/string.html#format) | | [`required`](#required) | Adds the key to the required items | `true` or `false` or `array` | | [`deprecated`](#deprecated) | Marks the option as deprecated | `true` or `false` | | [`items`](#items) | Contains the schema that describes the possible array items | Takes an `object` | | [`enum`](#enum) | Multiple allowed values. Accepts an array of `string` | Takes an `array` | | [`const`](#const) | Single allowed value | Takes a `string`| | [`examples`](#examples) | Some examples you can provide for the end user | Takes an `array` | | [`minimum`](#minimum) | Minimum value. Can't be used with `exclusiveMinimum` | Takes a `number` (integer or float). Must be smaller than `maximum` or `exclusiveMaximum` (if used) | | [`exclusiveMinimum`](#exclusiveminimum) | Exclusive minimum. Can't be used with `minimum` | Takes a `number` (integer or float). Must be smaller than `maximum` or `exclusiveMaximum` (if used) | | [`maximum`](#maximum) | Maximum value. Can't be used with `exclusiveMaximum` | Takes a `number` (integer or float). Must be bigger than `minimum` or `exclusiveMinimum` (if used) | | [`exclusiveMaximum`](#exclusivemaximum) | Exclusive maximum value. Can't be used with `maximum` | Takes a `number` (integer or float). Must be bigger than `minimum` or `exclusiveMinimum` (if used) | | [`multipleOf`](#multipleof) | The yaml-value must be a multiple of. For example: If you set this to 0.1, allowed values would be 0, 0.1, 0.2... | Takes a `number` (integer or float, must be > 0) | | [`additionalProperties`](#additionalproperties) | Allow additional keys in maps. Useful if you want to use for example `additionalAnnotations`, which will be filled with keys that the `jsonschema` can't know| Defaults to `false` if the map is not an empty map. Takes a schema or boolean value | | [`patternProperties`](#patternproperties) | Contains a map which maps schemas to pattern. If properties match the patterns, the given schema is applied| Takes an `object` | | [`anyOf`](#anyof) | Accepts an array of schemas. None or one must apply | Takes an `array` | | [`oneOf`](#oneof) | Accepts an array of schemas. One or more must apply | Takes an `array` | | [`allOf`](#allof) | Accepts an array of schemas. All must apply| Takes an `array` | | [`not`](#not) | A schema that must not be matched. | Takes an `object` | | [`if/then/else`](#ifthenelse) | `if` the given schema applies, `then` also apply the given schema or `else` the other schema| Takes an `object` | | [`$ref`](#ref) | Accepts an URI to a valid `jsonschema`. Extend the schema for the current key | Takes an URI (or relative file) | | [`minLength`](#minlength) | Minimum string length. | Takes an `integer`. Must be smaller or equal than `maxLength` (if used) | | [`maxLength`](#maxlength) | Maximum string length. | Takes an `integer`. Must be greater or equal than `minLength` (if used) | | [`minItems`](#minItems) | Minimum length of an array. | Takes an `integer`. Must be smaller or equal than `maxItems` (if used) | | [`maxItems`](#maxItems) | Maximum length of an array. | Takes an `integer`. Must be greater or equal than `minItems` (if used) | | [`contains`](#contains) | Array must contain at least one item matching this schema | Takes a schema `object` | | [`additionalItems`](#additionalItems) | Schema for array items beyond those defined in `items` tuple | Takes a `boolean` or schema `object` | | [`minProperties`](#minProperties) | Minimum number of properties in an object | Takes an `integer` >= 0 | | [`maxProperties`](#maxProperties) | Maximum number of properties in an object | Takes an `integer` >= 0 | | [`propertyNames`](#propertyNames) | Schema that all property names must match | Takes a schema `object` | | [`dependencies`](#dependencies) | Property dependencies (presence of one property requires others) | Takes an `object` mapping property names to arrays or schemas | | [`definitions`](#definitions) | Reusable schema definitions for use with `$ref`. Also supports `$defs` from newer JSON Schema drafts (automatically converted) | Takes an `object` mapping names to schemas | | [`$comment`](#comment) | Comment for schema maintainers (not shown to end users) | Takes a `string` | | [`contentEncoding`](#contentEncoding) | Encoding for string content (e.g., base64) | Takes a `string` | | [`contentMediaType`](#contentMediaType) | MIME type for string content | Takes a `string` | ## Validation & completion To take advantage of the generated `values.schema.json`, you can use it within your IDE through a plugin supporting the `yaml-language-server` annotation (e.g. [VSCode - YAML](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml)) You'll have to place this line at the top of your `values.yaml` (`$schema=`) : ```yaml # vim: set ft=yaml: # yaml-language-server: $schema=values.schema.json # @schema # required: true # @schema # -- This is an example description foo: bar ``` You can use the `-r` flag to make sure this line exists. > [!NOTE] > You can also point to an online available schema, if you upload a version of yours and want other to be able to implement it. > > `yaml-language-server: $schema=https://example.org/my-json-schema.json` > > e.g. from github `https://raw.githubusercontent.com///main/values.schema.json` ### helm-docs If you're using [`helm-docs`](https://github.com/norwoodj/helm-docs), then you can combine both annotations and use both pre-commit hooks to automatically generate your documentation (e.g. `README.md`) alongside your `values.schema.json`. If not provided, `title` will be the key and the `description` will be parsed from the `helm-docs` formatted comment. ```yaml # @schema # type: array # @schema # -- helm-docs description here foo: [] ``` If you use `-p`/`--helm-docs-compatibility-mode` flags, the `@default`, `(type)` annotations and helm-docs descriptions are used if detected. > [!NOTE] > Make sure to place the `@schema` annotations **before** the actual key description to avoid having it in your `helm-docs` generated table ## Dependencies By default, `helm-schema` will try to also create the schemas for the dependencies in their respective chart directory. These schemas will be merged as properties in the main schema, but the `requiredProperties` field will be nullified, otherwise you would have to always overwrite all the required fields. If you don't want to generate `jsonschema` for chart dependencies, you can use the `-n, --no-dependencies` option to only generate the `values.schema.json` for your parent chart(s). With this flag, any discovered chart that is declared as a dependency of another discovered chart is skipped entirely — the dependency is not merged into its parent and its own `values.schema.json` is not generated. ### Reusing a Dependency's Pre-existing Schema By default, `helm-schema` regenerates `values.schema.json` for every discovered chart — including subcharts that already ship with a hand-written `values.schema.json`. If you instead want to preserve a dependency's shipped schema (typical for third-party charts pulled via `helm dep up` that carry rich constraints such as `minimum`, `pattern`, `format`, or custom `x-*` annotations), pass `-K, --keep-existing-dep-schemas`: ```sh helm-schema -K ``` When this flag is set: 1. A dependency chart's pre-existing `values.schema.json` is used as-is and merged into the parent. 2. That dependency's schema file is not overwritten on disk. Without the flag, every discovered chart's schema is regenerated from its `values.yaml`. ### Library Charts When a dependency has `type: library` in its `Chart.yaml`, `helm-schema` will merge its schema properties directly into the parent chart's schema at the top level, rather than nesting them under the dependency name. This reflects how [Helm library charts](https://helm.sh/docs/topics/library_charts/) work in practice, where the values scope is identical to the parent chart. For example, if you have a library chart named `common` with properties `environment` and `region`, these will appear at the top level of the parent schema alongside the parent's own properties, rather than under a `common` key. **Note:** If a library chart property has the same name as a property already defined in the parent chart, the parent's property takes precedence and a warning will be logged. ### Skip Dependency Schema Validation By default, when dependency schemas are merged into the parent chart schema, they inherit strict validation rules. This means that if you add unknown keys at the top level of a dependency's values (e.g., `subchart.unknownKey`), validation may not fail as expected. If you want to allow additional properties in dependency schemas and ensure they are not required, you can use the `-m, --skip-dependencies-schema-validation` flag. This will: 1. Set `additionalProperties: true` for all dependency schemas in the parent chart 2. Remove dependency names from the parent chart's required properties list Example usage: ```sh helm-schema -m ``` This is useful when you have umbrella charts with multiple dependencies and want to allow flexibility in overriding dependency values without strict schema validation. ### Handling Circular Dependencies In some scenarios, you may have charts that reference each other to share values, creating circular dependencies. For example: - A cert-manager chart depends on a grafana chart to get the instance name for creating dashboards - The grafana chart depends on the cert-manager chart to get the ACME issuer value By default, `helm-schema` will detect this as a circular dependency and log a warning. The tool will still continue processing, but the results may not be sorted in dependency order. If you want to explicitly allow circular dependencies and acknowledge this behavior, you can use the `-w, --allow-circular-dependencies` flag: ```sh helm-schema -w ``` When this flag is enabled, circular dependencies are treated as warnings rather than errors, and the charts are processed in their original discovery order instead of topologically sorted order. **Note:** This is primarily useful when charts have cross-dependencies purely for value sharing, not for actual build order dependencies. ## Limitations You can't change the `jsonschema` for dependencies by using `@schema` annotations on dependency config values. For example: ```yaml # foo is a dependency chart foo: # You can't change the schema here, this has no effect. # @schema # type: number # @schema bar: 1 ``` ## Examples Some annotation examples you may want to use, to help you get started! > [!NOTE] > See how the schema behaves with live examples : [values.yaml](./examples/values.yaml) Below a snippet to test it out, with the current options `helm-schema` will not analyze dependencies (`-n`) and will omit the `additionalProperties` (`-k`, when not explicitly defined) in the generated schema. It will start looking for `Chart.yaml` and `values.yaml` files in `examples/` (`-c`) ```sh cd examples helm-schema -n -k additionalProperties # or helm-schema -c examples -n -k additionalProperties ``` If you'd like to use `helm-schema` on your chart dependencies as well, you have to build and unpack them before. You'll avoid the "missing dependency" error message. ```sh # go where your Chart.lock/yaml is located cd # build dependencies and untar them helm dep build ls charts/*.tgz |xargs -n1 tar -C charts/ -xzf ``` #### `type` If `type` isn't specified, current value type will be used. ```yaml # Will be parsed as 'string' # @schema # title: Some title # description: Some description # @schema name: foo # Will be parsed as 'boolean' # @schema # type: boolean # @schema enabled: true # You can define multiple types as an array. # @schema # type: [string, integer] # minimum: 0 # @schema cpu: 1 ``` #### Root schema annotations Apply schema annotations to the root document itself. This is especially useful for setting the `additionalProperties` on the root level of the schema. ```yaml # @schema.root # additionalProperties: true # @schema.root # Main application settings app: # @schema # type: string # @schema name: my-app # @schema # type: boolean # @schema enabled: true ``` #### `title` By default, the `title` will be parsed from the key name. If the key is `foo`, then `title: foo`. ```yaml # Define a custom title for the key # @schema # title: My custom title for 'foo' # @schema bar: foo ``` #### `description` You can provide the `description` through its property or let it be parsed from your comments. If `description` is provided, the comments will not be parsed as description. If you're implementing it alongside `helm-docs`, read [this](#helm-docs) to do it correctly. ```yaml # This text will be used as description. # @schema # type: integer # minimum: 1 # @schema replica: 1 # @schema # type: integer # minimum: 1 # @schema # This text will be used as description. replica: 1 # @schema # type: integer # minimum: 1 # description: This text will be used as description. # @schema # And not this one replica: 1 ``` #### `default` Help users when using their IDE to quickly retrieve the `default` value, for example through CTRL+SPACE. ```yaml # @schema # default: standalone # enum: [standalone,cluster] # @schema architecture: "" # @schema # type: boolean # default: true # @schema enabled: true ``` #### `properties` Allows user to define valid keys without defining them yet. Give the user an insight of the possible properties, their types and description. By default, `title` for the keys defined under `properties` will inherit from the main key (e.g. here `title: env`). You need to provide `title` explicitly if you want to change it. ```yaml # @schema # properties: # CONFIG_PATH: # title: CONFIG_PATH # type: string # description: The local path to the service configuration file # ADMIN_EMAIL: # title: ADMIN_EMAIL # type: string # format: idn-email # API_URL: # type: string # format: idn-hostname # description: Title will be 'env' as we do not specify it here # @schema # -- Environment variables. If you want to provide auto-completion to the user env: {} ``` #### `pattern` Pattern that'll be used to test the value. ```yaml # @schema # pattern: ^api-key # @schema # The value have to start with the 'api-key-' prefix apiKey: "api-key-xxxxx" ``` #### `format` Known formats that the value must match. Formats available at [JSON Schema - Formats](https://json-schema.org/understanding-json-schema/reference/string.html#format). ```yaml # @schema # format: idn-email # @schema # Requires a valid email format email: foo@example.org ``` #### `required` By default every property is a required property, you can disable this with `required: false` for a single key. You can also invert this behaviour with the option `helm-schema -k required`, now every property is an optional one. ```yaml # @schema # required: false # @schema altName: foo ``` It's also possible to define an array of required properties on the parent. ```yaml # @schema # required: [foo] # @schema altName: foo: bar ``` #### `deprecated` Let the user know if the key is deprecated, hence should be avoided. ```yaml # @schema # deprecated: true # @schema secret: foo ``` #### `items` If you want to specify a schema for possible array values without using a default value. E.g. to define the structure of the hosts definition in an k8s ingress resource. ```yaml # @schema # type: array # items: # type: object # properties: # host: # type: object # properties: # url: # type: string # format: idn-hostname # @schema # Will give auto-completion for the below structure # hosts: # - name: # url: my.example.org hosts: [] ``` #### `enum` Allows user to define available values for a given key. Validation will fail and error shown if you try to put another value. ```yaml # @schema # enum: # - application # - controller # - api # @schema # Only those three values are accepted type: application # @schema # type: array # items: # enum: [api,frontend,backend,microservice,teamA,teamB,us-west-1,us-west-2] # @schema # For each array index, only one of those values are accepted tags: - "api" - "teamA" - "us-west-2" ``` #### `const` Defines a constant value which shouldn't be changed. ```yaml # @schema # const: maintainer@example.org # @schema maintainer: maintainer@example.org ``` #### `const-from-value` Copies the YAML value into the generated JSON Schema `const` without duplicating the payload in the annotation block. ```yaml # @schema # const-from-value: true # @schema message: | long message with {{ .gotemplate }} ``` #### `examples` Provides example values to the user when hovering the key in IDE, or by auto-completion mechanism. ```yaml # @schema # format: ipv4 # examples: [192.168.0.1] # @schema clusterIP: "" # @schema # properties: # CONFIG_PATH: # type: string # description: The local path to the service configuration file # examples: [/path/to/config] # ADMIN_EMAIL: # type: string # format: idn-email # examples: [admin@example.org] # API_URL: # type: string # format: idn-hostname # examples: [https://api.example.org] # @schema # -- Provide auto-completion and examples to the user env: {} ``` #### `minimum` The value have to be above or equal the given `integer`. ```yaml # @schema # minimum: 1 # @schema replica: "" ``` #### `exclusiveMinimum` The value have to be strictly above the given `integer`. ```yaml # @schema # exclusiveMinimum: 0 # @schema replica: "" ``` #### `maximum` The value have to be below or equal the given `integer`. ```yaml # @schema # maximum: 10 # @schema replica: "" ``` #### `exclusiveMaximum` The value have to be strictly below the given `integer`. ```yaml # @schema # exclusiveMaximum: 5 # @schema cpu: "" ``` #### `multipleOf` The value have to be a multiple of the given `integer`. ```yaml # @schema # multipleOf: 1024 # @schema storageCapacity: 2048 ``` #### `additionalProperties` By default, `additionalProperties` is set to `false` unless you use the `-k additionalProperties` option. Useful when you don't know what nested keys you'll have. ```yaml # @schema # additionalProperties: true # @schema # You'll be able to add as many keys below `env:` as you want without invalidating the schema env: LONG: foo LIST: bar OF: baz VARIABLES: bat # @schema # additionalProperties: true # properties: # REQUIRED_VAR: # type: string # @schema env: REQUIRED_VAR: foo OPTIONAL_VAR: bar ``` #### `patternProperties` Mapping schemas to key name patterns. If properties match the patterns, the given schema is applied. Useful when you work with a long list of keys and want to define a common schema for a group of them, for example. E.g. `patternProperties."^API_.*"` key defines the pattern whose schema will be applied on any user provided key that match that pattern. ```yaml # @schema # type: object # patternProperties: # "^API_.*": # type: string # pattern: ^api-key # "^EMAIL_.*": # type: string # format: idn-email # @schema env: API_PROVIDER_ONE: api-key-xxxxx API_PROVIDER_TWO: api-key-xxxxx EMAIL_ADMIN: admin@example.org EMAIL_DEFAULT_USER: user@example.org ``` #### `anyOf` Allows user to define multiple schema fo a single key. Key can be `anyOf` the given schemas or none of them. ```yaml # Accepts multiple types # @schema # anyOf: # - type: string # - type: integer # minimum: 0 # @schema foot: 1 # The above can be simplified with `type:` # @schema # type: [string, integer] # minimum: 0 # @schema fool: 1 # A pattern is also possible. # In this case null or some string starting with foo. # @schema # anyOf: # - type: "null" # - pattern: ^foo # @schema bar: ``` #### `oneOf` Allows user to define multiple schema fo a single key. Key must match `oneOf` the given schemas. ```yaml # @schema # oneOf: # - type: integer # - pattern: Gib$ # - pattern: gib$ # @schema storage: 30Gib ``` #### `allOf` Allows user to define multiple schema for a single key. Key must match `oneOf` the given schemas. ```yaml # @schema # allOf: # - type: string # pattern: Gib$ # - enum: [5Gib,10Gib,15Gib] # @schema storage: 10Gib ``` #### `not` Allows to define a schema that must not be matched. ```yaml # @schema # not: # type: string # @schema foo: bar ``` #### `if/then/else` Conditional schema settings with `if`/`then`/`else` ```yaml # @schema # anyOf: # - type: "null" # - type: string # if: # type: "null" # then: # description: It's a null value # else: # description: It's a string # @schema unknown: foo ``` #### `minLength` The value must be an integer greater or equal to zero and defines the minimum length of a string value. ```yaml # @schema # minLength: 1 # @schema namespace: foo ``` #### `maxLength` The value must be an integer greater than zero and defines the maximum length of a string value. ```yaml # @schema # maxLength: 3 # @schema namespace: foo ``` #### `minItems` The value must be an integer greater than zero and defines the minimum length of an array value. ```yaml # @schema # minItems: 1 # @schema namespace: - foo ``` #### `maxItems` The value must be an integer greater than zero and defines the maximum length of an array value. ```yaml # @schema # maxItems: 2 # @schema namespace: - foo - bar ``` #### `uniqueItems` A schema can ensure that each of the items in an array is unique. Simply set the uniqueItems keyword to true. ```yaml # @schema # uniqueItems: true # @schema namespace: - foo - bar ``` #### `$ref` The value must be an URI or relative file. Relative files are imported on creation time. If you update the referenced file, you need to run helm-schema again. **foo.json:** ```json { "foo": { "type": "string", "minLength": 10 } } ``` ```yaml # @schema # $ref: foo.json#/foo # @schema namespace: foo ``` is the same as ```yaml # @schema # type: string # minLength: 10 # @schema namespace: foo ``` #### `contains` Specifies that an array must contain at least one item matching the given schema. ```yaml # @schema # type: array # contains: # type: string # pattern: ^admin # @schema # At least one item must be a string starting with 'admin' users: - admin-user - regular-user ``` #### `additionalItems` Controls validation of array items beyond those specified in an `items` tuple. ```yaml # @schema # type: array # additionalItems: false # @schema # No additional items allowed beyond what's defined fixedArray: - foo - bar ``` #### `minProperties` Minimum number of properties an object must have. ```yaml # @schema # type: object # minProperties: 1 # @schema # Object must have at least one property config: {} ``` #### `maxProperties` Maximum number of properties an object can have. ```yaml # @schema # type: object # maxProperties: 5 # @schema # Object can have at most 5 properties labels: app: myapp env: prod ``` #### `propertyNames` Schema that all property names in an object must match. ```yaml # @schema # type: object # propertyNames: # pattern: ^[a-z][a-z0-9-]*$ # @schema # All property names must be lowercase with hyphens annotations: app-name: myapp version: v1 ``` #### `dependencies` Define property dependencies - when one property is present, others must be too. ```yaml # @schema # type: object # dependencies: # creditCard: [billingAddress] # billingAddress: [creditCard] # @schema # If creditCard is present, billingAddress must also be present payment: creditCard: "1234-5678" billingAddress: "123 Main St" ``` #### `definitions` Define reusable schema fragments that can be referenced with `$ref`. > [!NOTE] > When referencing external JSON Schema files that use `$defs` (JSON Schema Draft 2019-09+), helm-schema automatically converts them to `definitions` and rewrites `$ref` paths from `#/$defs/` to `#/definitions/` for Draft 7 compatibility. ```yaml # @schema # definitions: # port: # type: integer # minimum: 1 # maximum: 65535 # properties: # httpPort: # $ref: "#/definitions/port" # httpsPort: # $ref: "#/definitions/port" # @schema service: httpPort: 80 httpsPort: 443 ``` #### `$comment` Add comments for schema maintainers that won't be shown to end users. ```yaml # @schema # type: string # $comment: This field is deprecated and will be removed in v2.0 # @schema legacyField: foo ``` #### `contentEncoding` Specify the encoding for string content, such as base64. ```yaml # @schema # type: string # contentEncoding: base64 # @schema # Value is expected to be base64 encoded certificate: "LS0tLS1CRUdJTi..." ``` #### `contentMediaType` Specify the MIME type for string content. ```yaml # @schema # type: string # contentMediaType: application/json # contentEncoding: base64 # @schema # Value is base64-encoded JSON configData: "eyJmb28iOiAiYmFyIn0=" ``` ## License [MIT](https://github.com/dadav/helm-schema/blob/main/LICENSE) ================================================ FILE: cliff.toml ================================================ [changelog] header = "" body = """ {% if version %}## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }} {% else %}## Unreleased {% endif %} {% for group, commits in commits | group_by(attribute="group") %} ### {{ group }} {% for commit in commits %} - {{ commit.message | split(pat="\n") | first | trim }}{% if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/pull/{{ commit.remote.pr_number }})){% endif %}{% if commit.remote.username %} by @{{ commit.remote.username }}{% endif %} {% endfor %} {% endfor %} """ trim = true [git] conventional_commits = true filter_unconventional = true split_commits = false protect_breaking_commits = false filter_commits = true tag_pattern = "[0-9].*" sort_commits = "oldest" commit_parsers = [ { message = "^feat", group = "Features" }, { message = "^fix", group = "Bug Fixes" }, { message = "^docs?", group = "Documentation" }, { message = "^refactor", group = "Refactoring" }, { message = "^test", group = "Testing" }, { message = "^chore", group = "Chores" }, ] [remote.github] owner = "dadav" repo = "helm-schema" ================================================ FILE: cmd/helm-schema/cli.go ================================================ package main import ( "fmt" "os" "strings" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) func possibleLogLevels() []string { levels := make([]string, 0) for _, l := range log.AllLevels { levels = append(levels, l.String()) } return levels } func configureLogging() { logLevelName := viper.GetString("log-level") logLevel, err := log.ParseLevel(logLevelName) if err != nil { log.Errorf("Failed to parse provided log level %s: %s", logLevelName, err) os.Exit(1) } log.SetFormatter(&log.TextFormatter{FullTimestamp: true}) log.SetLevel(logLevel) } func newCommand(run func(cmd *cobra.Command, args []string) error) (*cobra.Command, error) { cmd := &cobra.Command{ Use: "helm-schema", Short: "helm-schema automatically generates a jsonschema file for helm charts from values files", Version: version, RunE: run, SilenceUsage: true, SilenceErrors: true, } logLevelUsage := fmt.Sprintf( "level of logs that should printed, one of (%s)", strings.Join(possibleLogLevels(), ", "), ) cmd.PersistentFlags(). StringP("chart-search-root", "c", ".", "directory to search recursively within for charts") cmd.PersistentFlags(). BoolP("dry-run", "d", false, "don't actually create files just print to stdout passed") cmd.PersistentFlags(). BoolP("append-newline", "a", false, "append newline to generated jsonschema at the end of the file") cmd.PersistentFlags(). BoolP("keep-full-comment", "s", false, "keep the whole leading comment (default: cut at empty line)") cmd.PersistentFlags(). BoolP("uncomment", "u", false, "consider yaml which is commented out") cmd.PersistentFlags(). BoolP("helm-docs-compatibility-mode", "p", false, "parse and use helm-docs comments") cmd.PersistentFlags(). BoolP("dont-strip-helm-docs-prefix", "x", false, "disable the removal of the helm-docs prefix (--)") cmd.PersistentFlags(). BoolP("no-dependencies", "n", false, "skip dependency charts: don't merge them into parents and don't generate their schemas") cmd.PersistentFlags(). BoolP("add-schema-reference", "r", false, "add reference to schema in values.yaml if not found") cmd.PersistentFlags().StringP("log-level", "l", "info", logLevelUsage) cmd.PersistentFlags(). StringSliceP("value-files", "f", []string{"values.yaml"}, "filenames to check for chart values") cmd.PersistentFlags(). StringP("output-file", "o", "values.schema.json", "jsonschema file path relative to each chart directory to which jsonschema will be written") cmd.PersistentFlags(). StringSliceP("skip-auto-generation", "k", []string{}, "comma separated list of fields to skip from being created by default (possible: title, description, required, default, additionalProperties)") cmd.PersistentFlags(). StringSliceP("dependencies-filter", "i", []string{}, "only generate schema for specified dependencies (comma-separated list of dependency names)") cmd.PersistentFlags(). BoolP("dont-add-global", "g", false, "dont auto add global property") cmd.PersistentFlags(). BoolP("skip-dependencies-schema-validation", "m", false, "skip schema validation for dependencies by setting additionalProperties to true and removing from required") cmd.PersistentFlags(). BoolP("allow-circular-dependencies", "w", false, "allow circular dependencies between charts (will log a warning instead of failing)") cmd.PersistentFlags(). BoolP("annotate", "A", false, "write inferred @schema annotations into values.yaml files for unannotated keys") cmd.PersistentFlags(). BoolP("keep-existing-dep-schemas", "K", false, "use dependency charts' pre-existing values.schema.json instead of regenerating from values.yaml") viper.AutomaticEnv() viper.SetEnvPrefix("HELM_SCHEMA") viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) err := viper.BindPFlags(cmd.PersistentFlags()) return cmd, err } ================================================ FILE: cmd/helm-schema/main.go ================================================ package main import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "runtime" "slices" "strings" "sync" "github.com/dadav/helm-schema/pkg/chart" "github.com/dadav/helm-schema/pkg/chart/searching" "github.com/dadav/helm-schema/pkg/schema" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) // getDependencyNames extracts dependency names (or aliases if present) from a chart // filtering based on the provided dependenciesFilterMap func getDependencyNames(dependencies []*chart.Dependency, dependenciesFilterMap map[string]bool) []string { var depNames []string for _, dep := range dependencies { if len(dependenciesFilterMap) > 0 && !dependenciesFilterMap[dep.Name] { continue } if dep.Alias != "" { depNames = append(depNames, dep.Alias) } else if dep.Name != "" { depNames = append(depNames, dep.Name) } } return depNames } // mergeSchemaProperties merges properties from source to target schema. // It skips "global" and properties in the skip map, and returns merged property names. // Follows Helm's value coalescing behavior with one exception: // - If target has explicit @schema annotation (HasData=true), target wins // - If target only has inferred schema (HasData=false), source wins func mergeSchemaProperties( target *schema.Schema, source *schema.Schema, skip map[string]bool, sourceName string, targetName string, ) map[string]bool { merged := make(map[string]bool) if source.Properties == nil { return merged } if target.Properties == nil { target.Properties = make(map[string]*schema.Schema) } for propName, propSchema := range source.Properties { if propName == "global" { continue } if skip != nil && skip[propName] { continue } existingProp, exists := target.Properties[propName] if !exists { target.Properties[propName] = propSchema merged[propName] = true } else if !existingProp.HasData && propSchema.HasData { // Target only has inferred schema, source has explicit annotation - source wins target.Properties[propName] = propSchema merged[propName] = true log.Debugf("Property %s from %s replaces inferred schema in %s", propName, sourceName, targetName) } else if existingProp.HasData { // Target has explicit @schema annotation, keep it log.Debugf("Property %s from %s skipped: %s has explicit @schema annotation", propName, sourceName, targetName) } else { // Both are inferred schemas, keep target (first wins) log.Debugf("Property %s from %s skipped: both schemas are inferred, keeping first", propName, sourceName) } } return merged } // processImportValues processes the import-values directive for a dependency. // It returns a map of property names that were imported (to track what was handled). func processImportValues( parentSchema *schema.Schema, depSchema *schema.Schema, dep *chart.Dependency, parentChartName string, ) map[string]bool { importedProps := make(map[string]bool) if len(dep.ImportValues) == 0 { return importedProps } for _, importValue := range dep.ImportValues { var childPath, parentPath string switch v := importValue.(type) { case string: // Simple form: "defaults" -> imports from exports. to root childPath = "exports." + v parentPath = "" case map[string]interface{}: // Complex form: {child: "path", parent: "path"} if child, ok := v["child"].(string); ok { childPath = child } if parent, ok := v["parent"].(string); ok { parentPath = parent } case map[interface{}]interface{}: // YAML sometimes produces this type variation if child, ok := v["child"].(string); ok { childPath = child } if parent, ok := v["parent"].(string); ok { parentPath = parent } default: log.Warnf("Unknown import-values format for dependency %s in chart %s: %T", dep.Name, parentChartName, importValue) continue } if childPath == "" { log.Warnf("Empty child path in import-values for dependency %s in chart %s", dep.Name, parentChartName) continue } // Get the source schema from the dependency sourceSchema := depSchema.GetPropertyAtPath(childPath) if sourceSchema == nil { log.Warnf("Could not find path %q in dependency %s schema for chart %s", childPath, dep.Name, parentChartName) continue } if sourceSchema.Properties == nil { log.Warnf("No properties found at path %q in dependency %s for chart %s", childPath, dep.Name, parentChartName) continue } // Determine target schema in parent var targetSchema *schema.Schema if parentPath == "" { targetSchema = parentSchema } else { targetSchema = parentSchema.SetPropertyAtPath(parentPath) } merged := mergeSchemaProperties( targetSchema, sourceSchema, nil, fmt.Sprintf("import-values of %s", dep.Name), parentChartName, ) targetPathDisplay := parentPath if targetPathDisplay == "" { targetPathDisplay = "root" } for k := range merged { importedProps[k] = true log.Debugf("Imported property %q from %s.%s to %s in chart %s", k, dep.Name, childPath, targetPathDisplay, parentChartName) } } return importedProps } func exec(cmd *cobra.Command, _ []string) error { configureLogging() var skipAutoGeneration, valueFileNames []string chartSearchRoot := viper.GetString("chart-search-root") dryRun := viper.GetBool("dry-run") noDeps := viper.GetBool("no-dependencies") addSchemaReference := viper.GetBool("add-schema-reference") keepFullComment := viper.GetBool("keep-full-comment") helmDocsCompatibilityMode := viper.GetBool("helm-docs-compatibility-mode") uncomment := viper.GetBool("uncomment") outFile := viper.GetString("output-file") dontRemoveHelmDocsPrefix := viper.GetBool("dont-strip-helm-docs-prefix") appendNewline := viper.GetBool("append-newline") dependenciesFilter := viper.GetStringSlice("dependencies-filter") dependenciesFilterMap := make(map[string]bool) dontAddGlobal := viper.GetBool("dont-add-global") skipDepsSchemaValidation := viper.GetBool("skip-dependencies-schema-validation") allowCircularDeps := viper.GetBool("allow-circular-dependencies") annotate := viper.GetBool("annotate") keepExistingDepSchemas := viper.GetBool("keep-existing-dep-schemas") for _, dep := range dependenciesFilter { dependenciesFilterMap[dep] = true } if err := viper.UnmarshalKey("value-files", &valueFileNames); err != nil { return err } if err := viper.UnmarshalKey("skip-auto-generation", &skipAutoGeneration); err != nil { return err } workersCount := runtime.NumCPU() * 2 skipConfig, err := schema.NewSkipAutoGenerationConfig(skipAutoGeneration) if err != nil { return err } queue := make(chan string) resultsChan := make(chan schema.Result) results := []*schema.Result{} errs := make(chan error, 100) // Buffered to prevent deadlock when errors occur before goroutines start done := make(chan struct{}) tempDir := searching.SearchArchivesOpenTemp(chartSearchRoot, errs) if tempDir != "" { defer os.RemoveAll(tempDir) } go searching.SearchFiles(chartSearchRoot, chartSearchRoot, "Chart.yaml", dependenciesFilterMap, queue, errs) wg := sync.WaitGroup{} for i := 0; i < workersCount; i++ { wg.Add(1) go func() { defer wg.Done() schema.Worker( dryRun, uncomment, addSchemaReference, keepFullComment, helmDocsCompatibilityMode, dontRemoveHelmDocsPrefix, dontAddGlobal, annotate, valueFileNames, skipConfig, outFile, queue, resultsChan, ) }() } // Close resultsChan after all workers are done go func() { wg.Wait() close(resultsChan) close(done) }() // Collect results and errors until both channels are closed resultsChanOpen := true for resultsChanOpen { select { case err, ok := <-errs: if ok { log.Error(err) } case res, ok := <-resultsChan: if !ok { resultsChanOpen = false } else { results = append(results, &res) } } } // Drain any remaining errors drainErrors: for { select { case err, ok := <-errs: if ok { log.Error(err) } default: break drainErrors } } // In annotate mode, just report errors and return (no schema generation) if annotate { foundErrors := false for _, result := range results { if len(result.Errors) > 0 { foundErrors = true if result.Chart != nil { log.Errorf("Found %d errors while annotating chart %s (%s)", len(result.Errors), result.Chart.Name, result.ChartPath) } else { log.Errorf("Found %d errors while annotating chart %s", len(result.Errors), result.ChartPath) } for _, err := range result.Errors { log.Error(err) } } } if foundErrors { return errors.New("some errors were found") } return nil } if !noDeps { results, err = schema.TopoSort(results, allowCircularDeps) if err != nil { if _, ok := err.(*schema.CircularError); ok { log.Errorf("Error while sorting results: %s", err) return err } else { log.Warnf("Could not sort results: %s", err) } } } // Identify charts that are declared as dependencies of some other discovered // chart. Used both to skip dependency charts entirely with --no-dependencies // and to opt-in reuse of a dependency's pre-existing schema. isDependencyChart := make(map[string]bool) for _, result := range results { if result.Chart == nil || len(result.Errors) > 0 { continue } for _, dep := range result.Chart.Dependencies { isDependencyChart[dep.Name] = true } } // For dependency charts with pre-existing schema files, load them instead of // using the worker-generated schema from values.yaml. Opt-in via // --keep-existing-dep-schemas; default is to regenerate every discovered // chart's schema. if !noDeps && keepExistingDepSchemas { for _, result := range results { if result.Chart == nil || len(result.Errors) > 0 { continue } if !isDependencyChart[result.Chart.Name] { continue } schemaPath := filepath.Join(filepath.Dir(result.ChartPath), outFile) schemaData, err := os.ReadFile(schemaPath) if err != nil { continue } var existingSchema schema.Schema if err := json.Unmarshal(schemaData, &existingSchema); err != nil { log.Warnf("Found existing %s for dependency %s but failed to parse it: %s", outFile, result.Chart.Name, err) continue } log.Debugf("Using pre-existing schema for dependency chart %s", result.Chart.Name) result.Schema = existingSchema result.PreExistingSchema = true } } conditionsToPatch := make(map[string][][]string) if !noDeps { for _, result := range results { if len(result.Errors) > 0 { continue } for _, dep := range result.Chart.Dependencies { if len(dependenciesFilterMap) > 0 && !dependenciesFilterMap[dep.Name] { continue } if dep.Condition != "" { conditionKeys := strings.Split(dep.Condition, ".") if len(conditionKeys) == 1 { continue } targetName := conditionKeys[0] if dep.Alias != "" && dep.Alias == conditionKeys[0] { targetName = dep.Name } if targetName != "" { conditionsToPatch[targetName] = append(conditionsToPatch[targetName], conditionKeys[1:]) } } } } } chartNameToResult := make(map[string]*schema.Result) foundErrors := false for _, result := range results { if len(result.Errors) > 0 { foundErrors = true if result.Chart != nil { log.Errorf( "Found %d errors while processing the chart %s (%s)", len(result.Errors), result.Chart.Name, result.ChartPath, ) } else { log.Errorf("Found %d errors while processing the chart %s", len(result.Errors), result.ChartPath) } for _, err := range result.Errors { log.Error(err) } continue } if result.Chart == nil { log.Warnf("Skipping result with nil Chart at path: %s", result.ChartPath) continue } // With --no-dependencies, skip charts that are declared as dependencies of // some other discovered chart. Top-level charts are still processed. if noDeps && isDependencyChart[result.Chart.Name] { log.Debugf("Skipping dependency chart %s (--no-dependencies)", result.Chart.Name) continue } log.Debugf("Processing result for chart: %s (%s)", result.Chart.Name, result.ChartPath) if !noDeps { chartNameToResult[result.Chart.Name] = result log.Debugf("Stored chart %s in chartNameToResult", result.Chart.Name) if patches, ok := conditionsToPatch[result.Chart.Name]; ok { for _, patch := range patches { schemaToPatch := &result.Schema lastIndex := len(patch) - 1 for i, key := range patch { // Ensure Properties map is initialized if schemaToPatch.Properties == nil { schemaToPatch.Properties = make(map[string]*schema.Schema) } if alreadyPresentSchema, ok := schemaToPatch.Properties[key]; !ok { log.Debugf( "Patching conditional field \"%s\" into schema of chart %s", key, result.Chart.Name, ) if i == lastIndex { schemaToPatch.Properties[key] = &schema.Schema{ Type: []string{"boolean"}, Title: key, Description: "Conditional property used in parent chart", } } else { schemaToPatch.Properties[key] = &schema.Schema{ Type: []string{"object"}, Title: key, Properties: make(map[string]*schema.Schema), } schemaToPatch = schemaToPatch.Properties[key] } } else { schemaToPatch = alreadyPresentSchema } } } } for _, dep := range result.Chart.Dependencies { if len(dependenciesFilterMap) > 0 && !dependenciesFilterMap[dep.Name] { continue } if dep.Name != "" { if dependencyResult, ok := chartNameToResult[dep.Name]; ok { log.Debugf( "Found chart of dependency %s (%s)", dependencyResult.Chart.Name, dependencyResult.ChartPath, ) // Process import-values first (before regular dependency nesting) importedProps := processImportValues( &result.Schema, &dependencyResult.Schema, dep, result.Chart.Name, ) hasImportValues := len(dep.ImportValues) > 0 // Check if this is a library chart if dependencyResult.Chart.Type == "library" { // For library charts, merge properties directly into parent schema log.Debugf("Merging library chart %s properties into parent chart %s at top level", dep.Name, result.Chart.Name) mergeSchemaProperties( &result.Schema, &dependencyResult.Schema, importedProps, fmt.Sprintf("library chart %s", dep.Name), fmt.Sprintf("parent chart %s", result.Chart.Name), ) } else if !hasImportValues { // For non-library charts WITHOUT import-values, nest under dependency name // (If import-values is used, user explicitly controls what's imported) depSchema := schema.Schema{ Type: []string{"object"}, Title: dep.Name, Description: dependencyResult.Chart.Description, Properties: dependencyResult.Schema.Properties, } if dep.Condition != "" && !strings.Contains(dep.Condition, ".") { depSchema.Type = []string{"object", "boolean"} } depSchema.DisableRequiredProperties() if dep.Alias != "" { result.Schema.Properties[dep.Alias] = &depSchema } else { result.Schema.Properties[dep.Name] = &depSchema } } } else { log.Warnf("Dependency (%s->%s) specified but no schema found. If you want to create jsonschemas for external dependencies, you need to run helm dep up", result.Chart.Name, dep.Name) } } else { log.Warnf("Dependency without name found (checkout %s).", result.ChartPath) } } } // Handle skip-dependencies-schema-validation flag if skipDepsSchemaValidation && !noDeps { // Collect dependency names using helper function depNames := getDependencyNames(result.Chart.Dependencies, dependenciesFilterMap) // Remove dependency names from required properties oldRequired := result.Schema.Required.Strings var newRequired []string for _, n := range oldRequired { if !slices.Contains(depNames, n) { newRequired = append(newRequired, n) } } result.Schema.Required.Strings = newRequired // Set additionalProperties to true for dependency schemas for _, depName := range depNames { if prop, ok := result.Schema.Properties[depName]; ok && prop != nil { log.Debugf("Setting additionalProperties to true for dependency %s in chart %s", depName, result.Chart.Name) additionalPropsTrue := true prop.AdditionalProperties = &additionalPropsTrue } } } // Hoist all nested definitions to the root level so $ref pointers resolve correctly result.Schema.HoistDefinitions() // Skip writing output for dependency charts with pre-existing schema files if result.PreExistingSchema { log.Debugf("Skipping output for dependency chart %s: using pre-existing schema", result.Chart.Name) continue } jsonStr, err := result.Schema.ToJson() if err != nil { log.Error(err) continue } if appendNewline { jsonStr = append(jsonStr, '\n') } if dryRun { log.Infof("Printing jsonschema for %s chart (%s)", result.Chart.Name, result.ChartPath) if appendNewline { fmt.Printf("%s", jsonStr) } else { fmt.Printf("%s\n", jsonStr) } } else { chartBasePath := filepath.Dir(result.ChartPath) if err := os.WriteFile(filepath.Join(chartBasePath, outFile), jsonStr, 0o644); err != nil { errs <- err continue } } } if foundErrors { return errors.New("some errors were found") } return nil } func main() { command, err := newCommand(exec) if err != nil { log.Errorf("Failed to create the CLI commander: %s", err) os.Exit(1) } if err := command.Execute(); err != nil { log.Errorf("Execution error: %s", err) os.Exit(1) } } ================================================ FILE: cmd/helm-schema/main_test.go ================================================ package main import ( "encoding/json" "os" "path/filepath" "testing" "github.com/spf13/viper" "github.com/stretchr/testify/assert" ) type stringOrArray []string func (s *stringOrArray) UnmarshalJSON(value []byte) error { if len(value) == 0 { return nil } if value[0] == '"' { var single string if err := json.Unmarshal(value, &single); err != nil { return err } *s = []string{single} return nil } var multi []string if err := json.Unmarshal(value, &multi); err != nil { return err } *s = multi return nil } type schemaDoc struct { Properties map[string]schemaProperty `json:"properties"` } type schemaProperty struct { Type stringOrArray `json:"type"` Properties map[string]schemaProperty `json:"properties"` } func TestExec_ConditionPatchingAndRootConditions(t *testing.T) { tmpDir := t.TempDir() writeFile := func(relPath, content string) { path := filepath.Join(tmpDir, relPath) err := os.MkdirAll(filepath.Dir(path), 0o755) assert.NoError(t, err) err = os.WriteFile(path, []byte(content), 0o644) assert.NoError(t, err) } writeFile("dep/Chart.yaml", ` apiVersion: v2 name: dep version: 1.0.0 `) writeFile("dep/values.yaml", ` key: value `) writeFile("dep2/Chart.yaml", ` apiVersion: v2 name: dep2 version: 1.0.0 `) writeFile("dep2/values.yaml", ` key: value `) writeFile("parent1/Chart.yaml", ` apiVersion: v2 name: parent1 version: 1.0.0 dependencies: - name: dep version: 1.0.0 condition: dep.enabled - name: dep2 version: 1.0.0 condition: dep2 `) writeFile("parent1/values.yaml", ` root: value `) writeFile("parent2/Chart.yaml", ` apiVersion: v2 name: parent2 version: 1.0.0 dependencies: - name: dep version: 1.0.0 condition: dep.extra.flag `) writeFile("parent2/values.yaml", ` root: value `) viper.Reset() viper.Set("chart-search-root", tmpDir) viper.Set("dry-run", false) viper.Set("no-dependencies", false) viper.Set("add-schema-reference", false) viper.Set("keep-full-comment", false) viper.Set("helm-docs-compatibility-mode", false) viper.Set("uncomment", false) viper.Set("output-file", "values.schema.json") viper.Set("dont-strip-helm-docs-prefix", false) viper.Set("append-newline", false) viper.Set("dependencies-filter", []string{}) viper.Set("dont-add-global", true) viper.Set("skip-dependencies-schema-validation", false) viper.Set("allow-circular-dependencies", false) viper.Set("annotate", false) viper.Set("keep-existing-dep-schemas", false) viper.Set("value-files", []string{"values.yaml"}) viper.Set("skip-auto-generation", []string{}) viper.Set("log-level", "info") err := exec(nil, nil) assert.NoError(t, err) depSchemaPath := filepath.Join(tmpDir, "dep", "values.schema.json") depSchemaBytes, err := os.ReadFile(depSchemaPath) assert.NoError(t, err) var depSchema schemaDoc err = json.Unmarshal(depSchemaBytes, &depSchema) assert.NoError(t, err) enabledProp, ok := depSchema.Properties["enabled"] assert.True(t, ok) assert.Contains(t, []string(enabledProp.Type), "boolean") extraProp, ok := depSchema.Properties["extra"] assert.True(t, ok) flagProp, ok := extraProp.Properties["flag"] assert.True(t, ok) assert.Contains(t, []string(flagProp.Type), "boolean") parent1SchemaPath := filepath.Join(tmpDir, "parent1", "values.schema.json") parent1SchemaBytes, err := os.ReadFile(parent1SchemaPath) assert.NoError(t, err) var parent1Schema schemaDoc err = json.Unmarshal(parent1SchemaBytes, &parent1Schema) assert.NoError(t, err) dep2Prop, ok := parent1Schema.Properties["dep2"] assert.True(t, ok) assert.Contains(t, []string(dep2Prop.Type), "object") assert.Contains(t, []string(dep2Prop.Type), "boolean") } func TestExec_DependencyFilterSkipsConditionPatching(t *testing.T) { tmpDir := t.TempDir() writeFile := func(relPath, content string) { path := filepath.Join(tmpDir, relPath) err := os.MkdirAll(filepath.Dir(path), 0o755) assert.NoError(t, err) err = os.WriteFile(path, []byte(content), 0o644) assert.NoError(t, err) } writeFile("dep/Chart.yaml", ` apiVersion: v2 name: dep version: 1.0.0 `) writeFile("dep/values.yaml", ` key: value `) writeFile("dep2/Chart.yaml", ` apiVersion: v2 name: dep2 version: 1.0.0 `) writeFile("dep2/values.yaml", ` key: value `) writeFile("Chart.yaml", ` apiVersion: v2 name: parent version: 1.0.0 dependencies: - name: dep version: 1.0.0 condition: dep.enabled - name: dep2 version: 1.0.0 condition: dep2.flag `) writeFile("values.yaml", ` root: value `) viper.Reset() viper.Set("chart-search-root", tmpDir) viper.Set("dry-run", false) viper.Set("no-dependencies", false) viper.Set("add-schema-reference", false) viper.Set("keep-full-comment", false) viper.Set("helm-docs-compatibility-mode", false) viper.Set("uncomment", false) viper.Set("output-file", "values.schema.json") viper.Set("dont-strip-helm-docs-prefix", false) viper.Set("append-newline", false) viper.Set("dependencies-filter", []string{"dep"}) viper.Set("dont-add-global", true) viper.Set("skip-dependencies-schema-validation", false) viper.Set("allow-circular-dependencies", false) viper.Set("annotate", false) viper.Set("keep-existing-dep-schemas", false) viper.Set("value-files", []string{"values.yaml"}) viper.Set("skip-auto-generation", []string{}) viper.Set("log-level", "info") err := exec(nil, nil) assert.NoError(t, err) parentSchemaPath := filepath.Join(tmpDir, "values.schema.json") parentSchemaBytes, err := os.ReadFile(parentSchemaPath) assert.NoError(t, err) var parentSchema schemaDoc err = json.Unmarshal(parentSchemaBytes, &parentSchema) assert.NoError(t, err) _, hasDep := parentSchema.Properties["dep"] _, hasDep2 := parentSchema.Properties["dep2"] assert.True(t, hasDep) assert.False(t, hasDep2) depSchemaPath := filepath.Join(tmpDir, "dep", "values.schema.json") depSchemaBytes, err := os.ReadFile(depSchemaPath) assert.NoError(t, err) var depSchema schemaDoc err = json.Unmarshal(depSchemaBytes, &depSchema) assert.NoError(t, err) enabledProp, ok := depSchema.Properties["enabled"] assert.True(t, ok) assert.Contains(t, []string(enabledProp.Type), "boolean") dep2SchemaPath := filepath.Join(tmpDir, "dep2", "values.schema.json") _, err = os.Stat(dep2SchemaPath) assert.True(t, os.IsNotExist(err)) } func TestExec_DependencyAliasConditionPatching(t *testing.T) { tmpDir := t.TempDir() writeFile := func(relPath, content string) { path := filepath.Join(tmpDir, relPath) err := os.MkdirAll(filepath.Dir(path), 0o755) assert.NoError(t, err) err = os.WriteFile(path, []byte(content), 0o644) assert.NoError(t, err) } writeFile("dep/Chart.yaml", ` apiVersion: v2 name: dep version: 1.0.0 `) writeFile("dep/values.yaml", ` key: value `) writeFile("Chart.yaml", ` apiVersion: v2 name: parent version: 1.0.0 dependencies: - name: dep alias: depalias version: 1.0.0 condition: depalias.enabled `) writeFile("values.yaml", ` root: value `) viper.Reset() viper.Set("chart-search-root", tmpDir) viper.Set("dry-run", false) viper.Set("no-dependencies", false) viper.Set("add-schema-reference", false) viper.Set("keep-full-comment", false) viper.Set("helm-docs-compatibility-mode", false) viper.Set("uncomment", false) viper.Set("output-file", "values.schema.json") viper.Set("dont-strip-helm-docs-prefix", false) viper.Set("append-newline", false) viper.Set("dependencies-filter", []string{}) viper.Set("dont-add-global", true) viper.Set("skip-dependencies-schema-validation", false) viper.Set("allow-circular-dependencies", false) viper.Set("annotate", false) viper.Set("keep-existing-dep-schemas", false) viper.Set("value-files", []string{"values.yaml"}) viper.Set("skip-auto-generation", []string{}) viper.Set("log-level", "info") err := exec(nil, nil) assert.NoError(t, err) depSchemaPath := filepath.Join(tmpDir, "dep", "values.schema.json") depSchemaBytes, err := os.ReadFile(depSchemaPath) assert.NoError(t, err) var depSchema schemaDoc err = json.Unmarshal(depSchemaBytes, &depSchema) assert.NoError(t, err) enabledProp, ok := depSchema.Properties["enabled"] assert.True(t, ok) assert.Contains(t, []string(enabledProp.Type), "boolean") parentSchemaPath := filepath.Join(tmpDir, "values.schema.json") parentSchemaBytes, err := os.ReadFile(parentSchemaPath) assert.NoError(t, err) var parentSchema schemaDoc err = json.Unmarshal(parentSchemaBytes, &parentSchema) assert.NoError(t, err) _, ok = parentSchema.Properties["depalias"] assert.True(t, ok) } func TestExec_KeepExistingDepSchemasPreservesDependencySchema(t *testing.T) { tmpDir := t.TempDir() writeFile := func(relPath, content string) { path := filepath.Join(tmpDir, relPath) err := os.MkdirAll(filepath.Dir(path), 0o755) assert.NoError(t, err) err = os.WriteFile(path, []byte(content), 0o644) assert.NoError(t, err) } writeFile("dep/Chart.yaml", ` apiVersion: v2 name: dep version: 1.0.0 `) writeFile("dep/values.yaml", ` port: 8080 `) preExistingDepSchema := `{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "port": { "type": "integer", "description": "The port to listen on", "minimum": 1, "maximum": 65535, "x-custom-annotation": "preserve-me" } }, "required": ["port"] } ` writeFile("dep/values.schema.json", preExistingDepSchema) writeFile("parent/Chart.yaml", ` apiVersion: v2 name: parent version: 1.0.0 dependencies: - name: dep version: 1.0.0 repository: file://../dep `) writeFile("parent/values.yaml", ` root: value `) viper.Reset() viper.Set("chart-search-root", tmpDir) viper.Set("dry-run", false) viper.Set("no-dependencies", false) viper.Set("add-schema-reference", false) viper.Set("keep-full-comment", false) viper.Set("helm-docs-compatibility-mode", false) viper.Set("uncomment", false) viper.Set("output-file", "values.schema.json") viper.Set("dont-strip-helm-docs-prefix", false) viper.Set("append-newline", false) viper.Set("dependencies-filter", []string{}) viper.Set("dont-add-global", true) viper.Set("skip-dependencies-schema-validation", false) viper.Set("allow-circular-dependencies", false) viper.Set("annotate", false) viper.Set("keep-existing-dep-schemas", true) viper.Set("value-files", []string{"values.yaml"}) viper.Set("skip-auto-generation", []string{}) viper.Set("log-level", "info") err := exec(nil, nil) assert.NoError(t, err) depSchemaPath := filepath.Join(tmpDir, "dep", "values.schema.json") depSchemaBytes, err := os.ReadFile(depSchemaPath) assert.NoError(t, err) assert.Equal(t, preExistingDepSchema, string(depSchemaBytes), "dependency's pre-existing values.schema.json must not be overwritten when --keep-existing-dep-schemas is set") parentSchemaPath := filepath.Join(tmpDir, "parent", "values.schema.json") parentSchemaBytes, err := os.ReadFile(parentSchemaPath) assert.NoError(t, err) var parentRaw map[string]any err = json.Unmarshal(parentSchemaBytes, &parentRaw) assert.NoError(t, err) props, _ := parentRaw["properties"].(map[string]any) depNode, ok := props["dep"].(map[string]any) assert.True(t, ok, "parent must nest dependency schema under dep key") depProps, _ := depNode["properties"].(map[string]any) portNode, ok := depProps["port"].(map[string]any) assert.True(t, ok, "parent must include port from merged dependency schema") assert.Equal(t, "preserve-me", portNode["x-custom-annotation"], "pre-existing x-* annotations must be carried into the merged parent schema") } func TestExec_DefaultRegeneratesDependencySchema(t *testing.T) { tmpDir := t.TempDir() writeFile := func(relPath, content string) { path := filepath.Join(tmpDir, relPath) err := os.MkdirAll(filepath.Dir(path), 0o755) assert.NoError(t, err) err = os.WriteFile(path, []byte(content), 0o644) assert.NoError(t, err) } writeFile("dep/Chart.yaml", ` apiVersion: v2 name: dep version: 1.0.0 `) writeFile("dep/values.yaml", ` port: 8080 `) preExistingDepSchema := `{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "port": { "type": "integer", "x-custom-annotation": "should-be-lost" } } } ` writeFile("dep/values.schema.json", preExistingDepSchema) writeFile("parent/Chart.yaml", ` apiVersion: v2 name: parent version: 1.0.0 dependencies: - name: dep version: 1.0.0 repository: file://../dep `) writeFile("parent/values.yaml", ` root: value `) viper.Reset() viper.Set("chart-search-root", tmpDir) viper.Set("dry-run", false) viper.Set("no-dependencies", false) viper.Set("add-schema-reference", false) viper.Set("keep-full-comment", false) viper.Set("helm-docs-compatibility-mode", false) viper.Set("uncomment", false) viper.Set("output-file", "values.schema.json") viper.Set("dont-strip-helm-docs-prefix", false) viper.Set("append-newline", false) viper.Set("dependencies-filter", []string{}) viper.Set("dont-add-global", true) viper.Set("skip-dependencies-schema-validation", false) viper.Set("allow-circular-dependencies", false) viper.Set("annotate", false) viper.Set("keep-existing-dep-schemas", false) viper.Set("value-files", []string{"values.yaml"}) viper.Set("skip-auto-generation", []string{}) viper.Set("log-level", "info") err := exec(nil, nil) assert.NoError(t, err) depSchemaPath := filepath.Join(tmpDir, "dep", "values.schema.json") depSchemaBytes, err := os.ReadFile(depSchemaPath) assert.NoError(t, err) assert.NotEqual(t, preExistingDepSchema, string(depSchemaBytes), "default mode must regenerate a dependency's values.schema.json") } func TestExec_NoDependenciesSkipsDependencyCharts(t *testing.T) { tmpDir := t.TempDir() writeFile := func(relPath, content string) { path := filepath.Join(tmpDir, relPath) err := os.MkdirAll(filepath.Dir(path), 0o755) assert.NoError(t, err) err = os.WriteFile(path, []byte(content), 0o644) assert.NoError(t, err) } writeFile("foo/Chart.yaml", ` apiVersion: v2 name: foo version: 1.0.0 dependencies: - name: bar version: 1.0.0 repository: file://./bar `) writeFile("foo/values.yaml", ` top: 1 `) writeFile("foo/bar/Chart.yaml", ` apiVersion: v2 name: bar version: 1.0.0 `) writeFile("foo/bar/values.yaml", ` inside: 2 `) viper.Reset() viper.Set("chart-search-root", filepath.Join(tmpDir, "foo")) viper.Set("dry-run", false) viper.Set("no-dependencies", true) viper.Set("add-schema-reference", false) viper.Set("keep-full-comment", false) viper.Set("helm-docs-compatibility-mode", false) viper.Set("uncomment", false) viper.Set("output-file", "values.schema.json") viper.Set("dont-strip-helm-docs-prefix", false) viper.Set("append-newline", false) viper.Set("dependencies-filter", []string{}) viper.Set("dont-add-global", true) viper.Set("skip-dependencies-schema-validation", false) viper.Set("allow-circular-dependencies", false) viper.Set("annotate", false) viper.Set("keep-existing-dep-schemas", false) viper.Set("value-files", []string{"values.yaml"}) viper.Set("skip-auto-generation", []string{}) viper.Set("log-level", "info") err := exec(nil, nil) assert.NoError(t, err) parentSchemaPath := filepath.Join(tmpDir, "foo", "values.schema.json") _, err = os.Stat(parentSchemaPath) assert.NoError(t, err, "parent chart schema must still be generated with --no-dependencies") depSchemaPath := filepath.Join(tmpDir, "foo", "bar", "values.schema.json") _, err = os.Stat(depSchemaPath) assert.True(t, os.IsNotExist(err), "dependency chart schema must not be generated with --no-dependencies (issue #215)") } ================================================ FILE: cmd/helm-schema/version.go ================================================ package main var version string = "0.23.2" ================================================ FILE: examples/Chart.yaml ================================================ apiVersion: v2 name: example description: A Helm chart for Kubernetes # A chart can be either an 'application' or a 'library' chart. # # Application charts are a collection of templates that can be packaged into versioned archives # to be deployed. # # Library charts provide useful utilities or functions for the chart developer. They're included as # a dependency of application charts to inject those utilities and functions into the rendering # pipeline. Library charts do not define any templates and therefore cannot be deployed. type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) version: 0.1.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. appVersion: "1.16.0" ================================================ FILE: examples/values.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" }, "service": { "additionalProperties": true, "properties": { "conf": { "additionalProperties": false, "examples": [ "API_PROVIDER_ONE: api-key-x", "EMAIL_ADMIN: admin@example.org" ], "patternProperties": { "^API_.*": { "pattern": "^api-key", "type": "string" }, "^EMAIL_.*": { "format": "idn-email", "type": "string" } }, "required": [], "title": "conf", "type": "object" }, "contact": { "default": "", "examples": [ "name@domain.tld" ], "format": "idn-email", "required": [], "title": "contact" }, "enabled": { "default": true, "description": "Type will be parsed as boolean", "title": "enabled", "type": "boolean" }, "env": { "additionalProperties": false, "description": "Environment variables. If you want to provide auto-completion to the user", "properties": { "ADMIN_EMAIL": { "examples": [ "admin@example.org" ], "format": "idn-email", "title": "ADMIN_EMAIL", "type": "string" }, "API_URL": { "examples": [ "https://api.example.org" ], "format": "idn-hostname", "title": "API_URL", "type": "string" }, "CONFIG_PATH": { "description": "The local path to the service configuration file", "examples": [ "/path/to/config" ], "title": "CONFIG_PATH", "type": "string" } }, "required": [], "title": "env" }, "externalIP": { "default": "443", "enum": [ 443, 80 ], "required": [], "title": "externalIP" }, "hosts": { "description": "Will give auto-completion for the below structure\nhosts:\n - name:\n url: my.example.org", "items": { "properties": { "host": { "properties": { "url": { "format": "idn-hostname", "type": "string" } }, "required": [], "type": "object" } }, "required": [], "type": "object" }, "title": "hosts", "type": "array" }, "maintainer": { "const": "maintainer@example.org", "default": "maintainer@example.org", "required": [], "title": "maintainer" }, "name": { "anyOf": [ { "required": [] }, { "pattern": "^foo-", "required": [] } ], "default": "", "description": "Name of the deployed service. Defined in the schema annotation", "required": [], "title": "name" }, "otherExternalIP": { "default": 443, "examples": [ 443, 80 ], "title": "otherExternalIP", "type": "integer" }, "port": { "default": 80, "maximum": 89, "minimum": 80, "title": "port", "type": "integer" }, "storage": { "default": "10Gib", "examples": [ "5Gib", "10Gib", "20Gib" ], "pattern": "^[1-9][0-9]*Gib$", "title": "storage", "type": "string" }, "telemetry": { "default": true, "title": "telemetry", "type": "boolean" }, "type": { "default": "application", "enum": [ "application", "controller", "api" ], "required": [], "title": "type" } }, "required": [ "enabled" ], "title": "service" } }, "required": [], "type": "object" } ================================================ FILE: examples/values.yaml ================================================ # vim: set ft=yaml: # yaml-language-server: $schema=values.schema.json # This is an example values.yaml file, it aims to show how to annotate the keys, and it's its only purpose. # The corresponding values.schema.json has been generated with the below command. # helm-schema -n -k additionalProperties # @schema # additionalProperties: true # @schema service: # Type will be parsed as boolean enabled: true # @schema # type: integer # minimum: 80 # maximum: 89 # @schema port: 80 # @schema # enum: [443, 80] # @schema externalIP: 443 # @schema # type: integer # examples: [443, 80] # @schema otherExternalIP: 443 # @schema # description: Name of the deployed service. Defined in the schema annotation # anyOf: # - type: null # - pattern: ^foo- # @schema # This comment will not be parsed as 'description', the 'description' field take precedence over comments name: # @schema # properties: # CONFIG_PATH: # title: CONFIG_PATH # type: string # description: The local path to the service configuration file # examples: [/path/to/config] # ADMIN_EMAIL: # title: ADMIN_EMAIL # type: string # format: idn-email # examples: [admin@example.org] # API_URL: # title: API_URL # type: string # format: idn-hostname # examples: [https://api.example.org] # @schema # -- Environment variables. If you want to provide auto-completion to the user env: {} # @schema # type: object # patternProperties: # "^API_.*": # type: string # pattern: ^api-key # "^EMAIL_.*": # type: string # format: idn-email # examples: ["API_PROVIDER_ONE: api-key-x","EMAIL_ADMIN: admin@example.org"] # @schema conf: {} # @schema # format: idn-email # examples: [name@domain.tld] # @schema contact: "" # @schema # type: boolean # default: true # @schema telemetry: true # @schema # type: array # items: # type: object # properties: # host: # type: object # properties: # url: # type: string # format: idn-hostname # @schema # Will give auto-completion for the below structure # hosts: # - name: # url: my.example.org hosts: [] # @schema # enum: # - application # - controller # - api # @schema type: application # @schema # const: maintainer@example.org # @schema maintainer: maintainer@example.org # @schema # type: string # pattern: ^[1-9][0-9]*Gib$ # examples: ["5Gib","10Gib","20Gib"] # @schema storage: "10Gib" ================================================ FILE: go.mod ================================================ module github.com/dadav/helm-schema go 1.25.0 require ( github.com/dadav/go-jsonpointer v0.0.0-20240918181927-335cbee8c279 github.com/magiconair/properties v1.8.10 github.com/norwoodj/helm-docs v1.14.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect helm.sh/helm/v3 v3.20.2 // indirect ) ================================================ FILE: go.sum ================================================ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dadav/go-jsonpointer v0.0.0-20240918181927-335cbee8c279 h1:GxxxrAMM6YJK4Tf7c6APwkhXzzpJm2hGeWKpD3OKpHY= github.com/dadav/go-jsonpointer v0.0.0-20240918181927-335cbee8c279/go.mod h1:JoQhlwhuWFnmxzDNgByW0ONYJZjQGnUnRu9H7+C0lTU= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/norwoodj/helm-docs v1.14.2 h1:Ew3bCq1hZqMnnTopkk66Uy2mGwu/jAclAx+3JAVp1To= github.com/norwoodj/helm-docs v1.14.2/go.mod h1:qdo76rorOkPDme8nsV5e0JBAYrs56kzvZMYW83k1kgc= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= helm.sh/helm/v3 v3.20.2 h1:binM4rvPx5DcNsa1sIt7UZi55lRbu3pZUFmQkSoRh48= helm.sh/helm/v3 v3.20.2/go.mod h1:Fl1kBaWCpkUrM6IYXPjQ3bdZQfFrogKArqptvueZ6Ww= ================================================ FILE: install-binary.sh ================================================ #!/usr/bin/env sh # Shamelessly copied from https://github.com/technosophos/helm-template/blob/master/install-binary.sh PROJECT_NAME="helm-schema" BINARY_NAME="helm-schema" PROJECT_GH="dadav/$PROJECT_NAME" PLUGIN_MANIFEST="plugin.yaml" # Convert HELM_BIN and HELM_PLUGIN_DIR to unix if cygpath is # available. This is the case when using MSYS2 or Cygwin # on Windows where helm returns a Windows path but we # need a Unix path if command -v cygpath >/dev/null 2>&1; then HELM_BIN="$(cygpath -u "${HELM_BIN}")" HELM_PLUGIN_DIR="$(cygpath -u "${HELM_PLUGIN_DIR}")" fi [ -z "$HELM_BIN" ] && HELM_BIN=$(command -v helm) [ -z "$HELM_HOME" ] && HELM_HOME=$(helm env | grep 'HELM_DATA_HOME' | cut -d '=' -f2 | tr -d '"') mkdir -p "$HELM_HOME" if [ "$SKIP_BIN_INSTALL" = "1" ]; then echo "Skipping binary install" exit fi # which mode is the common installer script running in. SCRIPT_MODE="install" if [ "$1" = "-u" ]; then SCRIPT_MODE="update" fi # initArch discovers the architecture for this system. initArch() { ARCH=$(uname -m) case $ARCH in armv6*) ARCH="armv6" ;; armv7*) ARCH="armv7" ;; aarch64 | arm64) ARCH="arm64" ;; x86_64 | amd64) ARCH="x86_64" ;; *) echo "Arch '$(uname -m)' not supported!" >&2 exit 1 ;; esac } # initOS discovers the operating system for this system. initOS() { OS=$(uname -s) case "$OS" in Windows_NT) OS='Windows' ;; # Msys support MSYS*) OS='Windows' ;; # Minimalist GNU for Windows MINGW*) OS='Windows' ;; CYGWIN*) OS='Windows' ;; Darwin) OS='Darwin' ;; Linux) OS='Linux' ;; *) echo "OS '$(uname)' not supported!" >&2 exit 1 ;; esac } # verifySupported checks that the os/arch combination is supported for binary builds. verifySupported() { supported="Linux-x86_64\nLinux-arm64\nLinux-armv6\nLinux-armv7\nDarwin-x86_64\nDarwin-arm64\nWindows-x86_64\nWindows-arm64\nWindows-armv6\nWindows-armv7" if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then echo "No prebuild binary for ${OS}-${ARCH}." exit 1 fi if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1 then echo "Either curl or wget is required" exit 1 fi } # getDownloadURL retrieves the download URL and checksum URL. getDownloadURL() { version="$(grep <"$HELM_PLUGIN_DIR/$PLUGIN_MANIFEST" "version" | cut -d '"' -f 2)" ext="tar.gz" if [ "$OS" = "Windows" ]; then ext="zip" fi if [ "$SCRIPT_MODE" = "install" ] && [ -n "$version" ]; then DOWNLOAD_URL="https://github.com/${PROJECT_GH}/releases/download/${version}/${PROJECT_NAME}_${version}_${OS}_${ARCH}.${ext}" CHECKSUM_URL="https://github.com/${PROJECT_GH}/releases/download/${version}/checksums.txt" else DOWNLOAD_URL="https://github.com/${PROJECT_GH}/releases/latest/download/${PROJECT_NAME}_${version}_${OS}_${ARCH}.${ext}" CHECKSUM_URL="https://github.com/${PROJECT_GH}/releases/latest/download/checksums.txt" fi } # Temporary dir mkTempDir() { HELM_TMP="$(mktemp -d -t "${PROJECT_NAME}-XXXXXX")" } rmTempDir() { if [ -d "${HELM_TMP:-/tmp/helm-schema}" ]; then rm -rf "${HELM_TMP:-/tmp/helm-schema}" fi } # downloadFile downloads the latest binary package and the checksum. downloadFile() { PLUGIN_TMP_FILE="${HELM_TMP}/${PROJECT_NAME}.tar.gz" PLUGIN_CHECKSUMS_FILE="${HELM_TMP}/${PROJECT_NAME}_checksums.txt" echo "Downloading ..." echo "$DOWNLOAD_URL" echo "$CHECKSUM_URL" if command -v curl >/dev/null 2>&1 then curl -sSf -L "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" curl -sSf -L "$CHECKSUM_URL" >"$PLUGIN_CHECKSUMS_FILE" elif command -v wget >/dev/null 2>&1 then wget -q -O - "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" wget -q -O - "$CHECKSUM_URL" >"$PLUGIN_CHECKSUMS_FILE" fi } validateChecksum() { if ! grep -q ${1} ${2}; then echo "Invalid checksum" >/dev/stderr exit 1 fi echo "Checksum is valid." } # installFile verifies the SHA256 for the file, then unpacks and installs it. installFile() { if command -v sha256sum >/dev/null 2>&1; then checksum=$(sha256sum ${PLUGIN_TMP_FILE} | awk '{ print $1 }') validateChecksum ${checksum} ${PLUGIN_CHECKSUMS_FILE} elif command -v openssl >/dev/null 2>&1; then checksum=$(openssl dgst -sha256 ${PLUGIN_TMP_FILE} | awk '{ print $2 }') validateChecksum ${checksum} ${PLUGIN_CHECKSUMS_FILE} else echo "WARNING: no tool found to verify checksum" >/dev/stderr fi HELM_TMP_BIN="$HELM_TMP/$BINARY_NAME" if [ "${OS}" = "Windows" ]; then HELM_TMP_BIN="$HELM_TMP_BIN.exe" unzip "$PLUGIN_TMP_FILE" -d "$HELM_TMP" else tar xzf "$PLUGIN_TMP_FILE" -C "$HELM_TMP" fi echo "Preparing to install into ${HELM_PLUGIN_DIR}" mkdir -p "$HELM_PLUGIN_DIR/bin" cp "$HELM_TMP_BIN" "$HELM_PLUGIN_DIR/bin" } # exit_trap is executed if on exit (error or not). exit_trap() { result=$? rmTempDir if [ "$result" != "0" ]; then echo "Failed to install $PROJECT_NAME" printf "\tFor support, go to https://github.com/%s.\n" "$PROJECT_GH" fi exit $result } # testVersion tests the installed client to make sure it is working. testVersion() { set +e echo "$PROJECT_NAME installed into $HELM_PLUGIN_DIR" "${HELM_PLUGIN_DIR}/bin/$BINARY_NAME" --version set -e } # Execution #Stop execution on any error trap "exit_trap" EXIT set -e initArch initOS verifySupported getDownloadURL mkTempDir downloadFile installFile testVersion ================================================ FILE: pkg/chart/chart.go ================================================ package chart import ( "io" "github.com/dadav/helm-schema/pkg/util" yaml "gopkg.in/yaml.v3" ) type Dependency struct { Name string `yaml:"name"` Version string `yaml:"version"` Condition string `yaml:"condition,omitempty"` Repository string `yaml:"repository,omitempty"` Alias string `yaml:"alias,omitempty"` ImportValues []interface{} `yaml:"import-values,omitempty"` // Tags []string `yaml:"tags,omitempty"` } // Maintainer describes a Chart maintainer. // https://github.com/helm/helm/blob/main/pkg/chart/metadata.go#L26C1-L34C2 type Maintainer struct { // Name is a user name or organization name Name string `json:"name,omitempty"` // Email is an optional email address to contact the named maintainer Email string `json:"email,omitempty"` // URL is an optional URL to an address for the named maintainer URL string `json:"url,omitempty"` } // https://github.com/helm/helm/blob/main/pkg/chart/metadata.go#L48 type ChartFile struct { // The name of the chart. Required. Name string `yaml:"name,omitempty"` // The URL to a relevant project page, git repo, or contact person Home string `yaml:"home,omitempty"` // Source is the URL to the source code of this chart Sources []string `yaml:"sources,omitempty"` // A SemVer 2 conformant version string of the chart. Required. Version string `yaml:"version,omitempty"` // A one-sentence description of the chart Description string `yaml:"description,omitempty"` // A list of string keywords Keywords []string `yaml:"keywords,omitempty"` // A list of name and URL/email address combinations for the maintainer(s) Maintainers []*Maintainer `yaml:"maintainers,omitempty"` // The URL to an icon file. Icon string `yaml:"icon,omitempty"` // The API Version of this chart. Required. APIVersion string `yaml:"apiVersion,omitempty"` // The condition to check to enable chart Condition string `yaml:"condition,omitempty"` // The tags to check to enable chart Tags string `yaml:"tags,omitempty"` // The version of the application enclosed inside of this chart. AppVersion string `yaml:"appVersion,omitempty"` // Whether or not this chart is deprecated Deprecated bool `yaml:"deprecated,omitempty"` // Annotations are additional mappings uninterpreted by Helm, // made available for inspection by other applications. Annotations map[string]string `yaml:"annotations,omitempty"` // KubeVersion is a SemVer constraint specifying the version of Kubernetes required. KubeVersion string `yaml:"kubeVersion,omitempty"` // Dependencies are a list of dependencies for a chart. Dependencies []*Dependency `yaml:"dependencies,omitempty"` // Specifies the chart type: application or library Type string `yaml:"type,omitempty"` } // ReadChart parses the given yaml into a ChartFile struct func ReadChart(reader io.Reader) (ChartFile, error) { var chart ChartFile chartContent, err := util.ReadFileAndFixNewline(reader) if err != nil { return chart, err } err = yaml.Unmarshal(chartContent, &chart) if err != nil { return chart, err } return chart, nil } ================================================ FILE: pkg/chart/chart_test.go ================================================ package chart import ( "bytes" "testing" "gopkg.in/yaml.v3" ) func TestReadChartFile(t *testing.T) { data := []byte(` name: test description: test dependencies: - name: test `) r := bytes.NewReader(data) c, err := ReadChart(r) if err != nil { t.Errorf("Error while reading test data: %v", err) } if c.Name != "test" { t.Errorf("Expected Name was test, but got %v", c.Name) } if c.Description != "test" { t.Errorf("Expected Description was test, but got %v", c.Description) } if len(c.Dependencies) != 1 { t.Errorf("Expected to find one dependency, but got %d", len(c.Dependencies)) } if c.Dependencies[0].Name != "test" { t.Errorf("Expected Dependency name was test, but got %v", c.Dependencies[0].Name) } } func TestImportValuesParsing(t *testing.T) { tests := []struct { name string input string expected []interface{} wantErr bool }{ { name: "simple string import-values", input: ` name: dep1 import-values: - defaults`, expected: []interface{}{"defaults"}, wantErr: false, }, { name: "complex map import-values", input: ` name: dep1 import-values: - child: exports.data parent: mydata`, expected: []interface{}{ map[string]interface{}{ "child": "exports.data", "parent": "mydata", }, }, wantErr: false, }, { name: "mixed import-values", input: ` name: dep1 import-values: - defaults - child: exports.config parent: config`, expected: []interface{}{ "defaults", map[string]interface{}{ "child": "exports.config", "parent": "config", }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var dep Dependency err := yaml.Unmarshal([]byte(tt.input), &dep) if (err != nil) != tt.wantErr { t.Errorf("yaml.Unmarshal() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr { if len(dep.ImportValues) != len(tt.expected) { t.Errorf("ImportValues length = %v, want %v", len(dep.ImportValues), len(tt.expected)) return } for i, val := range dep.ImportValues { switch expected := tt.expected[i].(type) { case string: if val != expected { t.Errorf("ImportValues[%d] = %v, want %v", i, val, expected) } case map[string]interface{}: valMap, ok := val.(map[string]interface{}) if !ok { t.Errorf("ImportValues[%d] is not a map, got %T", i, val) continue } for k, v := range expected { if valMap[k] != v { t.Errorf("ImportValues[%d][%s] = %v, want %v", i, k, valMap[k], v) } } } } } }) } } func TestChartFileParsing(t *testing.T) { tests := []struct { name string input string expected ChartFile wantErr bool }{ { name: "basic chart file", input: ` name: mychart description: A test chart dependencies: - name: dep1 alias: aliased-dep condition: subchart.enabled`, expected: ChartFile{ Name: "mychart", Description: "A test chart", Dependencies: []*Dependency{ { Name: "dep1", Alias: "aliased-dep", Condition: "subchart.enabled", }, }, }, wantErr: false, }, { name: "chart file without dependencies", input: ` name: standalone description: A standalone chart`, expected: ChartFile{ Name: "standalone", Description: "A standalone chart", }, wantErr: false, }, { name: "invalid yaml", input: ` name: broken description: [broken`, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var got ChartFile err := yaml.Unmarshal([]byte(tt.input), &got) if (err != nil) != tt.wantErr { t.Errorf("yaml.Unmarshal() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr { if got.Name != tt.expected.Name { t.Errorf("Name = %v, want %v", got.Name, tt.expected.Name) } if got.Description != tt.expected.Description { t.Errorf("Description = %v, want %v", got.Description, tt.expected.Description) } if len(got.Dependencies) != len(tt.expected.Dependencies) { t.Errorf("Dependencies length = %v, want %v", len(got.Dependencies), len(tt.expected.Dependencies)) return } for i, dep := range got.Dependencies { if dep.Name != tt.expected.Dependencies[i].Name { t.Errorf("Dependency[%d].Name = %v, want %v", i, dep.Name, tt.expected.Dependencies[i].Name) } if dep.Alias != tt.expected.Dependencies[i].Alias { t.Errorf("Dependency[%d].Alias = %v, want %v", i, dep.Alias, tt.expected.Dependencies[i].Alias) } if dep.Condition != tt.expected.Dependencies[i].Condition { t.Errorf("Dependency[%d].Condition = %v, want %v", i, dep.Condition, tt.expected.Dependencies[i].Condition) } } } }) } } ================================================ FILE: pkg/chart/searching/dependency_charts.go ================================================ package searching import ( "archive/tar" "compress/gzip" "fmt" "github.com/dadav/helm-schema/pkg/chart" "gopkg.in/yaml.v3" "io" "os" "path/filepath" "strings" ) func extractTGZ(src, dest string) error { file, err := os.Open(src) if err != nil { return err } defer file.Close() // Open gzip reader gzr, err := gzip.NewReader(file) if err != nil { return err } defer gzr.Close() // Open tar reader tr := tar.NewReader(gzr) // Extract files for { header, err := tr.Next() if err == io.EOF { break } if err != nil { return err } // Resolve and sanitize file path cleanName := filepath.Clean(header.Name) // Prevent absolute paths if filepath.IsAbs(cleanName) { return fmt.Errorf("tar entry has absolute path: %s", cleanName) } // Prevent path traversal outside dest target := filepath.Join(dest, cleanName) rel, err := filepath.Rel(dest, target) if err != nil { return fmt.Errorf("failed to get relative path: %v", err) } if strings.HasPrefix(rel, "..") || rel == ".." { return fmt.Errorf("tar entry attempts to write outside destination: %s", cleanName) } switch header.Typeflag { case tar.TypeDir: // Create directory if not exists if err := os.MkdirAll(target, 0755); err != nil { return err } case tar.TypeReg: // Ensure directory exists if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } // Create file outFile, err := os.Create(target) if err != nil { return err } // Copy file content if _, err := io.Copy(outFile, tr); err != nil { outFile.Close() return err } if err := outFile.Close(); err != nil { return err } } } return nil } func SearchFiles(chartSearchRoot, startPath, fileName string, dependenciesFilter map[string]bool, queue chan<- string, errs chan<- error) { defer close(queue) err := filepath.Walk(startPath, func(path string, info os.FileInfo, err error) error { if err != nil { errs <- err return nil } if !info.IsDir() && info.Name() == fileName { if filepath.Dir(path) == chartSearchRoot { queue <- path return nil } if len(dependenciesFilter) > 0 { chartData, err := os.ReadFile(path) if err != nil { errs <- fmt.Errorf("failed to read Chart.yaml at %s: %w", path, err) return nil } var chartFile chart.ChartFile if err := yaml.Unmarshal(chartData, &chartFile); err != nil { errs <- fmt.Errorf("failed to parse Chart.yaml at %s: %w", path, err) return nil } if dependenciesFilter[chartFile.Name] { queue <- path } } else { queue <- path } } return nil }) if err != nil { errs <- err } } func SearchArchivesOpenTemp(startPath string, errs chan<- error) string { tempDir := "" tempDirCreationFailed := false err := filepath.Walk(startPath, func(path string, info os.FileInfo, err error) error { if err != nil { errs <- err return nil } if strings.HasSuffix(info.Name(), ".tgz") || strings.HasSuffix(info.Name(), ".tar.gz") { // Skip extraction if temp dir creation previously failed if tempDirCreationFailed { return nil } // Extract archived charts from deps if tempDir == "" { relativeDir := filepath.Dir(path) var mkdirErr error tempDir, mkdirErr = os.MkdirTemp(relativeDir, "tmp-*") if mkdirErr != nil { errs <- fmt.Errorf("failed to create temp directory for chart extraction: %w", mkdirErr) tempDirCreationFailed = true return nil } } if extractErr := extractTGZ(path, tempDir); extractErr != nil { errs <- fmt.Errorf("failed to extract %s: %w", path, extractErr) return nil } } return nil }) if err != nil { errs <- err } return tempDir } ================================================ FILE: pkg/schema/annotate.go ================================================ package schema import ( "fmt" "os" "sort" "strings" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) // HasSchemaAnnotation checks if a HeadComment already contains a # @schema block. // It matches exact "# @schema" lines but not "# @schema.root" lines. func HasSchemaAnnotation(comment string) bool { for _, line := range strings.Split(comment, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "# @schema" { return true } } return false } // typeAnnotationFromTag maps a YAML tag to the annotation type string. // Uses the same mapping as typeFromTag. func typeAnnotationFromTag(tag string) string { switch tag { case nullTag: return `"null"` case boolTag: return "boolean" case strTag: return "string" case intTag: return "integer" case floatTag: return "number" case timestampTag: return "string" case arrayTag: return "array" case mapTag: return "object" default: return "" } } // InsertionPoint represents where to insert an annotation block in the file. type InsertionPoint struct { Line int // 1-based line number of the key node Indent string // indentation string (spaces) derived from keyNode.Column TypeStr string // type annotation value } // collectInsertionPoints walks the yaml.Node tree and collects InsertionPoints // for keys that don't already have @schema annotations. func collectInsertionPoints(node *yaml.Node) []InsertionPoint { var points []InsertionPoint collectInsertionPointsRecursive(node, &points) return points } func collectInsertionPointsRecursive(node *yaml.Node, points *[]InsertionPoint) { if node == nil { return } switch node.Kind { case yaml.DocumentNode: for _, child := range node.Content { collectInsertionPointsRecursive(child, points) } case yaml.MappingNode: for i := 0; i < len(node.Content)-1; i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] if !HasSchemaAnnotation(keyNode.HeadComment) { typeStr := typeAnnotationFromTag(valueNode.Tag) if typeStr != "" { indent := strings.Repeat(" ", keyNode.Column-1) *points = append(*points, InsertionPoint{ Line: keyNode.Line, Indent: indent, TypeStr: typeStr, }) } } // Recurse into mapping values for nested keys if valueNode.Kind == yaml.MappingNode { collectInsertionPointsRecursive(valueNode, points) } // Handle alias nodes that point to mappings if valueNode.Kind == yaml.AliasNode && valueNode.Alias != nil && valueNode.Alias.Kind == yaml.MappingNode { collectInsertionPointsRecursive(valueNode.Alias, points) } } } } // AnnotateContent parses YAML content, collects insertion points for keys // that lack @schema annotations, and inserts type annotation blocks. // Returns the modified content. func AnnotateContent(content []byte) ([]byte, error) { var doc yaml.Node if err := yaml.Unmarshal(content, &doc); err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) } points := collectInsertionPoints(&doc) if len(points) == 0 { return content, nil } lines := strings.Split(string(content), "\n") // Sort by line number descending so insertions don't shift earlier line numbers sort.Slice(points, func(i, j int) bool { return points[i].Line > points[j].Line }) for _, pt := range points { // pt.Line is 1-based; convert to 0-based index targetIdx := pt.Line - 1 if targetIdx < 0 || targetIdx >= len(lines) { continue } // Walk backwards past any existing HeadComment lines (lines starting with #) // to insert the annotation above existing comments insertIdx := targetIdx for insertIdx > 0 { candidate := strings.TrimSpace(lines[insertIdx-1]) if strings.HasPrefix(candidate, "#") { insertIdx-- } else { break } } // Build the 3 annotation lines annotationLines := []string{ pt.Indent + "# @schema", pt.Indent + "# type: " + pt.TypeStr, pt.Indent + "# @schema", } // Insert at insertIdx newLines := make([]string, 0, len(lines)+3) newLines = append(newLines, lines[:insertIdx]...) newLines = append(newLines, annotationLines...) newLines = append(newLines, lines[insertIdx:]...) lines = newLines } return []byte(strings.Join(lines, "\n")), nil } // AnnotateValuesFile reads a values.yaml file, annotates unannotated keys // with @schema type blocks, and writes the result back (or prints to stdout if dryRun). func AnnotateValuesFile(valuesPath string, dryRun bool) error { fileInfo, err := os.Stat(valuesPath) if err != nil { return fmt.Errorf("failed to stat %s: %w", valuesPath, err) } perm := fileInfo.Mode().Perm() content, err := os.ReadFile(valuesPath) if err != nil { return fmt.Errorf("failed to read %s: %w", valuesPath, err) } annotated, err := AnnotateContent(content) if err != nil { return fmt.Errorf("failed to annotate %s: %w", valuesPath, err) } if dryRun { log.Infof("Annotated values for %s", valuesPath) fmt.Print(string(annotated)) return nil } if err := os.WriteFile(valuesPath, annotated, perm); err != nil { return fmt.Errorf("failed to write %s: %w", valuesPath, err) } log.Infof("Annotated %s", valuesPath) return nil } ================================================ FILE: pkg/schema/annotate_test.go ================================================ package schema import ( "strings" "testing" "gopkg.in/yaml.v3" ) func TestHasSchemaAnnotation(t *testing.T) { tests := []struct { name string comment string want bool }{ { name: "empty comment", comment: "", want: false, }, { name: "no schema annotation", comment: "# This is a normal comment", want: false, }, { name: "has schema annotation", comment: "# @schema\n# type: string\n# @schema", want: true, }, { name: "has schema.root only", comment: "# @schema.root\n# title: foo\n# @schema.root", want: false, }, { name: "has both schema and schema.root", comment: "# @schema.root\n# title: foo\n# @schema.root\n# @schema\n# type: string\n# @schema", want: true, }, { name: "schema prefix but not exact", comment: "# @schema.something", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := HasSchemaAnnotation(tt.comment) if got != tt.want { t.Errorf("HasSchemaAnnotation(%q) = %v, want %v", tt.comment, got, tt.want) } }) } } func TestTypeAnnotationFromTag(t *testing.T) { tests := []struct { tag string want string }{ {"!!null", `"null"`}, {"!!bool", "boolean"}, {"!!str", "string"}, {"!!int", "integer"}, {"!!float", "number"}, {"!!timestamp", "string"}, {"!!seq", "array"}, {"!!map", "object"}, {"!!unknown", ""}, } for _, tt := range tests { t.Run(tt.tag, func(t *testing.T) { got := typeAnnotationFromTag(tt.tag) if got != tt.want { t.Errorf("typeAnnotationFromTag(%q) = %q, want %q", tt.tag, got, tt.want) } }) } } func TestCollectInsertionPoints(t *testing.T) { tests := []struct { name string yaml string wantCount int wantTypes []string }{ { name: "simple flat yaml", yaml: "name: hello\nport: 80\nenabled: true\n", wantCount: 3, wantTypes: []string{"string", "integer", "boolean"}, }, { name: "nested objects", yaml: "service:\n type: ClusterIP\n port: 80\n", wantCount: 3, wantTypes: []string{"object", "string", "integer"}, }, { name: "already annotated key", yaml: "# @schema\n# type: string\n# @schema\nname: hello\nport: 80\n", wantCount: 1, wantTypes: []string{"integer"}, }, { name: "null value", yaml: "key:\n", wantCount: 1, wantTypes: []string{`"null"`}, }, { name: "array value", yaml: "items: []\n", wantCount: 1, wantTypes: []string{"array"}, }, { name: "empty map value", yaml: "config: {}\n", wantCount: 1, wantTypes: []string{"object"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var doc yaml.Node if err := yaml.Unmarshal([]byte(tt.yaml), &doc); err != nil { t.Fatalf("failed to parse YAML: %v", err) } points := collectInsertionPoints(&doc) if len(points) != tt.wantCount { t.Errorf("got %d insertion points, want %d", len(points), tt.wantCount) for _, p := range points { t.Logf(" line=%d type=%s", p.Line, p.TypeStr) } } for i, wantType := range tt.wantTypes { if i >= len(points) { break } if points[i].TypeStr != wantType { t.Errorf("point[%d].TypeStr = %q, want %q", i, points[i].TypeStr, wantType) } } }) } } func TestAnnotateContent(t *testing.T) { tests := []struct { name string input string want string wantErr bool }{ { name: "simple unannotated file", input: "port: 80\n", want: "# @schema\n# type: integer\n# @schema\nport: 80\n", }, { name: "multiple keys", input: "name: hello\nport: 80\n", want: "# @schema\n# type: string\n# @schema\nname: hello\n# @schema\n# type: integer\n# @schema\nport: 80\n", }, { name: "already fully annotated", input: "# @schema\n# type: integer\n# @schema\nport: 80\n", want: "# @schema\n# type: integer\n# @schema\nport: 80\n", }, { name: "partially annotated", input: "# @schema\n# type: string\n# @schema\nname: hello\nport: 80\n", want: "# @schema\n# type: string\n# @schema\nname: hello\n# @schema\n# type: integer\n# @schema\nport: 80\n", }, { name: "nested objects with indentation", input: "service:\n type: ClusterIP\n port: 80\n", want: "# @schema\n# type: object\n# @schema\nservice:\n # @schema\n # type: string\n # @schema\n type: ClusterIP\n # @schema\n # type: integer\n # @schema\n port: 80\n", }, { name: "key with existing comment - annotation goes above comment", input: "# This is the port\nport: 80\n", want: "# @schema\n# type: integer\n# @schema\n# This is the port\nport: 80\n", }, { name: "empty file", input: "", want: "", }, { name: "document separator preserved", input: "---\nport: 80\n", want: "---\n# @schema\n# type: integer\n# @schema\nport: 80\n", }, { name: "boolean value", input: "enabled: true\n", want: "# @schema\n# type: boolean\n# @schema\nenabled: true\n", }, { name: "null value", input: "key:\n", want: "# @schema\n# type: \"null\"\n# @schema\nkey:\n", }, { name: "array value", input: "items: []\n", want: "# @schema\n# type: array\n# @schema\nitems: []\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := AnnotateContent([]byte(tt.input)) if (err != nil) != tt.wantErr { t.Fatalf("AnnotateContent() error = %v, wantErr %v", err, tt.wantErr) } if string(got) != tt.want { t.Errorf("AnnotateContent() mismatch\ngot:\n%s\nwant:\n%s", string(got), tt.want) // Show diff line by line gotLines := strings.Split(string(got), "\n") wantLines := strings.Split(tt.want, "\n") maxLen := len(gotLines) if len(wantLines) > maxLen { maxLen = len(wantLines) } for i := 0; i < maxLen; i++ { var g, w string if i < len(gotLines) { g = gotLines[i] } if i < len(wantLines) { w = wantLines[i] } if g != w { t.Errorf(" line %d: got=%q want=%q", i, g, w) } } } }) } } ================================================ FILE: pkg/schema/err.go ================================================ package schema type CircularError struct { msg string } func (e *CircularError) Error() string { return e.msg } ================================================ FILE: pkg/schema/err_test.go ================================================ package schema import "testing" func TestCircularError(t *testing.T) { tests := []struct { name string message string want string }{ { name: "basic error message", message: "circular dependency detected", want: "circular dependency detected", }, { name: "empty message", message: "", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := &CircularError{msg: tt.message} if got := err.Error(); got != tt.want { t.Errorf("CircularError.Error() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/schema/root_schema_test.go ================================================ package schema import ( "strings" "testing" "gopkg.in/yaml.v3" ) func TestGetRootSchemaFromComment(t *testing.T) { tests := []struct { name string comment string expectedHasData bool expectedTitle string expectedDescription string expectedRemaining string expectError bool }{ { name: "simple root schema", comment: `# @schema.root # title: My Chart # description: A chart for testing # @schema.root # This is a key comment key: value`, expectedHasData: true, expectedTitle: "My Chart", expectedDescription: "A chart for testing", expectedRemaining: `# This is a key comment key: value`, }, { name: "root schema with custom annotations", comment: `# @schema.root # title: Advanced Chart # x-custom-field: custom-value # additionalProperties: true # @schema.root # Key description`, expectedHasData: true, expectedTitle: "Advanced Chart", expectedDescription: "", expectedRemaining: `# Key description`, }, { name: "no root schema", comment: `# @schema # type: string # @schema # Just a regular comment`, expectedHasData: false, expectedRemaining: `# @schema # type: string # @schema # Just a regular comment`, }, { name: "unclosed root schema block", comment: `# @schema.root # title: Unclosed # This will fail`, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { schema, remaining, err := GetRootSchemaFromComment(tt.comment) if tt.expectError { if err == nil { t.Errorf("Expected an error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } if schema.HasData != tt.expectedHasData { t.Errorf("Expected HasData=%v, got %v", tt.expectedHasData, schema.HasData) } if tt.expectedHasData { if schema.Title != tt.expectedTitle { t.Errorf("Expected Title=%q, got %q", tt.expectedTitle, schema.Title) } if schema.Description != tt.expectedDescription { t.Errorf("Expected Description=%q, got %q", tt.expectedDescription, schema.Description) } } if strings.TrimSpace(remaining) != strings.TrimSpace(tt.expectedRemaining) { t.Errorf("Expected remaining=%q, got %q", tt.expectedRemaining, remaining) } }) } } func TestYamlToSchemaWithRootAnnotations(t *testing.T) { tests := []struct { name string yamlContent string expectedTitle string expectedDescription string expectedAdditionalProp interface{} expectedCustomField interface{} }{ { name: "basic root schema", yamlContent: `# @schema.root # title: Test Chart Values # description: Test description # @schema.root foo: bar`, expectedTitle: "Test Chart Values", expectedDescription: "Test description", }, { name: "root schema with additionalProperties", yamlContent: `# @schema.root # title: Flexible Chart # additionalProperties: true # @schema.root service: port: 8080`, expectedTitle: "Flexible Chart", expectedAdditionalProp: true, }, { name: "root schema with custom annotations", yamlContent: `# @schema.root # title: Custom Chart # x-helm-version: "3.0" # @schema.root app: myapp`, expectedTitle: "Custom Chart", expectedCustomField: "3.0", }, { name: "root schema with required array", yamlContent: `# @schema.root # required: [keycloak, apps] # @schema.root keycloak: url: "" apps: decide: baseUrl: ""`, }, { name: "root schema separated by blank lines", yamlContent: `# @schema.root # additionalProperties: true # @schema.root # @schema # type: object # @schema _: {}`, expectedAdditionalProp: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var node yaml.Node err := yaml.Unmarshal([]byte(tt.yamlContent), &node) if err != nil { t.Fatalf("Failed to unmarshal YAML: %v", err) } skipConfig := &SkipAutoGenerationConfig{} schema, err := YamlToSchema("", &node, false, false, false, true, skipConfig, nil) if err != nil { t.Fatalf("YamlToSchema failed: %v", err) } if schema.Title != tt.expectedTitle { t.Errorf("Expected Title=%q, got %q", tt.expectedTitle, schema.Title) } if tt.expectedDescription != "" && schema.Description != tt.expectedDescription { t.Errorf("Expected Description=%q, got %q", tt.expectedDescription, schema.Description) } if tt.expectedAdditionalProp != nil { if schema.AdditionalProperties == nil { t.Errorf("Expected AdditionalProperties=%v, got nil", tt.expectedAdditionalProp) } else if schema.AdditionalProperties != tt.expectedAdditionalProp { t.Errorf("Expected AdditionalProperties=%v, got %v", tt.expectedAdditionalProp, schema.AdditionalProperties) } } if tt.expectedCustomField != nil { if schema.CustomAnnotations == nil { t.Errorf("Expected CustomAnnotations to contain x-helm-version, but CustomAnnotations is nil") } else if val, ok := schema.CustomAnnotations["x-helm-version"]; !ok { t.Errorf("Expected CustomAnnotations to contain x-helm-version") } else if val != tt.expectedCustomField { t.Errorf("Expected x-helm-version=%v, got %v", tt.expectedCustomField, val) } } if tt.name == "root schema with required array" { expectedRequired := []string{"keycloak", "apps"} if len(schema.Required.Strings) != len(expectedRequired) { t.Fatalf("Expected required=%v, got %v", expectedRequired, schema.Required.Strings) } for i, item := range expectedRequired { if schema.Required.Strings[i] != item { t.Fatalf("Expected required[%d]=%q, got %q", i, item, schema.Required.Strings[i]) } } } }) } } func TestRootSchemaDoesNotAffectKeyAnnotations(t *testing.T) { yamlContent := `# @schema.root # title: Root Title # description: Root description # @schema.root # @schema # enum: [dev, prod] # @schema # -- Environment setting environment: dev service: port: 8080` var node yaml.Node err := yaml.Unmarshal([]byte(yamlContent), &node) if err != nil { t.Fatalf("Failed to unmarshal YAML: %v", err) } skipConfig := &SkipAutoGenerationConfig{} schema, err := YamlToSchema("", &node, false, false, false, true, skipConfig, nil) if err != nil { t.Fatalf("YamlToSchema failed: %v", err) } // Check root schema if schema.Title != "Root Title" { t.Errorf("Expected root Title=%q, got %q", "Root Title", schema.Title) } if schema.Description != "Root description" { t.Errorf("Expected root Description=%q, got %q", "Root description", schema.Description) } // Check that environment key still has its own annotations if schema.Properties == nil { t.Fatal("Expected Properties to be set") } envSchema, ok := schema.Properties["environment"] if !ok { t.Fatal("Expected environment property to exist") } if len(envSchema.Enum) != 2 { t.Errorf("Expected 2 enum values, got %d", len(envSchema.Enum)) } if envSchema.Description != "Environment setting" { t.Errorf("Expected environment Description=%q, got %q", "Environment setting", envSchema.Description) } } ================================================ FILE: pkg/schema/schema.go ================================================ package schema import ( "bufio" "encoding/json" "errors" "fmt" "io" "os" "reflect" "regexp" "slices" "strconv" "strings" "github.com/dadav/go-jsonpointer" "github.com/dadav/helm-schema/pkg/util" "github.com/norwoodj/helm-docs/pkg/helm" "github.com/santhosh-tekuri/jsonschema/v6" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) // SchemaPrefix and CommentPrefix define the markers used for schema annotations in comments const ( SchemaPrefix = "# @schema" SchemaRootPrefix = "# @schema.root" CommentPrefix = "#" // CustomAnnotationPrefix marks custom annotations. // Custom annotations are extensions to the JSON Schema specification // See: https://json-schema.org/blog/posts/custom-annotations-will-continue CustomAnnotationPrefix = "x-" ) // YAML tag constants used for type inference const ( nullTag = "!!null" boolTag = "!!bool" strTag = "!!str" intTag = "!!int" floatTag = "!!float" timestampTag = "!!timestamp" arrayTag = "!!seq" mapTag = "!!map" ) // Precompiled regex patterns for better performance var ( leadingCommentsRemover = regexp.MustCompile(`(?s)(?m)(?:.*\n{2,})+`) helmDocsTagsRemover = regexp.MustCompile(`(?ms)(\r\n|\r|\n)?\s*@\w+(\s+--\s)?[^\n\r]*`) helmDocsPrefixRemover = regexp.MustCompile(`(?m)^--\s?`) ) // SchemaOrBool represents a JSON Schema field that can be either a boolean or a Schema object type SchemaOrBool interface{} // BoolOrArrayOfString represents a JSON Schema field that can be either a boolean or an array of strings // Used primarily for the "required" field which can be either true/false or an array of required property names type BoolOrArrayOfString struct { Strings []string Bool bool } func NewBoolOrArrayOfString(arr []string, b bool) BoolOrArrayOfString { return BoolOrArrayOfString{ Strings: arr, Bool: b, } } func (s *BoolOrArrayOfString) UnmarshalJSON(value []byte) error { var multi []string var single bool if err := json.Unmarshal(value, &multi); err == nil { s.Strings = multi } else if err := json.Unmarshal(value, &single); err == nil { s.Bool = single } return nil } func (s *BoolOrArrayOfString) MarshalJSON() ([]byte, error) { if len(s.Strings) > 0 { return json.Marshal(s.Strings) } // Return empty array - the Bool field is only used internally // to signal that the property should be added to parent's required list return json.Marshal([]string{}) } func (s *BoolOrArrayOfString) UnmarshalYAML(value *yaml.Node) error { var multi []string if value.ShortTag() == arrayTag { for _, v := range value.Content { var typeStr string err := v.Decode(&typeStr) if err != nil { return err } multi = append(multi, typeStr) } s.Strings = multi } else if value.ShortTag() == boolTag { var single bool err := value.Decode(&single) if err != nil { return err } s.Bool = single } else { return fmt.Errorf("could not unmarshal %v to slice of string or bool", value.Content) } return nil } type StringOrArrayOfString []string func (s *StringOrArrayOfString) UnmarshalYAML(value *yaml.Node) error { var multi []string if value.ShortTag() == arrayTag { for _, v := range value.Content { if v.ShortTag() == nullTag { multi = append(multi, "null") } else { var typeStr string err := v.Decode(&typeStr) if err != nil { return err } multi = append(multi, typeStr) } } *s = multi } else { var single string err := value.Decode(&single) if err != nil { return err } *s = []string{single} } return nil } func (s *StringOrArrayOfString) UnmarshalJSON(value []byte) error { var multi []string var single string if err := json.Unmarshal(value, &multi); err == nil { *s = multi } else if err := json.Unmarshal(value, &single); err == nil { *s = []string{single} } return nil } func (s *StringOrArrayOfString) MarshalJSON() ([]byte, error) { if len(*s) == 1 { return json.Marshal([]string(*s)[0]) } return json.Marshal([]string(*s)) } func (s *StringOrArrayOfString) Validate() error { // Check if type is valid for _, t := range []string(*s) { if t != "" && t != "object" && t != "string" && t != "integer" && t != "number" && t != "array" && t != "null" && t != "boolean" { return fmt.Errorf("unsupported type %s", t) } } return nil } func (s *StringOrArrayOfString) IsEmpty() bool { for _, t := range []string(*s) { if t == "" { return true } } return len(*s) == 0 } func (s *StringOrArrayOfString) canDropRequired() bool { ss := *s return len(ss) == 1 && (ss[0] == "string" || ss[0] == "number" || ss[0] == "boolean" || ss[0] == "integer" || ss[0] == "null" || ss[0] == "array") } func (s *StringOrArrayOfString) Matches(typeString string) bool { for _, t := range []string(*s) { if t == typeString { return true } } return false } // MarshalJSON custom marshal method for Schema. It inlines the CustomAnnotations fields func (s *Schema) MarshalJSON() ([]byte, error) { // Create a map to hold all the fields type Alias Schema data := make(map[string]interface{}) // Marshal the Schema struct (excluding CustomAnnotations) alias := (*Alias)(s) aliasJSON, err := json.Marshal(alias) if err != nil { return nil, err } // Unmarshal the JSON back into the map if err := json.Unmarshal(aliasJSON, &data); err != nil { return nil, err } // inline the CustomAnnotations fields for key, value := range s.CustomAnnotations { data[key] = value } delete(data, "CustomAnnotations") // Remove "required" if the schema type is not object if s.Type.canDropRequired() { delete(data, "required") } // Explicitly include const field when it was set to null // This handles the case where const: null in YAML should appear as "const": null in JSON if s.constWasSet && s.Const == nil { data["const"] = nil } // Marshal the final map into JSON return json.Marshal(data) } // Schema struct contains yaml tags for reading, json for writing (creating the jsonschema) type Schema struct { AdditionalProperties SchemaOrBool `yaml:"additionalProperties,omitempty" json:"additionalProperties,omitempty"` Default interface{} `yaml:"default,omitempty" json:"default,omitempty"` Then *Schema `yaml:"then,omitempty" json:"then,omitempty"` PatternProperties map[string]*Schema `yaml:"patternProperties,omitempty" json:"patternProperties,omitempty"` Properties map[string]*Schema `yaml:"properties,omitempty" json:"properties,omitempty"` If *Schema `yaml:"if,omitempty" json:"if,omitempty"` Minimum *float64 `yaml:"minimum,omitempty" json:"minimum,omitempty"` MultipleOf *float64 `yaml:"multipleOf,omitempty" json:"multipleOf,omitempty"` ExclusiveMaximum *float64 `yaml:"exclusiveMaximum,omitempty" json:"exclusiveMaximum,omitempty"` Items *Schema `yaml:"items,omitempty" json:"items,omitempty"` ExclusiveMinimum *float64 `yaml:"exclusiveMinimum,omitempty" json:"exclusiveMinimum,omitempty"` Maximum *float64 `yaml:"maximum,omitempty" json:"maximum,omitempty"` Else *Schema `yaml:"else,omitempty" json:"else,omitempty"` Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"` Const interface{} `yaml:"const,omitempty" json:"const,omitempty"` ConstFromValue bool `yaml:"const-from-value,omitempty" json:"-"` Ref string `yaml:"$ref,omitempty" json:"$ref,omitempty"` Schema string `yaml:"$schema,omitempty" json:"$schema,omitempty"` Id string `yaml:"$id,omitempty" json:"$id,omitempty"` Comment string `yaml:"$comment,omitempty" json:"$comment,omitempty"` Format string `yaml:"format,omitempty" json:"format,omitempty"` Description string `yaml:"description,omitempty" json:"description,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` ContentEncoding string `yaml:"contentEncoding,omitempty" json:"contentEncoding,omitempty"` ContentMediaType string `yaml:"contentMediaType,omitempty" json:"contentMediaType,omitempty"` Type StringOrArrayOfString `yaml:"type,omitempty" json:"type,omitempty"` AnyOf []*Schema `yaml:"anyOf,omitempty" json:"anyOf,omitempty"` AllOf []*Schema `yaml:"allOf,omitempty" json:"allOf,omitempty"` OneOf []*Schema `yaml:"oneOf,omitempty" json:"oneOf,omitempty"` Not *Schema `yaml:"not,omitempty" json:"not,omitempty"` Examples []interface{} `yaml:"examples,omitempty" json:"examples,omitempty"` Enum []interface{} `yaml:"enum,omitempty" json:"enum,omitempty"` Definitions map[string]*Schema `yaml:"definitions,omitempty" json:"definitions,omitempty"` HasData bool `yaml:"-" json:"-"` Deprecated bool `yaml:"deprecated,omitempty" json:"deprecated,omitempty"` ReadOnly bool `yaml:"readOnly,omitempty" json:"readOnly,omitempty"` WriteOnly bool `yaml:"writeOnly,omitempty" json:"writeOnly,omitempty"` Required BoolOrArrayOfString `yaml:"required,omitempty" json:"required,omitempty"` CustomAnnotations map[string]interface{} `yaml:"-" json:",omitempty"` MinLength *int `yaml:"minLength,omitempty" json:"minLength,omitempty"` MaxLength *int `yaml:"maxLength,omitempty" json:"maxLength,omitempty"` MinItems *int `yaml:"minItems,omitempty" json:"minItems,omitempty"` MaxItems *int `yaml:"maxItems,omitempty" json:"maxItems,omitempty"` UniqueItems bool `yaml:"uniqueItems,omitempty" json:"uniqueItems,omitempty"` Contains *Schema `yaml:"contains,omitempty" json:"contains,omitempty"` AdditionalItems SchemaOrBool `yaml:"additionalItems,omitempty" json:"additionalItems,omitempty"` MinProperties *int `yaml:"minProperties,omitempty" json:"minProperties,omitempty"` MaxProperties *int `yaml:"maxProperties,omitempty" json:"maxProperties,omitempty"` PropertyNames *Schema `yaml:"propertyNames,omitempty" json:"propertyNames,omitempty"` Dependencies map[string]interface{} `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` constWasSet bool `yaml:"-" json:"-"` } func NewSchema(schemaType string) *Schema { if schemaType == "" { return &Schema{} } return &Schema{ Type: []string{schemaType}, Required: NewBoolOrArrayOfString([]string{}, false), } } // getJsonKeys returns a slice of all JSON tag values from the Schema struct fields. // This is used to identify known fields during YAML unmarshaling to separate them // from custom annotations. func (s Schema) getJsonKeys() []string { result := []string{} t := reflect.TypeOf(s) for i := 0; i < t.NumField(); i++ { field := t.Field(i) result = append(result, field.Tag.Get("json")) } return result } // UnmarshalYAML implements custom YAML unmarshaling for Schema objects. // It handles both standard schema fields and custom annotations (prefixed with "x-"). // Custom annotations are stored in the CustomAnnotations map while standard fields // are unmarshaled directly into the Schema struct. func (s *Schema) UnmarshalYAML(node *yaml.Node) error { // Create an alias type to avoid recursion type schemaAlias Schema alias := new(schemaAlias) // copy all existing fields *alias = schemaAlias(*s) // Unmarshal known fields into alias if err := node.Decode(alias); err != nil { return err } // Initialize CustomAnnotations map alias.CustomAnnotations = make(map[string]interface{}) knownKeys := s.getJsonKeys() // Iterate through all node fields for i := 0; i < len(node.Content)-1; i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] key := keyNode.Value // Track if const field was explicitly set (even to null) if key == "const" { alias.constWasSet = true } if slices.Contains(knownKeys, key) { continue } // Unmarshal unknown fields into the CustomAnnotations map if !strings.HasPrefix(key, CustomAnnotationPrefix) { continue } var value interface{} if err := valueNode.Decode(&value); err != nil { return err } alias.CustomAnnotations[key] = value } // Copy alias to the main struct *s = Schema(*alias) return nil } // UnmarshalJSON implements custom JSON unmarshaling for Schema objects. // It handles "definitions" (Draft 7), "$defs" (Draft 2019-09+), custom // annotations (prefixed with "x-"), and tracks explicit "const" fields. func (s *Schema) UnmarshalJSON(data []byte) error { // Create an alias type to avoid recursion type schemaAlias Schema alias := new(schemaAlias) // Unmarshal known fields into alias if err := json.Unmarshal(data, alias); err != nil { return err } // Parse raw JSON to check for $defs, custom annotations, and const var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } // Handle $defs (Draft 2019-09+) - merge into Definitions if defsRaw, ok := raw["$defs"]; ok { var defs map[string]*Schema if err := json.Unmarshal(defsRaw, &defs); err != nil { return fmt.Errorf("failed to unmarshal $defs: %w", err) } // Merge $defs into Definitions if alias.Definitions == nil { alias.Definitions = make(map[string]*Schema) } for k, v := range defs { if _, exists := alias.Definitions[k]; !exists { alias.Definitions[k] = v } } } // Track if const field was explicitly set (even to null) if _, ok := raw["const"]; ok { alias.constWasSet = true } // Extract custom annotations (x-* prefixed keys) knownKeys := s.getJsonKeys() alias.CustomAnnotations = make(map[string]interface{}) for key, rawValue := range raw { if !strings.HasPrefix(key, CustomAnnotationPrefix) { continue } if slices.Contains(knownKeys, key) { continue } var value interface{} if err := json.Unmarshal(rawValue, &value); err != nil { return fmt.Errorf("failed to unmarshal custom annotation %s: %w", key, err) } alias.CustomAnnotations[key] = value } // Copy alias to the main struct *s = Schema(*alias) // Rewrite $ref paths from $defs to definitions for Draft 7 compatibility s.rewriteDefsRefs() return nil } // HoistDefinitions collects all definitions from nested schemas and hoists them // to the root level. This is necessary because $ref paths like "#/definitions/X" // always reference the document root, not the local schema. func (s *Schema) HoistDefinitions() { if s == nil { return } // Initialize root definitions if needed if s.Definitions == nil { s.Definitions = make(map[string]*Schema) } // Collect definitions from all nested schemas s.collectAndHoistDefinitions(s.Definitions) } // collectAndHoistDefinitions recursively collects definitions from nested schemas // and adds them to the root definitions map, then removes them from nested positions. func (s *Schema) collectAndHoistDefinitions(rootDefs map[string]*Schema) { if s == nil { return } // Process nested properties for _, prop := range s.Properties { prop.collectAndHoistDefinitions(rootDefs) // Hoist definitions from this property if prop.Definitions != nil { for name, def := range prop.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } prop.Definitions = nil // Remove from nested position } } // Process pattern properties for _, prop := range s.PatternProperties { prop.collectAndHoistDefinitions(rootDefs) if prop.Definitions != nil { for name, def := range prop.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } prop.Definitions = nil } } // Process items if s.Items != nil { s.Items.collectAndHoistDefinitions(rootDefs) if s.Items.Definitions != nil { for name, def := range s.Items.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } s.Items.Definitions = nil } } // Process composition schemas for _, schema := range s.AllOf { schema.collectAndHoistDefinitions(rootDefs) if schema.Definitions != nil { for name, def := range schema.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } schema.Definitions = nil } } for _, schema := range s.AnyOf { schema.collectAndHoistDefinitions(rootDefs) if schema.Definitions != nil { for name, def := range schema.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } schema.Definitions = nil } } for _, schema := range s.OneOf { schema.collectAndHoistDefinitions(rootDefs) if schema.Definitions != nil { for name, def := range schema.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } schema.Definitions = nil } } // Process conditional schemas if s.If != nil { s.If.collectAndHoistDefinitions(rootDefs) if s.If.Definitions != nil { for name, def := range s.If.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } s.If.Definitions = nil } } if s.Then != nil { s.Then.collectAndHoistDefinitions(rootDefs) if s.Then.Definitions != nil { for name, def := range s.Then.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } s.Then.Definitions = nil } } if s.Else != nil { s.Else.collectAndHoistDefinitions(rootDefs) if s.Else.Definitions != nil { for name, def := range s.Else.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } s.Else.Definitions = nil } } if s.Not != nil { s.Not.collectAndHoistDefinitions(rootDefs) if s.Not.Definitions != nil { for name, def := range s.Not.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } s.Not.Definitions = nil } } // Process AdditionalProperties when it's a schema if s.AdditionalProperties != nil { switch v := s.AdditionalProperties.(type) { case *Schema: v.collectAndHoistDefinitions(rootDefs) if v.Definitions != nil { for name, def := range v.Definitions { if _, exists := rootDefs[name]; !exists { rootDefs[name] = def } } v.Definitions = nil } } } } // rewriteDefsRefs recursively rewrites $ref paths from "#/$defs/" to "#/definitions/" // for JSON Schema Draft 7 compatibility. func (s *Schema) rewriteDefsRefs() { if s == nil { return } // Rewrite main $ref if strings.HasPrefix(s.Ref, "#/$defs/") { s.Ref = strings.Replace(s.Ref, "#/$defs/", "#/definitions/", 1) } // Recursively process all nested schemas for _, prop := range s.Properties { prop.rewriteDefsRefs() } for _, prop := range s.PatternProperties { prop.rewriteDefsRefs() } for _, def := range s.Definitions { def.rewriteDefsRefs() } if s.Items != nil { s.Items.rewriteDefsRefs() } if s.Contains != nil { s.Contains.rewriteDefsRefs() } if s.PropertyNames != nil { s.PropertyNames.rewriteDefsRefs() } if s.If != nil { s.If.rewriteDefsRefs() } if s.Then != nil { s.Then.rewriteDefsRefs() } if s.Else != nil { s.Else.rewriteDefsRefs() } if s.Not != nil { s.Not.rewriteDefsRefs() } for _, schema := range s.AllOf { schema.rewriteDefsRefs() } for _, schema := range s.AnyOf { schema.rewriteDefsRefs() } for _, schema := range s.OneOf { schema.rewriteDefsRefs() } // Handle AdditionalProperties and AdditionalItems when they are schemas if s.AdditionalProperties != nil { switch v := s.AdditionalProperties.(type) { case *Schema: v.rewriteDefsRefs() case Schema: v.rewriteDefsRefs() s.AdditionalProperties = v } } if s.AdditionalItems != nil { switch v := s.AdditionalItems.(type) { case *Schema: v.rewriteDefsRefs() case Schema: v.rewriteDefsRefs() s.AdditionalItems = v } } } // Set sets the HasData field to true func (s *Schema) Set() { s.HasData = true } // DisableRequiredProperties recursively disables all required property validations throughout the schema. // This includes: // - Setting the root schema's required field to an empty array // - Recursively disabling required properties in all nested schemas (properties, items, etc.) // - Handling all conditional schemas (if/then/else) // - Processing all composition schemas (anyOf/oneOf/allOf) func (s *Schema) DisableRequiredProperties() { s.Required = NewBoolOrArrayOfString([]string{}, false) for _, v := range s.Properties { v.DisableRequiredProperties() } if s.Items != nil { s.Items.DisableRequiredProperties() } if s.AnyOf != nil { for _, v := range s.AnyOf { v.DisableRequiredProperties() } } if s.OneOf != nil { for _, v := range s.OneOf { v.DisableRequiredProperties() } } if s.AllOf != nil { for _, v := range s.AllOf { v.DisableRequiredProperties() } } if s.If != nil { s.If.DisableRequiredProperties() } if s.Else != nil { s.Else.DisableRequiredProperties() } if s.Then != nil { s.Then.DisableRequiredProperties() } if s.Not != nil { s.Not.DisableRequiredProperties() } // Add handling for AdditionalProperties when it's a Schema if s.AdditionalProperties != nil { switch v := s.AdditionalProperties.(type) { case *Schema: v.DisableRequiredProperties() case Schema: v.DisableRequiredProperties() s.AdditionalProperties = v } } // Handle Contains schema if s.Contains != nil { s.Contains.DisableRequiredProperties() } // Handle PropertyNames schema if s.PropertyNames != nil { s.PropertyNames.DisableRequiredProperties() } // Handle AdditionalItems when it's a Schema if s.AdditionalItems != nil { switch v := s.AdditionalItems.(type) { case *Schema: v.DisableRequiredProperties() case Schema: v.DisableRequiredProperties() s.AdditionalItems = v } } // Handle Definitions for _, v := range s.Definitions { v.DisableRequiredProperties() } } // GetPropertyAtPath navigates a dot-separated path and returns the schema at that location. // Returns nil if any part of the path doesn't exist. Empty path segments are skipped. func (s *Schema) GetPropertyAtPath(path string) *Schema { if s == nil || path == "" { return s } parts := strings.Split(path, ".") current := s for _, part := range parts { if part == "" { continue // Skip empty segments (e.g., "foo..bar" or ".foo") } if current.Properties == nil { return nil } prop, ok := current.Properties[part] if !ok { return nil } current = prop } return current } // SetPropertyAtPath navigates a dot-separated path and ensures all intermediate schemas exist. // Creates intermediate object schemas as needed. Returns the schema at the final path location. // If the path is empty, returns the current schema. Empty path segments are skipped. func (s *Schema) SetPropertyAtPath(path string) *Schema { if s == nil || path == "" { return s } parts := strings.Split(path, ".") current := s for _, part := range parts { if part == "" { continue // Skip empty segments (e.g., "foo..bar" or ".foo") } if current.Properties == nil { current.Properties = make(map[string]*Schema) } if _, ok := current.Properties[part]; !ok { current.Properties[part] = &Schema{ Type: []string{"object"}, Title: part, Properties: make(map[string]*Schema), } } current = current.Properties[part] } return current } // ToJson converts the data to raw json func (s Schema) ToJson() ([]byte, error) { res, err := json.MarshalIndent(&s, "", " ") if err != nil { return nil, err } return res, nil } // Supported format values according to JSON Schema specification const ( FormatDateTime = "date-time" FormatTime = "time" FormatDate = "date" FormatDuration = "duration" FormatEmail = "email" FormatIDNEmail = "idn-email" FormatHostname = "hostname" FormatIDNHostname = "idn-hostname" FormatIPv4 = "ipv4" FormatIPv6 = "ipv6" FormatUUID = "uuid" FormatURI = "uri" FormatURIReference = "uri-reference" FormatIRI = "iri" FormatIRIReference = "iri-reference" FormatURITemplate = "uri-template" FormatJSONPointer = "json-pointer" FormatRelJSONPointer = "relative-json-pointer" FormatRegex = "regex" ) var supportedFormats = map[string]bool{ FormatDateTime: true, FormatTime: true, FormatDate: true, FormatDuration: true, FormatEmail: true, FormatIDNEmail: true, FormatHostname: true, FormatIDNHostname: true, FormatIPv4: true, FormatIPv6: true, FormatUUID: true, FormatURI: true, FormatURIReference: true, FormatIRI: true, FormatIRIReference: true, FormatURITemplate: true, FormatJSONPointer: true, FormatRelJSONPointer: true, FormatRegex: true, } // Validate performs comprehensive validation of the schema func (s Schema) Validate() error { // Validate schema syntax if err := s.validateSchemaSyntax(); err != nil { return err } // Validate type constraints if err := s.validateTypeConstraints(); err != nil { return err } // Validate numeric constraints if err := s.validateNumericConstraints(); err != nil { return err } // Validate string constraints if err := s.validateStringConstraints(); err != nil { return err } // Validate array constraints if err := s.validateArrayConstraints(); err != nil { return err } // Validate object constraints if err := s.validateObjectConstraints(); err != nil { return err } // Validate nested schemas if err := s.validateNestedSchemas(); err != nil { return err } return nil } func (s Schema) validateSchemaSyntax() error { jsonStr, err := s.ToJson() if err != nil { return fmt.Errorf("failed to convert schema to JSON: %w", err) } c := jsonschema.NewCompiler() if err := c.AddResource("schema.json", jsonStr); err != nil { return fmt.Errorf("invalid schema syntax: %w", err) } return s.Type.Validate() } func (s Schema) validateTypeConstraints() error { return nil } func (s Schema) validateNumericConstraints() error { if !s.hasNumericConstraints() { return nil } if !s.Type.IsEmpty() && !s.Type.Matches("number") && !s.Type.Matches("integer") { return fmt.Errorf("numeric constraints can only be used with number or integer types, got %v", s.Type) } if s.MultipleOf != nil && *s.MultipleOf <= 0 { return errors.New("multipleOf must be greater than 0") } if s.Minimum != nil && s.ExclusiveMinimum != nil { return errors.New("cannot use both minimum and exclusiveMinimum") } if s.Maximum != nil && s.ExclusiveMaximum != nil { return errors.New("cannot use both maximum and exclusiveMaximum") } // Validate min <= max when both are specified if s.Minimum != nil && s.Maximum != nil && *s.Minimum > *s.Maximum { return fmt.Errorf("minimum (%v) cannot be greater than maximum (%v)", *s.Minimum, *s.Maximum) } if s.ExclusiveMinimum != nil && s.ExclusiveMaximum != nil && *s.ExclusiveMinimum >= *s.ExclusiveMaximum { return fmt.Errorf("exclusiveMinimum (%v) must be less than exclusiveMaximum (%v)", *s.ExclusiveMinimum, *s.ExclusiveMaximum) } return nil } func (s Schema) validateStringConstraints() error { if s.Format != "" { if !s.Type.IsEmpty() && !s.Type.Matches("string") { return fmt.Errorf("format can only be used with string type, got %v", s.Type) } if !supportedFormats[s.Format] { return fmt.Errorf("unsupported format: %s", s.Format) } } if s.Pattern != "" { if !s.Type.IsEmpty() && !s.Type.Matches("string") { return fmt.Errorf("pattern can only be used with string type, got %v", s.Type) } // Validate that pattern is a valid regex if _, err := regexp.Compile(s.Pattern); err != nil { return fmt.Errorf("invalid pattern regex: %w", err) } } if s.Format != "" && s.Pattern != "" { return errors.New("cannot use both format and pattern in the same schema") } // Validate minLength/maxLength are only used with string type if s.MinLength != nil || s.MaxLength != nil { if !s.Type.IsEmpty() && !s.Type.Matches("string") { return fmt.Errorf("minLength/maxLength can only be used with string type, got %v", s.Type) } } if s.MaxLength != nil && s.MinLength != nil && *s.MinLength > *s.MaxLength { return fmt.Errorf("minLength (%d) cannot be greater than maxLength (%d)", *s.MinLength, *s.MaxLength) } if s.MinLength != nil && *s.MinLength < 0 { return errors.New("minLength must be non-negative") } if s.MaxLength != nil && *s.MaxLength < 0 { return errors.New("maxLength must be non-negative") } // Validate contentEncoding and contentMediaType are only used with string type if s.ContentEncoding != "" || s.ContentMediaType != "" { if !s.Type.IsEmpty() && !s.Type.Matches("string") { return fmt.Errorf("contentEncoding/contentMediaType can only be used with string type, got %v", s.Type) } } return nil } func (s Schema) validateArrayConstraints() error { if s.Items != nil { if !s.Type.IsEmpty() && !s.Type.Matches("array") { return fmt.Errorf("items can only be used with array type, got %v", s.Type) } if err := s.Items.Validate(); err != nil { return fmt.Errorf("invalid items schema: %w", err) } } if s.MinItems != nil || s.MaxItems != nil { if !s.Type.IsEmpty() && !s.Type.Matches("array") { return fmt.Errorf("minItems/maxItems can only be used with array type, got %v", s.Type) } if s.MinItems != nil && s.MaxItems != nil && *s.MaxItems < *s.MinItems { return fmt.Errorf("maxItems (%d) cannot be less than minItems (%d)", *s.MaxItems, *s.MinItems) } } if s.MinItems != nil && *s.MinItems < 0 { return errors.New("minItems must be non-negative") } if s.MaxItems != nil && *s.MaxItems < 0 { return errors.New("maxItems must be non-negative") } // Note: uniqueItems is a boolean that doesn't require type validation. // Per JSON Schema spec, keywords are ignored if the type doesn't match. // Validate contains if s.Contains != nil { if !s.Type.IsEmpty() && !s.Type.Matches("array") { return fmt.Errorf("contains can only be used with array type, got %v", s.Type) } if err := s.Contains.Validate(); err != nil { return fmt.Errorf("invalid contains schema: %w", err) } } // Validate additionalItems if s.AdditionalItems != nil { if !s.Type.IsEmpty() && !s.Type.Matches("array") { return fmt.Errorf("additionalItems can only be used with array type, got %v", s.Type) } switch v := s.AdditionalItems.(type) { case *Schema: if err := v.Validate(); err != nil { return fmt.Errorf("invalid additionalItems schema: %w", err) } case Schema: if err := v.Validate(); err != nil { return fmt.Errorf("invalid additionalItems schema: %w", err) } case bool: // Boolean is valid case map[string]interface{}: // When unmarshaled from YAML, a schema object becomes map[string]interface{} // Convert and validate it schemaBytes, err := json.Marshal(v) if err != nil { return fmt.Errorf("invalid additionalItems schema: %w", err) } var itemsSchema Schema if err := json.Unmarshal(schemaBytes, &itemsSchema); err != nil { return fmt.Errorf("invalid additionalItems schema: %w", err) } if err := itemsSchema.Validate(); err != nil { return fmt.Errorf("invalid additionalItems schema: %w", err) } default: return fmt.Errorf("additionalItems must be a boolean or schema, got %T", s.AdditionalItems) } } return nil } func (s Schema) validateObjectConstraints() error { // Validate minProperties/maxProperties if s.MinProperties != nil || s.MaxProperties != nil { if !s.Type.IsEmpty() && !s.Type.Matches("object") { return fmt.Errorf("minProperties/maxProperties can only be used with object type, got %v", s.Type) } if s.MinProperties != nil && *s.MinProperties < 0 { return errors.New("minProperties must be non-negative") } if s.MaxProperties != nil && *s.MaxProperties < 0 { return errors.New("maxProperties must be non-negative") } if s.MinProperties != nil && s.MaxProperties != nil && *s.MaxProperties < *s.MinProperties { return fmt.Errorf("maxProperties (%d) cannot be less than minProperties (%d)", *s.MaxProperties, *s.MinProperties) } } // Validate propertyNames if s.PropertyNames != nil { if !s.Type.IsEmpty() && !s.Type.Matches("object") { return fmt.Errorf("propertyNames can only be used with object type, got %v", s.Type) } if err := s.PropertyNames.Validate(); err != nil { return fmt.Errorf("invalid propertyNames schema: %w", err) } } // Validate additionalProperties type check if s.AdditionalProperties != nil { if !s.Type.IsEmpty() && !s.Type.Matches("object") { return fmt.Errorf("additionalProperties can only be used with object type, got %v", s.Type) } switch v := s.AdditionalProperties.(type) { case *Schema: if err := v.Validate(); err != nil { return fmt.Errorf("invalid additionalProperties schema: %w", err) } case Schema: if err := v.Validate(); err != nil { return fmt.Errorf("invalid additionalProperties schema: %w", err) } case bool: // Boolean is valid case map[string]interface{}: // When unmarshaled from YAML, a schema object becomes map[string]interface{} // Convert and validate it schemaBytes, err := json.Marshal(v) if err != nil { return fmt.Errorf("invalid additionalProperties schema: %w", err) } var propsSchema Schema if err := json.Unmarshal(schemaBytes, &propsSchema); err != nil { return fmt.Errorf("invalid additionalProperties schema: %w", err) } if err := propsSchema.Validate(); err != nil { return fmt.Errorf("invalid additionalProperties schema: %w", err) } default: return fmt.Errorf("additionalProperties must be a boolean or schema, got %T", s.AdditionalProperties) } } // Validate patternProperties patterns for pattern, patternSchema := range s.PatternProperties { if _, err := regexp.Compile(pattern); err != nil { return fmt.Errorf("invalid pattern in patternProperties '%s': %w", pattern, err) } if patternSchema != nil { if err := patternSchema.Validate(); err != nil { return fmt.Errorf("invalid schema in patternProperties[%s]: %w", pattern, err) } } } // Validate dependencies for depKey, depValue := range s.Dependencies { switch v := depValue.(type) { case []interface{}: // Array of property names - validate they are strings for i, item := range v { if _, ok := item.(string); !ok { return fmt.Errorf("dependencies[%s][%d] must be a string, got %T", depKey, i, item) } } case map[string]interface{}: // Schema - convert and validate schemaBytes, err := json.Marshal(v) if err != nil { return fmt.Errorf("invalid schema in dependencies[%s]: %w", depKey, err) } var depSchema Schema if err := json.Unmarshal(schemaBytes, &depSchema); err != nil { return fmt.Errorf("invalid schema in dependencies[%s]: %w", depKey, err) } if err := depSchema.Validate(); err != nil { return fmt.Errorf("invalid schema in dependencies[%s]: %w", depKey, err) } default: return fmt.Errorf("dependencies[%s] must be an array of strings or a schema, got %T", depKey, depValue) } } return nil } func (s Schema) validateNestedSchemas() error { // Validate combinatorial schemas for _, schemas := range [][]*Schema{s.AllOf, s.AnyOf, s.OneOf} { for _, schema := range schemas { if err := schema.Validate(); err != nil { return err } } } // Validate conditional schemas for _, schema := range []*Schema{s.If, s.Then, s.Else, s.Not} { if schema != nil { if err := schema.Validate(); err != nil { return err } } } // Validate definitions for name, defSchema := range s.Definitions { if defSchema != nil { if err := defSchema.Validate(); err != nil { return fmt.Errorf("invalid schema in definitions[%s]: %w", name, err) } } } // Validate nested properties for name, propSchema := range s.Properties { if propSchema != nil { if err := propSchema.Validate(); err != nil { return fmt.Errorf("invalid schema in properties[%s]: %w", name, err) } } } return nil } func (s Schema) hasNumericConstraints() bool { return s.Minimum != nil || s.Maximum != nil || s.ExclusiveMinimum != nil || s.ExclusiveMaximum != nil || s.MultipleOf != nil } var possibleSkipFields = []string{"type", "title", "description", "required", "default", "additionalProperties"} type SkipAutoGenerationConfig struct { Type, Title, Description, Required, Default, AdditionalProperties bool } func NewSkipAutoGenerationConfig(flag []string) (*SkipAutoGenerationConfig, error) { var config SkipAutoGenerationConfig var invalidFlags []string for _, fieldName := range flag { if !slices.Contains(possibleSkipFields, fieldName) { invalidFlags = append(invalidFlags, fieldName) } if fieldName == "type" { config.Type = true } if fieldName == "title" { config.Title = true } if fieldName == "description" { config.Description = true } if fieldName == "required" { config.Required = true } if fieldName == "default" { config.Default = true } if fieldName == "additionalProperties" { config.AdditionalProperties = true } } if len(invalidFlags) != 0 { return nil, fmt.Errorf("unsupported field names '%s' for skipping auto-generation", strings.Join(invalidFlags, "', '")) } return &config, nil } func typeFromTag(tag string) ([]string, error) { switch tag { case nullTag: return []string{"null"}, nil case boolTag: return []string{"boolean"}, nil case strTag: return []string{"string"}, nil case intTag: return []string{"integer"}, nil case floatTag: return []string{"number"}, nil case timestampTag: return []string{"string"}, nil case arrayTag: return []string{"array"}, nil case mapTag: return []string{"object"}, nil } return []string{}, fmt.Errorf("unsupported yaml tag found: %s", tag) } // FixRequiredProperties iterates over the properties and checks if required has a boolean value. // Then the property is added to the parents required property list func FixRequiredProperties(schema *Schema) error { if schema.Properties != nil { for propName, propValue := range schema.Properties { FixRequiredProperties(propValue) if propValue.Required.Bool && !slices.Contains(schema.Required.Strings, propName) { schema.Required.Strings = append(schema.Required.Strings, propName) } } if !slices.Contains(schema.Type, "object") { // If .Properties is set, type must be object if len(schema.Type) == 0 { schema.Type = []string{"object"} } else { schema.Type = append(schema.Type, "object") } } } if schema.Then != nil { FixRequiredProperties(schema.Then) } if schema.If != nil { FixRequiredProperties(schema.If) } if schema.Else != nil { FixRequiredProperties(schema.Else) } if schema.Items != nil { FixRequiredProperties(schema.Items) } if schema.AdditionalProperties != nil { switch v := schema.AdditionalProperties.(type) { case *Schema: FixRequiredProperties(v) case Schema: FixRequiredProperties(&v) schema.AdditionalProperties = v } } if len(schema.AnyOf) > 0 { for _, subSchema := range schema.AnyOf { FixRequiredProperties(subSchema) } } if len(schema.AllOf) > 0 { for _, subSchema := range schema.AllOf { FixRequiredProperties(subSchema) } } if len(schema.OneOf) > 0 { for _, subSchema := range schema.OneOf { FixRequiredProperties(subSchema) } } if schema.Not != nil { FixRequiredProperties(schema.Not) } // Handle Contains schema if schema.Contains != nil { FixRequiredProperties(schema.Contains) } // Handle PropertyNames schema if schema.PropertyNames != nil { FixRequiredProperties(schema.PropertyNames) } // Handle AdditionalItems when it's a Schema if schema.AdditionalItems != nil { switch v := schema.AdditionalItems.(type) { case *Schema: FixRequiredProperties(v) case Schema: FixRequiredProperties(&v) schema.AdditionalItems = v } } // Handle Definitions for _, defSchema := range schema.Definitions { FixRequiredProperties(defSchema) } // Handle PatternProperties for _, patternSchema := range schema.PatternProperties { FixRequiredProperties(patternSchema) } return nil } // applyRootSchemaProperties copies root-level schema properties from source to target. // Used for applying @schema.root annotations. func (s *Schema) applyRootSchemaProperties(source *Schema, valuesPath string) error { if source.Title != "" { s.Title = source.Title } if source.Description != "" { s.Description = source.Description } if source.Ref != "" { if err := handleSchemaRefs(source, valuesPath); err != nil { return err } s.Ref = source.Ref } if len(source.Examples) > 0 { s.Examples = source.Examples } if source.Deprecated { s.Deprecated = source.Deprecated } if source.ReadOnly { s.ReadOnly = source.ReadOnly } if source.WriteOnly { s.WriteOnly = source.WriteOnly } if source.AdditionalProperties != nil { s.AdditionalProperties = source.AdditionalProperties } if len(source.Required.Strings) > 0 || source.Required.Bool { s.Required = source.Required } if len(source.PatternProperties) > 0 { if s.PatternProperties == nil { s.PatternProperties = make(map[string]*Schema) } for k, v := range source.PatternProperties { s.PatternProperties[k] = v } } if len(source.Definitions) > 0 { if s.Definitions == nil { s.Definitions = make(map[string]*Schema) } for k, v := range source.Definitions { s.Definitions[k] = v } } if len(source.AllOf) > 0 { s.AllOf = source.AllOf } if len(source.AnyOf) > 0 { s.AnyOf = source.AnyOf } if len(source.OneOf) > 0 { s.OneOf = source.OneOf } if source.Not != nil { s.Not = source.Not } if len(source.CustomAnnotations) > 0 { if s.CustomAnnotations == nil { s.CustomAnnotations = make(map[string]interface{}) } for k, v := range source.CustomAnnotations { s.CustomAnnotations[k] = v } } return nil } // GetRootSchemaFromComment parses root-level schema annotations (marked with @schema.root) // from a comment and returns the schema, the remaining comment (without root annotations), // and any error. Root schema annotations are useful for applying schema properties to the // entire values file rather than individual keys. func GetRootSchemaFromComment(comment string) (Schema, string, error) { var result Schema scanner := bufio.NewScanner(strings.NewReader(comment)) rootSchemaLines := []string{} remainingCommentLines := []string{} insideRootSchemaBlock := false foundRootSchema := false for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, SchemaRootPrefix) { insideRootSchemaBlock = !insideRootSchemaBlock foundRootSchema = true continue } if insideRootSchemaBlock { content := strings.TrimPrefix(line, CommentPrefix) rootSchemaLines = append(rootSchemaLines, strings.TrimPrefix(strings.TrimPrefix(content, CommentPrefix), " ")) result.Set() } else { remainingCommentLines = append(remainingCommentLines, line) } } if insideRootSchemaBlock { return result, "", fmt.Errorf("unclosed root schema block found in comment: %s", comment) } if foundRootSchema { err := yaml.Unmarshal([]byte(strings.Join(rootSchemaLines, "\n")), &result) if err != nil { return result, "", err } } return result, strings.Join(remainingCommentLines, "\n"), nil } // GetSchemaFromComment parses the annotations from the given comment func GetSchemaFromComment(comment string) (Schema, string, error) { var result Schema scanner := bufio.NewScanner(strings.NewReader(comment)) description := []string{} rawSchema := []string{} insideSchemaBlock := false for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, SchemaPrefix) { insideSchemaBlock = !insideSchemaBlock continue } if insideSchemaBlock { content := strings.TrimPrefix(line, CommentPrefix) rawSchema = append(rawSchema, strings.TrimPrefix(strings.TrimPrefix(content, CommentPrefix), " ")) result.Set() } else { description = append(description, strings.TrimPrefix(strings.TrimPrefix(line, CommentPrefix), " ")) } } if insideSchemaBlock { return result, "", fmt.Errorf("unclosed schema block found in comment: %s", comment) } err := yaml.Unmarshal([]byte(strings.Join(rawSchema, "\n")), &result) if err != nil { return result, "", err } return result, strings.Join(description, "\n"), nil } // YamlToSchema recursively parses a YAML node and creates a JSON Schema from it // Parameters: // - valuesPath: path to the values file being processed // - node: current YAML node being processed // - keepFullComment: whether to preserve all comment text // - helmDocsCompatibilityMode: whether to parse helm-docs annotations // - dontRemoveHelmDocsPrefix: whether to keep helm-docs prefixes in comments // - skipAutoGeneration: configuration for which fields should not be auto-generated // - parentRequiredProperties: list of required properties to populate in parent // // Returns: // - The generated Schema and any error encountered during parsing func YamlToSchema( valuesPath string, node *yaml.Node, keepFullComment bool, helmDocsCompatibilityMode bool, dontRemoveHelmDocsPrefix bool, dontAddGlobal bool, skipAutoGeneration *SkipAutoGenerationConfig, parentRequiredProperties *[]string, ) (*Schema, error) { schema := NewSchema("object") switch node.Kind { case yaml.DocumentNode: if len(node.Content) != 1 { return nil, fmt.Errorf("unexpected yaml document structure: expected 1 content node, got %d", len(node.Content)) } schema.Schema = "http://json-schema.org/draft-07/schema#" // Check document-level HeadComment for @schema.root (handles blank-line separated case) if node.HeadComment != "" { if docRootSchema, _, err := GetRootSchemaFromComment(node.HeadComment); err != nil { return nil, fmt.Errorf("error parsing root schema from document comment: %w", err) } else if docRootSchema.HasData { if err := schema.applyRootSchemaProperties(&docRootSchema, valuesPath); err != nil { return nil, fmt.Errorf("error applying root schema from document comment: %w", err) } if err := docRootSchema.Validate(); err != nil { return nil, fmt.Errorf("error validating root schema from document comment: %w", err) } } } childSchema, err := YamlToSchema( valuesPath, node.Content[0], keepFullComment, helmDocsCompatibilityMode, dontRemoveHelmDocsPrefix, dontAddGlobal, skipAutoGeneration, &schema.Required.Strings, ) if err != nil { return nil, err } schema.Properties = childSchema.Properties // Apply root schema properties from child if they were set if err := schema.applyRootSchemaProperties(childSchema, valuesPath); err != nil { return nil, fmt.Errorf("error applying root schema properties from child: %w", err) } if _, ok := schema.Properties["global"]; !ok && !dontAddGlobal { // global key must be present, otherwise helm lint will fail if schema.Properties == nil { schema.Properties = make(map[string]*Schema) } schema.Properties["global"] = NewSchema( "object", ) if !skipAutoGeneration.Title { schema.Properties["global"].Title = "global" } if !skipAutoGeneration.Description { schema.Properties["global"].Description = "Global values are values that can be accessed from any chart or subchart by exactly the same name." } } // always disable on top level (unless root schema specifies otherwise) if !skipAutoGeneration.AdditionalProperties && schema.AdditionalProperties == nil { schema.AdditionalProperties = new(bool) } case yaml.MappingNode: // Check if the first key has root schema annotations (only for root-level mappings) if len(node.Content) > 0 && parentRequiredProperties != nil { firstKeyNode := node.Content[0] // Try to extract root schema annotations (adjacent to first key) rootSchema, remainingComment, err := GetRootSchemaFromComment(firstKeyNode.HeadComment) if err != nil { return nil, fmt.Errorf("error parsing root schema comment: %w", err) } if rootSchema.HasData { if err := schema.applyRootSchemaProperties(&rootSchema, valuesPath); err != nil { return nil, fmt.Errorf("error applying root schema: %w", err) } if err := rootSchema.Validate(); err != nil { return nil, fmt.Errorf("error validating root schema: %w", err) } // Update the first key's comment to exclude the root schema annotations firstKeyNode.HeadComment = remainingComment } } for i := 0; i < len(node.Content); i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] if valueNode.Kind == yaml.AliasNode { valueNode = valueNode.Alias } comment := keyNode.HeadComment if !keepFullComment { comment = leadingCommentsRemover.ReplaceAllString(comment, "") } keyNodeSchema, description, err := GetSchemaFromComment(comment) if err != nil { return nil, fmt.Errorf("error parsing comment of key %s: %w", keyNode.Value, err) } if helmDocsCompatibilityMode { _, helmDocsValue := helm.ParseComment(strings.Split(keyNode.HeadComment, "\n")) if helmDocsValue.Default != "" { keyNodeSchema.Set() keyNodeSchema.Default = helmDocsValue.Default } if helmDocsValue.Description != "" { keyNodeSchema.Set() keyNodeSchema.Description = helmDocsValue.Description } if helmDocsValue.ValueType != "" { helmDocsType, err := helmDocsTypeToSchemaType(helmDocsValue.ValueType) if err != nil { log.Warnln(err) } else { keyNodeSchema.Set() keyNodeSchema.Type = StringOrArrayOfString{helmDocsType} } } } if !dontRemoveHelmDocsPrefix { // remove all lines containing helm-docs @tags, like @ignored, or one of those: // https://github.com/norwoodj/helm-docs/blob/v1.14.2/pkg/helm/chart_info.go#L18-L24 description = helmDocsTagsRemover.ReplaceAllString(description, "") description = helmDocsPrefixRemover.ReplaceAllString(description, "") } if keyNodeSchema.Ref != "" || len(keyNodeSchema.PatternProperties) > 0 { // Handle $ref in main schema and pattern properties if err := handleSchemaRefs(&keyNodeSchema, valuesPath); err != nil { return nil, fmt.Errorf("error resolving $ref for key %s: %w", keyNode.Value, err) } } if keyNodeSchema.ConstFromValue { if keyNodeSchema.constWasSet { return nil, fmt.Errorf("error validating schema of key %s: const and const-from-value cannot be used together", keyNode.Value) } decodedValue, err := decodeNodeValue(valueNode) if err != nil { return nil, fmt.Errorf("error decoding value for const-from-value on key %s: %w", keyNode.Value, err) } keyNodeSchema.Const = decodedValue keyNodeSchema.constWasSet = true } if keyNodeSchema.HasData { if err := keyNodeSchema.Validate(); err != nil { return nil, fmt.Errorf("error validating schema of key %s: %w", keyNode.Value, err) } } else if !skipAutoGeneration.Type { nodeType, err := typeFromTag(valueNode.Tag) if err != nil { return nil, fmt.Errorf("error inferring type for key %s: %w", keyNode.Value, err) } keyNodeSchema.Type = nodeType } // only validate or default if $ref is not set if keyNodeSchema.Ref == "" { // Add key to required array of parent if keyNodeSchema.Required.Bool || (len(keyNodeSchema.Required.Strings) == 0 && !skipAutoGeneration.Required && !keyNodeSchema.HasData) { if !slices.Contains(*parentRequiredProperties, keyNode.Value) { *parentRequiredProperties = append(*parentRequiredProperties, keyNode.Value) } } if !skipAutoGeneration.AdditionalProperties && valueNode.Kind == yaml.MappingNode && (!keyNodeSchema.HasData || keyNodeSchema.AdditionalProperties == nil) { keyNodeSchema.AdditionalProperties = new(bool) } // If no title was set, use the key value if keyNodeSchema.Title == "" && !skipAutoGeneration.Title { keyNodeSchema.Title = keyNode.Value } // If no description was set, use the rest of the comment as description if keyNodeSchema.Description == "" && !skipAutoGeneration.Description { keyNodeSchema.Description = description } // If no default value was set, use the values node value as default if !skipAutoGeneration.Default && keyNodeSchema.Default == nil && valueNode.Kind == yaml.ScalarNode { keyNodeSchema.Default = castNodeValueByType(valueNode.Value, keyNodeSchema.Type) } // If the value is another map and no properties are set, get them from default values if valueNode.Kind == yaml.MappingNode && keyNodeSchema.Properties == nil { keyNodeSchema.Properties = make(map[string]*Schema) generatedSchema, err := YamlToSchema( valuesPath, valueNode, keepFullComment, helmDocsCompatibilityMode, dontRemoveHelmDocsPrefix, dontAddGlobal, skipAutoGeneration, &keyNodeSchema.Required.Strings, ) if err != nil { return nil, err } generatedProperties := generatedSchema.Properties // Process each property for i := 0; i < len(valueNode.Content); i += 2 { propKeyNode := valueNode.Content[i] // Check if this specific property matches any pattern skipProperty := false for pattern := range keyNodeSchema.PatternProperties { matched, err := regexp.MatchString(pattern, propKeyNode.Value) if err != nil { return nil, fmt.Errorf("invalid pattern '%s' in patternProperties: %w", pattern, err) } if matched { skipProperty = true break } } // Only add schema for non-skipped properties if !skipProperty { if prop, exists := generatedProperties[propKeyNode.Value]; exists { keyNodeSchema.Properties[propKeyNode.Value] = prop } } } } else if valueNode.Kind == yaml.SequenceNode && keyNodeSchema.Items == nil { // If the value is a sequence, but no items are predefined seqSchema := NewSchema("") for _, itemNode := range valueNode.Content { if itemNode.Kind == yaml.ScalarNode { itemNodeType, err := typeFromTag(itemNode.Tag) if err != nil { return nil, fmt.Errorf("error inferring type for array item: %w", err) } seqSchema.AnyOf = append(seqSchema.AnyOf, NewSchema(itemNodeType[0])) } else { itemRequiredProperties := []string{} itemSchema, err := YamlToSchema(valuesPath, itemNode, keepFullComment, helmDocsCompatibilityMode, dontRemoveHelmDocsPrefix, dontAddGlobal, skipAutoGeneration, &itemRequiredProperties) if err != nil { return nil, err } itemSchema.Required.Strings = append(itemSchema.Required.Strings, itemRequiredProperties...) if !skipAutoGeneration.AdditionalProperties && itemNode.Kind == yaml.MappingNode && (!itemSchema.HasData || itemSchema.AdditionalProperties == nil) { itemSchema.AdditionalProperties = new(bool) } seqSchema.AnyOf = append(seqSchema.AnyOf, itemSchema) } } keyNodeSchema.Items = seqSchema // Because the `required` field isn't valid jsonschema (but just a helper boolean) // we must convert them to valid requiredProperties fields FixRequiredProperties(&keyNodeSchema) } } if schema.Properties == nil { schema.Properties = make(map[string]*Schema) } schema.Properties[keyNode.Value] = &keyNodeSchema } } return schema, nil } func helmDocsTypeToSchemaType(helmDocsType string) (string, error) { switch helmDocsType { case "int": return "integer", nil case "bool": return "boolean", nil case "float": return "number", nil case "list": return "array", nil case "map": return "object", nil case "tpl": return "string", nil case "string", "object": return helmDocsType, nil } return "", fmt.Errorf("cant translate helm-docs type (%s) to helm-schema type", helmDocsType) } // castNodeValueByType attempts to convert a raw string value into the appropriate type based on // the provided fieldType. It handles boolean, integer, and number conversions. If the conversion // fails or the type is not supported (e.g., string), it returns the original raw value. // // Parameters: // - rawValue: The string value to be converted // - fieldType: Array of allowed JSON Schema types for this field // // Returns: // - The converted value as interface{}, or the original string if conversion fails/isn't needed func castNodeValueByType(rawValue string, fieldType StringOrArrayOfString) any { if len(fieldType) == 0 { return rawValue } // rawValue must be one of fielTypes for _, t := range fieldType { switch t { case "boolean": switch rawValue { case "true": return true case "false": return false } case "integer": v, err := strconv.Atoi(rawValue) if err == nil { return v } case "number": v, err := strconv.ParseFloat(rawValue, 64) if err == nil { return v } } } return rawValue } // decodeNodeValue converts a yaml.Node into a plain Go value suitable for JSON Schema keywords. // Aliases are expanded by yaml.v3 during Decode. func decodeNodeValue(node *yaml.Node) (interface{}, error) { var value interface{} if err := node.Decode(&value); err != nil { return nil, err } return value, nil } // handleSchemaRefs processes and resolves JSON Schema references ($ref) within a schema. // It handles both direct schema references and references within patternProperties. // For each reference: // - If it's a relative file path, it attempts to load and parse the referenced schema // - If it includes a JSON pointer (#/path/to/schema), it extracts the specific schema section // - The resolved schema replaces the original reference // // Parameters: // - schema: Pointer to the Schema object containing the references to resolve // - valuesPath: Path to the current values file, used for resolving relative paths // // Returns: // - An error if the reference cannot be resolved, or nil on success func handleSchemaRefs(schema *Schema, valuesPath string) error { // Handle main schema $ref if schema.Ref != "" { fileRef, jsonPointer, hasJSONPointer := strings.Cut(schema.Ref, "#") if fileRef == "" { return nil } relFilePath, err := util.IsRelativeFile(valuesPath, fileRef) if err != nil { // Not a relative file path, may be handled elsewhere log.Debug(err) return nil } var relSchema Schema file, err := os.Open(relFilePath) if err != nil { return fmt.Errorf("failed to open referenced schema file %s: %w", relFilePath, err) } defer file.Close() byteValue, err := io.ReadAll(file) if err != nil { return fmt.Errorf("failed to read referenced schema file %s: %w", relFilePath, err) } if hasJSONPointer && jsonPointer != "" { // Found json-pointer var obj interface{} if err := json.Unmarshal(byteValue, &obj); err != nil { return fmt.Errorf("failed to unmarshal JSON from %s: %w", relFilePath, err) } jsonPointerResultRaw, err := jsonpointer.Get(obj, jsonPointer) if err != nil { return fmt.Errorf("failed to resolve JSON pointer %s in %s: %w", jsonPointer, relFilePath, err) } jsonPointerResultMarshaled, err := json.Marshal(jsonPointerResultRaw) if err != nil { return fmt.Errorf("failed to marshal JSON pointer result from %s: %w", relFilePath, err) } if err := json.Unmarshal(jsonPointerResultMarshaled, &relSchema); err != nil { return fmt.Errorf("failed to unmarshal JSON pointer result from %s: %w", relFilePath, err) } } else { // No json-pointer if err := json.Unmarshal(byteValue, &relSchema); err != nil { return fmt.Errorf("failed to unmarshal schema from %s: %w", relFilePath, err) } } *schema = relSchema schema.HasData = true } // Handle $ref in pattern properties if schema.PatternProperties != nil { for pattern, subSchema := range schema.PatternProperties { if subSchema.Ref != "" { if err := handleSchemaRefs(subSchema, valuesPath); err != nil { return fmt.Errorf("failed to resolve $ref in patternProperties[%s]: %w", pattern, err) } schema.PatternProperties[pattern] = subSchema // Update the original schema in the map } } } return nil } ================================================ FILE: pkg/schema/schema_test.go ================================================ package schema import ( "encoding/json" "fmt" "reflect" "strings" "testing" "github.com/magiconair/properties/assert" "gopkg.in/yaml.v3" ) func TestValidate(t *testing.T) { tests := []struct { comment string expectedValid bool }{ { comment: ` # @schema # multipleOf: 0 # @schema`, expectedValid: false, }, { comment: ` # @schema # type: doesnotexist # @schema`, expectedValid: false, }, { comment: ` # @schema # type: [doesnotexist, string] # @schema`, expectedValid: false, }, { comment: ` # @schema # type: [string, integer] # @schema`, expectedValid: true, }, { comment: ` # @schema # type: string # @schema`, expectedValid: true, }, { comment: ` # @schema # const: "hello" # @schema`, expectedValid: true, }, { comment: ` # @schema # const: true # @schema`, expectedValid: true, }, { comment: ` # @schema # const: null # @schema`, expectedValid: true, }, { comment: ` # @schema # format: ipv4 # @schema`, expectedValid: true, }, { comment: ` # @schema # pattern: ^foo # format: ipv4 # @schema`, expectedValid: false, }, { comment: ` # @schema # readOnly: true # @schema`, expectedValid: true, }, { comment: ` # @schema # writeOnly: true # @schema`, expectedValid: true, }, { comment: ` # @schema # anyOf: # - type: "null" # - format: date-time # - format: date # @schema`, expectedValid: true, }, { comment: ` # @schema # not: # type: "null" # @schema`, expectedValid: true, }, { comment: ` # @schema # anyOf: # - type: "null" # - format: date-time # if: # type: "null" # then: # description: If set to null, this will do nothing # else: # description: Here goes the description for date-time # @schema`, expectedValid: true, }, { comment: ` # @schema # $ref: https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.29.2/affinity-v1.json # @schema`, expectedValid: true, }, { comment: ` # @schema # minLength: 1 # maxLength: 0 # @schema`, expectedValid: false, }, { comment: ` # @schema # minLength: 1 # maxLength: 2 # @schema`, expectedValid: true, }, { comment: ` # @schema # minItems: 1 # maxItems: 2 # @schema`, expectedValid: true, }, { comment: ` # @schema # minItems: 2 # maxItems: 1 # @schema`, expectedValid: false, }, { comment: ` # @schema # type: string # minItems: 1 # @schema`, expectedValid: false, }, { comment: ` # @schema # type: boolean # uniqueItems: true # @schema`, expectedValid: true, }, } for _, test := range tests { schema, _, err := GetSchemaFromComment(test.comment) if err != nil && test.expectedValid { t.Errorf( "Expected the schema %s to be valid=%t, but can't even parse it: %v", test.comment, test.expectedValid, err, ) } err = schema.Validate() valid := err == nil if valid != test.expectedValid { t.Errorf( "Expected schema\n%s\n\n to be valid=%t, but it's %t", test.comment, test.expectedValid, valid, ) } } } func TestUnmarshalYAML(t *testing.T) { yamlData := ` type: string x-custom-foo: bar ` var schema Schema if err := yaml.Unmarshal([]byte(yamlData), &schema); err != nil { fmt.Println("Error unmarshaling YAML:", err) return } assert.Equal(t, schema.Type, StringOrArrayOfString{"string"}) assert.Equal(t, schema.CustomAnnotations["x-custom-foo"], "bar") } func TestUnmarshalJSON(t *testing.T) { jsonData := `{ "type": "object", "x-custom-foo": "bar", "x-kubernetes-preserve-unknown-fields": true, "const": null, "properties": { "nested": { "type": "string", "x-nested-annotation": 42 } } }` var schema Schema err := json.Unmarshal([]byte(jsonData), &schema) if err != nil { t.Fatalf("unexpected error: %v", err) } assert.Equal(t, schema.Type, StringOrArrayOfString{"object"}) assert.Equal(t, schema.CustomAnnotations["x-custom-foo"], "bar") assert.Equal(t, schema.CustomAnnotations["x-kubernetes-preserve-unknown-fields"], true) assert.Equal(t, schema.constWasSet, true) // Nested schema should also preserve custom annotations if schema.Properties["nested"] == nil { t.Fatal("expected nested property to exist") } assert.Equal(t, schema.Properties["nested"].CustomAnnotations["x-nested-annotation"], float64(42)) } func TestNewDraft7Keywords(t *testing.T) { tests := []struct { name string comment string expectedValid bool }{ // Float numeric constraints tests { name: "minimum with float value", comment: ` # @schema # type: number # minimum: 1.5 # @schema`, expectedValid: true, }, { name: "maximum with float value", comment: ` # @schema # type: number # maximum: 99.9 # @schema`, expectedValid: true, }, { name: "exclusiveMinimum with float value", comment: ` # @schema # type: number # exclusiveMinimum: 0.1 # @schema`, expectedValid: true, }, { name: "exclusiveMaximum with float value", comment: ` # @schema # type: number # exclusiveMaximum: 100.5 # @schema`, expectedValid: true, }, { name: "multipleOf with float value", comment: ` # @schema # type: number # multipleOf: 0.1 # @schema`, expectedValid: true, }, { name: "minimum greater than maximum should fail", comment: ` # @schema # type: number # minimum: 10.5 # maximum: 5.5 # @schema`, expectedValid: false, }, // $comment keyword { name: "$comment keyword", comment: ` # @schema # type: string # $comment: This is a schema comment for developers # @schema`, expectedValid: true, }, // contentEncoding and contentMediaType { name: "contentEncoding keyword", comment: ` # @schema # type: string # contentEncoding: base64 # @schema`, expectedValid: true, }, { name: "contentMediaType keyword", comment: ` # @schema # type: string # contentMediaType: application/json # @schema`, expectedValid: true, }, { name: "contentEncoding with non-string type should fail", comment: ` # @schema # type: integer # contentEncoding: base64 # @schema`, expectedValid: false, }, // contains keyword { name: "contains keyword with array type", comment: ` # @schema # type: array # contains: # type: string # @schema`, expectedValid: true, }, { name: "contains with non-array type should fail", comment: ` # @schema # type: object # contains: # type: string # @schema`, expectedValid: false, }, // additionalItems keyword { name: "additionalItems as boolean", comment: ` # @schema # type: array # additionalItems: false # @schema`, expectedValid: true, }, { name: "additionalItems as schema", comment: ` # @schema # type: array # additionalItems: # type: string # @schema`, expectedValid: true, }, { name: "additionalItems with non-array type should fail", comment: ` # @schema # type: object # additionalItems: false # @schema`, expectedValid: false, }, // minProperties and maxProperties { name: "minProperties keyword", comment: ` # @schema # type: object # minProperties: 1 # @schema`, expectedValid: true, }, { name: "maxProperties keyword", comment: ` # @schema # type: object # maxProperties: 10 # @schema`, expectedValid: true, }, { name: "minProperties greater than maxProperties should fail", comment: ` # @schema # type: object # minProperties: 10 # maxProperties: 5 # @schema`, expectedValid: false, }, { name: "minProperties with non-object type should fail", comment: ` # @schema # type: string # minProperties: 1 # @schema`, expectedValid: false, }, // propertyNames keyword { name: "propertyNames keyword", comment: ` # @schema # type: object # propertyNames: # pattern: ^[a-z]+$ # @schema`, expectedValid: true, }, { name: "propertyNames with non-object type should fail", comment: ` # @schema # type: array # propertyNames: # pattern: ^[a-z]+$ # @schema`, expectedValid: false, }, // dependencies keyword { name: "dependencies with array of property names", comment: ` # @schema # type: object # dependencies: # bar: ["foo"] # @schema`, expectedValid: true, }, { name: "dependencies with schema", comment: ` # @schema # type: object # dependencies: # bar: # properties: # foo: # type: string # @schema`, expectedValid: true, }, // definitions keyword { name: "definitions keyword", comment: ` # @schema # definitions: # address: # type: object # properties: # street: # type: string # @schema`, expectedValid: true, }, // Invalid pattern regex { name: "invalid pattern regex should fail", comment: ` # @schema # type: string # pattern: "[invalid" # @schema`, expectedValid: false, }, // additionalProperties type check { name: "additionalProperties with non-object type should fail", comment: ` # @schema # type: string # additionalProperties: false # @schema`, expectedValid: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { schema, _, err := GetSchemaFromComment(tt.comment) if err != nil { if tt.expectedValid { t.Errorf("Expected the schema to be valid, but can't even parse it: %v", err) } return } err = schema.Validate() valid := err == nil if valid != tt.expectedValid { t.Errorf("Expected schema to be valid=%t, but got valid=%t (error: %v)", tt.expectedValid, valid, err) } }) } } func TestFloatNumericConstraintsMarshaling(t *testing.T) { tests := []struct { name string yamlData string expectedJSON string }{ { name: "minimum with float", yamlData: "type: number\nminimum: 1.5", expectedJSON: `"minimum": 1.5`, }, { name: "maximum with float", yamlData: "type: number\nmaximum: 99.9", expectedJSON: `"maximum": 99.9`, }, { name: "multipleOf with float", yamlData: "type: number\nmultipleOf: 0.01", expectedJSON: `"multipleOf": 0.01`, }, { name: "exclusiveMinimum with float", yamlData: "type: number\nexclusiveMinimum: 0.5", expectedJSON: `"exclusiveMinimum": 0.5`, }, { name: "exclusiveMaximum with float", yamlData: "type: number\nexclusiveMaximum: 100.5", expectedJSON: `"exclusiveMaximum": 100.5`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var schema Schema if err := yaml.Unmarshal([]byte(tt.yamlData), &schema); err != nil { t.Fatalf("Error unmarshaling YAML: %v", err) } jsonData, err := schema.ToJson() if err != nil { t.Fatalf("Error marshaling to JSON: %v", err) } jsonStr := string(jsonData) if !strings.Contains(jsonStr, tt.expectedJSON) { t.Errorf("Expected JSON to contain %q, but got:\n%s", tt.expectedJSON, jsonStr) } }) } } func TestNewKeywordsMarshaling(t *testing.T) { tests := []struct { name string yamlData string expectedJSON string }{ { name: "$comment keyword", yamlData: "$comment: Test comment", expectedJSON: `"$comment": "Test comment"`, }, { name: "contentEncoding keyword", yamlData: "contentEncoding: base64", expectedJSON: `"contentEncoding": "base64"`, }, { name: "contentMediaType keyword", yamlData: "contentMediaType: application/json", expectedJSON: `"contentMediaType": "application/json"`, }, { name: "minProperties keyword", yamlData: "minProperties: 2", expectedJSON: `"minProperties": 2`, }, { name: "maxProperties keyword", yamlData: "maxProperties: 10", expectedJSON: `"maxProperties": 10`, }, { name: "contains keyword", yamlData: "type: array\ncontains:\n type: string", expectedJSON: `"contains"`, }, { name: "propertyNames keyword", yamlData: "type: object\npropertyNames:\n pattern: ^[a-z]+$", expectedJSON: `"propertyNames"`, }, { name: "additionalItems as boolean", yamlData: "type: array\nadditionalItems: false", expectedJSON: `"additionalItems": false`, }, { name: "definitions keyword", yamlData: "definitions:\n myDef:\n type: string", expectedJSON: `"definitions"`, }, { name: "dependencies keyword", yamlData: "dependencies:\n bar: [\"foo\"]", expectedJSON: `"dependencies"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var schema Schema if err := yaml.Unmarshal([]byte(tt.yamlData), &schema); err != nil { t.Fatalf("Error unmarshaling YAML: %v", err) } jsonData, err := schema.ToJson() if err != nil { t.Fatalf("Error marshaling to JSON: %v", err) } jsonStr := string(jsonData) if !strings.Contains(jsonStr, tt.expectedJSON) { t.Errorf("Expected JSON to contain %q, but got:\n%s", tt.expectedJSON, jsonStr) } }) } } func TestDisableRequiredPropertiesWithNewFields(t *testing.T) { // Test that DisableRequiredProperties works with all new nested schema fields schema := Schema{ Type: StringOrArrayOfString{"object"}, Required: BoolOrArrayOfString{ Strings: []string{"foo"}, }, Contains: &Schema{ Required: BoolOrArrayOfString{Strings: []string{"inner"}}, }, PropertyNames: &Schema{ Required: BoolOrArrayOfString{Strings: []string{"name"}}, }, Definitions: map[string]*Schema{ "myDef": { Required: BoolOrArrayOfString{Strings: []string{"defProp"}}, }, }, } schema.DisableRequiredProperties() if len(schema.Required.Strings) != 0 { t.Error("Expected root required to be empty") } if len(schema.Contains.Required.Strings) != 0 { t.Error("Expected Contains required to be empty") } if len(schema.PropertyNames.Required.Strings) != 0 { t.Error("Expected PropertyNames required to be empty") } if len(schema.Definitions["myDef"].Required.Strings) != 0 { t.Error("Expected Definitions[myDef] required to be empty") } } func TestDefsToDefinitionsConversion(t *testing.T) { // Test that $defs is converted to definitions when unmarshaling JSON tests := []struct { name string jsonInput string expectDefs bool expectedKeys []string }{ { name: "$defs is converted to definitions", jsonInput: `{ "$defs": { "MyType": {"type": "string"} } }`, expectDefs: true, expectedKeys: []string{"MyType"}, }, { name: "definitions is preserved", jsonInput: `{ "definitions": { "OtherType": {"type": "integer"} } }`, expectDefs: true, expectedKeys: []string{"OtherType"}, }, { name: "$defs and definitions are merged", jsonInput: `{ "$defs": { "FromDefs": {"type": "string"} }, "definitions": { "FromDefinitions": {"type": "integer"} } }`, expectDefs: true, expectedKeys: []string{"FromDefs", "FromDefinitions"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var schema Schema if err := json.Unmarshal([]byte(tt.jsonInput), &schema); err != nil { t.Fatalf("Failed to unmarshal JSON: %v", err) } if tt.expectDefs && schema.Definitions == nil { t.Error("Expected Definitions to be non-nil") return } for _, key := range tt.expectedKeys { if _, ok := schema.Definitions[key]; !ok { t.Errorf("Expected key %q in Definitions", key) } } }) } } func TestRefPathRewriting(t *testing.T) { // Test that $ref paths are rewritten from #/$defs/ to #/definitions/ tests := []struct { name string jsonInput string expectedRef string }{ { name: "$ref with $defs path is rewritten", jsonInput: `{ "$ref": "#/$defs/MyType" }`, expectedRef: "#/definitions/MyType", }, { name: "$ref with definitions path is preserved", jsonInput: `{ "$ref": "#/definitions/MyType" }`, expectedRef: "#/definitions/MyType", }, { name: "nested $ref paths are rewritten", jsonInput: `{ "properties": { "foo": { "$ref": "#/$defs/FooType" } } }`, expectedRef: "#/definitions/FooType", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var schema Schema if err := json.Unmarshal([]byte(tt.jsonInput), &schema); err != nil { t.Fatalf("Failed to unmarshal JSON: %v", err) } // Check main ref if schema.Ref != "" && schema.Ref != tt.expectedRef { t.Errorf("Expected Ref to be %q, got %q", tt.expectedRef, schema.Ref) } // Check nested ref in properties if schema.Properties != nil { for _, prop := range schema.Properties { if prop.Ref != "" && prop.Ref != tt.expectedRef { t.Errorf("Expected nested Ref to be %q, got %q", tt.expectedRef, prop.Ref) } } } }) } } func TestConstNullMarshaling(t *testing.T) { tests := []struct { name string yamlData string expectedJSON string shouldContain bool }{ { name: "const with null value should be preserved", yamlData: "const: null", expectedJSON: `"const": null`, shouldContain: true, }, { name: "const with false value should be preserved", yamlData: "const: false", expectedJSON: `"const": false`, shouldContain: true, }, { name: "const with true value should be preserved", yamlData: "const: true", expectedJSON: `"const": true`, shouldContain: true, }, { name: "const with string value should be preserved", yamlData: `const: "test"`, expectedJSON: `"const": "test"`, shouldContain: true, }, { name: "schema without const should not have const field", yamlData: "type: string", expectedJSON: `"const"`, shouldContain: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var schema Schema if err := yaml.Unmarshal([]byte(tt.yamlData), &schema); err != nil { t.Fatalf("Error unmarshaling YAML: %v", err) } jsonData, err := schema.ToJson() if err != nil { t.Fatalf("Error marshaling to JSON: %v", err) } jsonStr := string(jsonData) contains := strings.Contains(jsonStr, tt.expectedJSON) if tt.shouldContain && !contains { t.Errorf("Expected JSON to contain %q, but got:\n%s", tt.expectedJSON, jsonStr) } if !tt.shouldContain && contains { t.Errorf("Expected JSON to NOT contain %q, but got:\n%s", tt.expectedJSON, jsonStr) } }) } } func TestYamlToSchemaConstFromValue(t *testing.T) { tests := []struct { name string yamlContent string expectedConst interface{} expectedErr string }{ { name: "scalar string value", yamlContent: `# @schema # const-from-value: true # @schema message: | long message with {{ .gotemplate }}`, expectedConst: "long message with {{ .gotemplate }}", }, { name: "null value", yamlContent: `# @schema # const-from-value: true # @schema message: null`, expectedConst: nil, }, { name: "mapping value", yamlContent: `# @schema # const-from-value: true # @schema message: enabled: true retries: 2`, expectedConst: map[string]interface{}{ "enabled": true, "retries": 2, }, }, { name: "conflicts with explicit const", yamlContent: `# @schema # const: fixed # const-from-value: true # @schema message: fixed`, expectedErr: "const and const-from-value cannot be used together", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var node yaml.Node if err := yaml.Unmarshal([]byte(tt.yamlContent), &node); err != nil { t.Fatalf("Failed to unmarshal YAML: %v", err) } skipConfig := &SkipAutoGenerationConfig{} schema, err := YamlToSchema("", &node, false, false, false, true, skipConfig, nil) if tt.expectedErr != "" { if err == nil { t.Fatalf("Expected error containing %q, got nil", tt.expectedErr) } if !strings.Contains(err.Error(), tt.expectedErr) { t.Fatalf("Expected error containing %q, got %v", tt.expectedErr, err) } return } if err != nil { t.Fatalf("YamlToSchema failed: %v", err) } property, ok := schema.Properties["message"] if !ok { t.Fatal("Expected schema to contain message property") } if !reflect.DeepEqual(property.Const, tt.expectedConst) { t.Fatalf("Expected const %#v, got %#v", tt.expectedConst, property.Const) } jsonData, err := property.ToJson() if err != nil { t.Fatalf("Failed to marshal property schema to JSON: %v", err) } if !strings.Contains(string(jsonData), `"const"`) { t.Fatalf("Expected JSON to contain const, got %s", string(jsonData)) } if strings.Contains(string(jsonData), "const-from-value") { t.Fatalf("Did not expect JSON to contain const-from-value, got %s", string(jsonData)) } }) } } func TestYamlToSchemaPreservesDocumentLocalRootRef(t *testing.T) { yamlContent := `# @schema # definitions: # toplevel: # description: "Top Level" # $ref: "#/definitions/toplevel" # @schema toplevel: ` var node yaml.Node if err := yaml.Unmarshal([]byte(yamlContent), &node); err != nil { t.Fatalf("Failed to unmarshal YAML: %v", err) } skipConfig := &SkipAutoGenerationConfig{} schema, err := YamlToSchema("/tmp/values.yaml", &node, false, false, false, true, skipConfig, nil) if err != nil { t.Fatalf("YamlToSchema failed: %v", err) } property, ok := schema.Properties["toplevel"] if !ok { t.Fatal("Expected schema to contain toplevel property") } if property.Ref != "#/definitions/toplevel" { t.Fatalf("Expected ref to be preserved, got %q", property.Ref) } schema.HoistDefinitions() if schema.Definitions == nil { t.Fatal("Expected root definitions to be preserved") } definition, ok := schema.Definitions["toplevel"] if !ok { t.Fatal("Expected toplevel definition to be present") } if definition.Description != "Top Level" { t.Fatalf("Expected toplevel definition description %q, got %q", "Top Level", definition.Description) } } func TestGetPropertyAtPath(t *testing.T) { // Create a nested schema structure schema := &Schema{ Type: StringOrArrayOfString{"object"}, Properties: map[string]*Schema{ "exports": { Type: StringOrArrayOfString{"object"}, Properties: map[string]*Schema{ "defaults": { Type: StringOrArrayOfString{"object"}, Properties: map[string]*Schema{ "foo": { Type: StringOrArrayOfString{"string"}, Title: "Foo", }, "bar": { Type: StringOrArrayOfString{"integer"}, Title: "Bar", }, }, }, }, }, "config": { Type: StringOrArrayOfString{"object"}, Title: "Config", }, }, } tests := []struct { name string path string expectedTitle string expectNil bool }{ { name: "empty path returns self", path: "", expectedTitle: "", expectNil: false, }, { name: "single level path", path: "config", expectedTitle: "Config", expectNil: false, }, { name: "nested path", path: "exports.defaults.foo", expectedTitle: "Foo", expectNil: false, }, { name: "non-existent path", path: "exports.nonexistent", expectNil: true, }, { name: "path through non-object", path: "config.deeper", expectNil: true, }, { name: "path with leading dot", path: ".config", expectedTitle: "Config", expectNil: false, }, { name: "path with trailing dot", path: "config.", expectedTitle: "Config", expectNil: false, }, { name: "path with consecutive dots", path: "exports..defaults.foo", expectedTitle: "Foo", expectNil: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := schema.GetPropertyAtPath(tt.path) if tt.expectNil { if result != nil { t.Errorf("Expected nil result, got %+v", result) } return } if result == nil { t.Errorf("Expected non-nil result") return } if tt.path == "" { if result != schema { t.Errorf("Empty path should return self") } return } if result.Title != tt.expectedTitle { t.Errorf("Expected Title %q, got %q", tt.expectedTitle, result.Title) } }) } } func TestSetPropertyAtPath(t *testing.T) { tests := []struct { name string path string expectedPath []string }{ { name: "empty path returns self", path: "", expectedPath: []string{}, }, { name: "single level path", path: "config", expectedPath: []string{"config"}, }, { name: "nested path", path: "exports.defaults.settings", expectedPath: []string{"exports", "defaults", "settings"}, }, { name: "path with leading dot", path: ".config", expectedPath: []string{"config"}, }, { name: "path with trailing dot", path: "config.", expectedPath: []string{"config"}, }, { name: "path with consecutive dots", path: "exports..defaults", expectedPath: []string{"exports", "defaults"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { schema := &Schema{ Type: StringOrArrayOfString{"object"}, Properties: make(map[string]*Schema), } result := schema.SetPropertyAtPath(tt.path) if tt.path == "" { if result != schema { t.Errorf("Empty path should return self") } return } // Verify the path was created current := schema for _, part := range tt.expectedPath { if current.Properties == nil { t.Errorf("Expected Properties to be initialized at %s", part) return } prop, ok := current.Properties[part] if !ok { t.Errorf("Expected property %s to exist", part) return } current = prop } if current != result { t.Errorf("Final property should match returned schema") } }) } } func TestHoistDefinitions(t *testing.T) { // Create a schema with nested definitions restConfig := &Schema{ Type: StringOrArrayOfString{"object"}, Title: "RestConfig", Description: "REST API configuration", Properties: map[string]*Schema{ "url": { Type: StringOrArrayOfString{"string"}, Format: "uri", }, }, } workerSchema := &Schema{ Type: StringOrArrayOfString{"object"}, Title: "Worker", Description: "Worker configuration", Definitions: map[string]*Schema{ "RestConfig": restConfig, }, Properties: map[string]*Schema{ "api": { Ref: "#/definitions/RestConfig", }, }, } rootSchema := &Schema{ Schema: "http://json-schema.org/draft-07/schema#", Type: StringOrArrayOfString{"object"}, Properties: map[string]*Schema{ "worker": workerSchema, }, } // Verify definitions are nested before hoisting if rootSchema.Definitions != nil && len(rootSchema.Definitions) > 0 { t.Error("Root should not have definitions before hoisting") } if workerSchema.Definitions == nil || len(workerSchema.Definitions) == 0 { t.Error("Worker should have definitions before hoisting") } // Hoist definitions rootSchema.HoistDefinitions() // Verify definitions are at root after hoisting if rootSchema.Definitions == nil || len(rootSchema.Definitions) == 0 { t.Error("Root should have definitions after hoisting") } if _, ok := rootSchema.Definitions["RestConfig"]; !ok { t.Error("Root should have RestConfig definition after hoisting") } // Verify definitions are removed from nested schema if workerSchema.Definitions != nil && len(workerSchema.Definitions) > 0 { t.Error("Worker should not have definitions after hoisting") } // Verify the $ref still points to the correct location if rootSchema.Properties["worker"].Properties["api"].Ref != "#/definitions/RestConfig" { t.Error("$ref should still point to #/definitions/RestConfig") } // Verify the hoisted definition is correct if rootSchema.Definitions["RestConfig"].Title != "RestConfig" { t.Errorf("Hoisted definition should have correct title, got %s", rootSchema.Definitions["RestConfig"].Title) } } ================================================ FILE: pkg/schema/toposort.go ================================================ package schema import ( "fmt" ) // TopoSort uses topological sorting to sort the results // If allowCircular is true, circular dependencies will be logged as warnings and results will be returned unsorted func TopoSort(results []*Result, allowCircular bool) ([]*Result, error) { // Map chart names to their Result objects for easy lookup chartMap := make(map[string]*Result) for _, r := range results { if r.Chart != nil { chartMap[r.Chart.Name] = r } } // Build dependency graph as adjacency list deps := make(map[string][]string) // Build dependency graph for _, r := range results { if r.Chart == nil { continue } // Initialize empty dependency list deps[r.Chart.Name] = []string{} // Add all dependencies for _, dep := range r.Chart.Dependencies { deps[r.Chart.Name] = append(deps[r.Chart.Name], dep.Name) } } // Track visited nodes during traversal visited := make(map[string]bool) // Track nodes in current recursion stack to detect cycles inStack := make(map[string]bool) // Final sorted results var sorted []*Result // Recursive DFS helper function var visit func(string) error visit = func(chart string) error { // Check for cycle first, before the visited check if inStack[chart] { return &CircularError{fmt.Sprintf("circular dependency detected: %s", chart)} } // Return if already visited if visited[chart] { return nil } // Mark as being visited inStack[chart] = true visited[chart] = true // Visit all dependencies first for _, dep := range deps[chart] { if err := visit(dep); err != nil { return err } } // Add to sorted results after dependencies if result, exists := chartMap[chart]; exists { sorted = append(sorted, result) } // Remove from recursion stack inStack[chart] = false return nil } // Visit all charts for _, r := range results { if r.Chart != nil { if err := visit(r.Chart.Name); err != nil { if allowCircular { // Return unsorted results when circular dependencies are allowed return results, nil } return nil, err } } } return sorted, nil } ================================================ FILE: pkg/schema/toposort_test.go ================================================ package schema import ( "testing" "github.com/dadav/helm-schema/pkg/chart" "github.com/stretchr/testify/assert" ) func TestTopoSort(t *testing.T) { tests := []struct { name string results []*Result allowCircular bool want []string // expected order of chart names wantErr bool errorType error }{ { name: "simple dependency chain", results: []*Result{ {Chart: &chart.ChartFile{Name: "A", Dependencies: []*chart.Dependency{{Name: "B"}}}}, {Chart: &chart.ChartFile{Name: "B", Dependencies: []*chart.Dependency{{Name: "C"}}}}, {Chart: &chart.ChartFile{Name: "C", Dependencies: []*chart.Dependency{}}}, }, allowCircular: false, want: []string{"C", "B", "A"}, wantErr: false, }, { name: "multiple dependencies", results: []*Result{ {Chart: &chart.ChartFile{Name: "A", Dependencies: []*chart.Dependency{{Name: "B"}, {Name: "C"}}}}, {Chart: &chart.ChartFile{Name: "B", Dependencies: []*chart.Dependency{{Name: "D"}}}}, {Chart: &chart.ChartFile{Name: "C", Dependencies: []*chart.Dependency{{Name: "D"}}}}, {Chart: &chart.ChartFile{Name: "D", Dependencies: []*chart.Dependency{}}}, }, allowCircular: false, want: []string{"D", "B", "C", "A"}, wantErr: false, }, { name: "circular dependency", results: []*Result{ {Chart: &chart.ChartFile{Name: "A", Dependencies: []*chart.Dependency{{Name: "B"}}}}, {Chart: &chart.ChartFile{Name: "B", Dependencies: []*chart.Dependency{{Name: "A"}}}}, }, allowCircular: false, want: nil, wantErr: true, errorType: &CircularError{}, }, { name: "nil chart in results", results: []*Result{ {Chart: &chart.ChartFile{Name: "A", Dependencies: []*chart.Dependency{{Name: "B"}}}}, {Chart: nil}, {Chart: &chart.ChartFile{Name: "B", Dependencies: []*chart.Dependency{}}}, }, allowCircular: false, want: []string{"B", "A"}, wantErr: false, }, { name: "circular dependency allowed", results: []*Result{ {Chart: &chart.ChartFile{Name: "A", Dependencies: []*chart.Dependency{{Name: "B"}}}}, {Chart: &chart.ChartFile{Name: "B", Dependencies: []*chart.Dependency{{Name: "A"}}}}, }, allowCircular: true, want: []string{"A", "B"}, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := TopoSort(tt.results, tt.allowCircular) if tt.wantErr { assert.Error(t, err) if tt.errorType != nil { assert.IsType(t, tt.errorType, err) } // When allowCircular is true and we get a CircularError, // we should still get unsorted results back if tt.allowCircular && tt.want != nil { // Convert results to slice of chart names for easier comparison var gotNames []string for _, r := range got { gotNames = append(gotNames, r.Chart.Name) } assert.Equal(t, tt.want, gotNames) } return } assert.NoError(t, err) // Convert results to slice of chart names for easier comparison var gotNames []string for _, r := range got { gotNames = append(gotNames, r.Chart.Name) } assert.Equal(t, tt.want, gotNames) }) } } ================================================ FILE: pkg/schema/values_merge.go ================================================ package schema import ( "fmt" "gopkg.in/yaml.v3" ) // mergeValuesDocuments merges YAML documents using Helm-style precedence: // later files override earlier files, and nested mappings merge recursively. func mergeValuesDocuments(base *yaml.Node, overlay *yaml.Node) (*yaml.Node, error) { if base == nil { return cloneYAMLNode(overlay), nil } if overlay == nil { return cloneYAMLNode(base), nil } if base.Kind != yaml.DocumentNode || overlay.Kind != yaml.DocumentNode { return nil, fmt.Errorf("expected yaml document nodes, got %d and %d", base.Kind, overlay.Kind) } if len(base.Content) != 1 || len(overlay.Content) != 1 { return nil, fmt.Errorf("unexpected yaml document structure while merging values") } merged := cloneYAMLNode(base) merged.HeadComment = mergeCommentText(merged.HeadComment, overlay.HeadComment) merged.LineComment = mergeCommentText(merged.LineComment, overlay.LineComment) merged.FootComment = mergeCommentText(merged.FootComment, overlay.FootComment) mergedContent, err := mergeValuesNodes(merged.Content[0], overlay.Content[0]) if err != nil { return nil, err } merged.Content[0] = mergedContent return merged, nil } func mergeValuesNodes(base *yaml.Node, overlay *yaml.Node) (*yaml.Node, error) { if base == nil { return cloneYAMLNode(overlay), nil } if overlay == nil { return cloneYAMLNode(base), nil } if base.Kind == yaml.AliasNode && base.Alias != nil { base = base.Alias } if overlay.Kind == yaml.AliasNode && overlay.Alias != nil { overlay = overlay.Alias } if base.Kind == yaml.MappingNode && overlay.Kind == yaml.MappingNode { return mergeMappingNodes(base, overlay) } replacement := cloneYAMLNode(overlay) replacement.HeadComment = mergeCommentText(base.HeadComment, overlay.HeadComment) replacement.LineComment = mergeCommentText(base.LineComment, overlay.LineComment) replacement.FootComment = mergeCommentText(base.FootComment, overlay.FootComment) return replacement, nil } func mergeMappingNodes(base *yaml.Node, overlay *yaml.Node) (*yaml.Node, error) { merged := cloneYAMLNode(base) merged.Content = nil merged.HeadComment = mergeCommentText(base.HeadComment, overlay.HeadComment) merged.LineComment = mergeCommentText(base.LineComment, overlay.LineComment) merged.FootComment = mergeCommentText(base.FootComment, overlay.FootComment) overlayIndex := make(map[string]int, len(overlay.Content)/2) for i := 0; i+1 < len(overlay.Content); i += 2 { overlayIndex[overlay.Content[i].Value] = i } usedOverlayKeys := make(map[string]bool, len(overlayIndex)) for i := 0; i+1 < len(base.Content); i += 2 { baseKey := base.Content[i] baseValue := base.Content[i+1] overlayPos, exists := overlayIndex[baseKey.Value] if !exists { merged.Content = append(merged.Content, cloneYAMLNode(baseKey), cloneYAMLNode(baseValue)) continue } overlayKey := overlay.Content[overlayPos] overlayValue := overlay.Content[overlayPos+1] usedOverlayKeys[baseKey.Value] = true mergedKey := cloneYAMLNode(baseKey) mergedKey.HeadComment = mergeCommentText(baseKey.HeadComment, overlayKey.HeadComment) mergedKey.LineComment = mergeCommentText(baseKey.LineComment, overlayKey.LineComment) mergedKey.FootComment = mergeCommentText(baseKey.FootComment, overlayKey.FootComment) mergedValue, err := mergeValuesNodes(baseValue, overlayValue) if err != nil { return nil, err } merged.Content = append(merged.Content, mergedKey, mergedValue) } for i := 0; i+1 < len(overlay.Content); i += 2 { overlayKey := overlay.Content[i] if usedOverlayKeys[overlayKey.Value] { continue } merged.Content = append(merged.Content, cloneYAMLNode(overlayKey), cloneYAMLNode(overlay.Content[i+1])) } return merged, nil } func cloneYAMLNode(node *yaml.Node) *yaml.Node { if node == nil { return nil } cloned := *node if node.Content != nil { cloned.Content = make([]*yaml.Node, len(node.Content)) for i, child := range node.Content { cloned.Content[i] = cloneYAMLNode(child) } } if node.Alias != nil { cloned.Alias = cloneYAMLNode(node.Alias) } return &cloned } func mergeCommentText(base string, overlay string) string { if overlay != "" { return overlay } return base } ================================================ FILE: pkg/schema/worker.go ================================================ package schema import ( "bytes" "fmt" "os" "path/filepath" "strings" "github.com/dadav/helm-schema/pkg/chart" "github.com/dadav/helm-schema/pkg/util" "gopkg.in/yaml.v3" ) type Result struct { ChartPath string ValuesPath string Chart *chart.ChartFile Schema Schema Errors []error PreExistingSchema bool } func Worker( dryRun, uncomment, addSchemaReference, keepFullComment, helmDocsCompatibilityMode, dontRemoveHelmDocsPrefix, dontAddGlobal, annotate bool, valueFileNames []string, skipAutoGenerationConfig *SkipAutoGenerationConfig, outFile string, queue <-chan string, results chan<- Result, ) { for chartPath := range queue { result := Result{ChartPath: chartPath} chartBasePath := filepath.Dir(chartPath) file, err := os.Open(chartPath) if err != nil { result.Errors = append(result.Errors, err) results <- result continue } chart, err := chart.ReadChart(file) file.Close() if err != nil { result.Errors = append(result.Errors, err) results <- result continue } result.Chart = &chart var valuesPath string valuesPaths := []string{} errorsWeMaybeCanIgnore := []error{} for _, possibleValueFileName := range valueFileNames { candidatePath := filepath.Join(chartBasePath, possibleValueFileName) _, err := os.Stat(candidatePath) if err != nil { if !os.IsNotExist(err) { errorsWeMaybeCanIgnore = append(errorsWeMaybeCanIgnore, err) } continue } valuesPaths = append(valuesPaths, candidatePath) } if len(valuesPaths) == 0 { result.Errors = append(result.Errors, errorsWeMaybeCanIgnore...) result.Errors = append(result.Errors, fmt.Errorf("no values file found (tried: %s)", strings.Join(valueFileNames, ", "))) results <- result continue } valuesPath = valuesPaths[0] result.ValuesPath = valuesPath // Annotate mode: write @schema annotations into values.yaml and skip schema generation if annotate { if err := AnnotateValuesFile(valuesPath, dryRun); err != nil { result.Errors = append(result.Errors, err) } results <- result continue } valuesFile, err := os.Open(valuesPath) if err != nil { result.Errors = append(result.Errors, err) results <- result continue } content, err := util.ReadFileAndFixNewline(valuesFile) valuesFile.Close() if err != nil { result.Errors = append(result.Errors, err) results <- result continue } // Check if we need to add a schema reference if addSchemaReference && !dryRun { schemaRef := `# yaml-language-server: $schema=values.schema.json` if !strings.Contains(string(content), schemaRef) { err = util.PrefixFirstYamlDocument(schemaRef, valuesPath) if err != nil { result.Errors = append(result.Errors, err) results <- result continue } } } var mergedValues *yaml.Node for _, currentValuesPath := range valuesPaths { valuesFile, err := os.Open(currentValuesPath) if err != nil { result.Errors = append(result.Errors, err) break } currentContent, err := util.ReadFileAndFixNewline(valuesFile) valuesFile.Close() if err != nil { result.Errors = append(result.Errors, err) break } if uncomment { // Remove comments from valid yaml before parsing. currentContent, err = util.RemoveCommentsFromYaml(bytes.NewReader(currentContent)) if err != nil { result.Errors = append(result.Errors, err) break } } var currentValues yaml.Node err = yaml.Unmarshal(currentContent, ¤tValues) if err != nil { result.Errors = append(result.Errors, err) break } mergedValues, err = mergeValuesDocuments(mergedValues, ¤tValues) if err != nil { result.Errors = append(result.Errors, err) break } } if len(result.Errors) > 0 { results <- result continue } schema, err := YamlToSchema(valuesPath, mergedValues, keepFullComment, helmDocsCompatibilityMode, dontRemoveHelmDocsPrefix, dontAddGlobal, skipAutoGenerationConfig, nil) if err != nil { result.Errors = append(result.Errors, err) results <- result continue } result.Schema = *schema results <- result } } ================================================ FILE: pkg/schema/worker_test.go ================================================ package schema import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestWorker(t *testing.T) { tests := []struct { name string setupFiles map[string]string // map of filepath to content chartPath string valueFileNames []string dryRun bool uncomment bool addSchemaReference bool keepFullComment bool helmDocsCompatibilityMode bool dontRemoveHelmDocsPrefix bool dontAddGlobal bool skipAutoGenerationConfig *SkipAutoGenerationConfig outFile string expectedErrors bool }{ { name: "valid chart and values", setupFiles: map[string]string{ "Chart.yaml": ` apiVersion: v2 name: test-chart version: 1.0.0 `, "values.yaml": ` # -- first value key1: value1 # -- second value key2: value2 `, }, chartPath: "Chart.yaml", valueFileNames: []string{"values.yaml"}, uncomment: true, addSchemaReference: true, keepFullComment: true, helmDocsCompatibilityMode: true, skipAutoGenerationConfig: &SkipAutoGenerationConfig{ Title: false, Description: false, Required: false, Default: false, AdditionalProperties: false, }, }, { name: "missing values file", setupFiles: map[string]string{ "Chart.yaml": ` apiVersion: v2 name: test-chart version: 1.0.0 `, }, chartPath: "Chart.yaml", valueFileNames: []string{"values.yaml"}, expectedErrors: true, skipAutoGenerationConfig: &SkipAutoGenerationConfig{ Title: false, Description: false, Required: false, Default: false, AdditionalProperties: false, }, }, { name: "invalid chart file", setupFiles: map[string]string{ "Chart.yaml": ` name: [this is invalid yaml version: 1.0.0 `, "values.yaml": ` key1: value1 `, }, chartPath: "Chart.yaml", valueFileNames: []string{"values.yaml"}, expectedErrors: true, skipAutoGenerationConfig: &SkipAutoGenerationConfig{ Title: false, Description: false, Required: false, Default: false, AdditionalProperties: false, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create temporary directory tmpDir, err := os.MkdirTemp("", "worker-test-*") assert.NoError(t, err) defer os.RemoveAll(tmpDir) // Create test files for filename, content := range tt.setupFiles { path := filepath.Join(tmpDir, filename) err := os.WriteFile(path, []byte(content), 0644) assert.NoError(t, err) } // Setup channels queue := make(chan string, 1) results := make(chan Result, 1) // Update chart path to use temp directory fullChartPath := filepath.Join(tmpDir, tt.chartPath) queue <- fullChartPath close(queue) // Run worker Worker( tt.dryRun, tt.uncomment, tt.addSchemaReference, tt.keepFullComment, tt.helmDocsCompatibilityMode, tt.dontRemoveHelmDocsPrefix, tt.dontAddGlobal, false, // annotate tt.valueFileNames, tt.skipAutoGenerationConfig, tt.outFile, queue, results, ) // Get result result := <-results if tt.expectedErrors { assert.NotEmpty(t, result.Errors) } else { assert.Empty(t, result.Errors) assert.NotNil(t, result.Chart) assert.NotEmpty(t, result.ValuesPath) assert.NotNil(t, result.Schema) } }) } } func TestWorker_DryRunDoesNotWriteSchemaReference(t *testing.T) { tmpDir := t.TempDir() chartPath := filepath.Join(tmpDir, "Chart.yaml") valuesPath := filepath.Join(tmpDir, "values.yaml") err := os.WriteFile(chartPath, []byte("apiVersion: v2\nname: test-chart\nversion: 1.0.0\n"), 0o644) assert.NoError(t, err) err = os.WriteFile(valuesPath, []byte("key: value\n"), 0o644) assert.NoError(t, err) queue := make(chan string, 1) results := make(chan Result, 1) queue <- chartPath close(queue) Worker( true, // dryRun false, // uncomment true, // addSchemaReference false, // keepFullComment false, // helmDocsCompatibilityMode false, // dontRemoveHelmDocsPrefix false, // dontAddGlobal false, // annotate []string{"values.yaml"}, &SkipAutoGenerationConfig{}, "values.schema.json", queue, results, ) result := <-results assert.Empty(t, result.Errors) updated, err := os.ReadFile(valuesPath) assert.NoError(t, err) assert.NotContains(t, string(updated), "yaml-language-server: $schema=values.schema.json") } func TestWorker_MergesMultipleValuesFilesWithRightmostPrecedence(t *testing.T) { tmpDir := t.TempDir() chartPath := filepath.Join(tmpDir, "Chart.yaml") baseValuesPath := filepath.Join(tmpDir, "values.base.yaml") overrideValuesPath := filepath.Join(tmpDir, "values.prod.yaml") err := os.WriteFile(chartPath, []byte("apiVersion: v2\nname: test-chart\nversion: 1.0.0\n"), 0o644) assert.NoError(t, err) err = os.WriteFile(baseValuesPath, []byte(` image: repository: nginx tag: stable # @schema # description: base replicas # @schema replicas: 1 `), 0o644) assert.NoError(t, err) err = os.WriteFile(overrideValuesPath, []byte(` image: tag: latest pullPolicy: Always # @schema # description: production replicas # @schema replicas: "two" `), 0o644) assert.NoError(t, err) queue := make(chan string, 1) results := make(chan Result, 1) queue <- chartPath close(queue) Worker( false, // dryRun false, // uncomment false, // addSchemaReference false, // keepFullComment false, // helmDocsCompatibilityMode false, // dontRemoveHelmDocsPrefix false, // dontAddGlobal false, // annotate []string{"values.base.yaml", "values.prod.yaml"}, &SkipAutoGenerationConfig{}, "values.schema.json", queue, results, ) result := <-results assert.Empty(t, result.Errors) replicasSchema := result.Schema.Properties["replicas"] if assert.NotNil(t, replicasSchema) { assert.Equal(t, "production replicas", replicasSchema.Description) assert.Equal(t, "two", replicasSchema.Default) } imageSchema := result.Schema.Properties["image"] if assert.NotNil(t, imageSchema) { repositorySchema := imageSchema.Properties["repository"] if assert.NotNil(t, repositorySchema) { assert.Equal(t, "nginx", repositorySchema.Default) } tagSchema := imageSchema.Properties["tag"] if assert.NotNil(t, tagSchema) { assert.Equal(t, "latest", tagSchema.Default) } pullPolicySchema := imageSchema.Properties["pullPolicy"] if assert.NotNil(t, pullPolicySchema) { assert.Equal(t, "Always", pullPolicySchema.Default) } } } ================================================ FILE: pkg/util/file.go ================================================ package util import ( "bufio" "errors" "fmt" "io" "os" "path/filepath" "regexp" "strings" "gopkg.in/yaml.v3" ) // ReadFileAndFixNewline reads the content of a io.Reader and replaces \r\n with \n func ReadFileAndFixNewline(reader io.Reader) ([]byte, error) { content, err := io.ReadAll(reader) if err != nil { return nil, err } return []byte(strings.ReplaceAll(string(content), "\r\n", "\n")), nil } func appendAndNL(to, from *[]byte) { if from != nil { *to = append(*to, *from...) } *to = append(*to, '\n') } func appendAndNLStr(to *[]byte, from string) { *to = append(*to, from...) *to = append(*to, '\n') } // PrefixFirstYamlDocument inserts a line to the beginning of the first YAML document in a file having content func PrefixFirstYamlDocument(line, file string) error { fileInfo, err := os.Stat(file) if err != nil { return err } perm := fileInfo.Mode().Perm() content, err := os.ReadFile(file) if err != nil { return err } eol := "\n" if len(content) >= 2 && content[len(content)-2] == '\r' && content[len(content)-1] == '\n' { eol = "\r\n" } // put line directly below YAML document_start if it exists and nothing is preceding it documentStart := "---" + eol if strings.HasPrefix(string(content), documentStart) { content = content[len(documentStart):] line = documentStart + line } newContent := line + eol + string(content) return os.WriteFile(file, []byte(newContent), perm) } // RemoveCommentsFromYaml tries to remove comments if they contain valid yaml func RemoveCommentsFromYaml(reader io.Reader) ([]byte, error) { result := make([]byte, 0) buff := make([]byte, 0) scanner := bufio.NewScanner(reader) commentMatcher := regexp.MustCompile(`^\s*#\s*`) // Capture indentation and comment marker separately // Group 1: indentation (spaces/tabs before #) // Group 2: comment marker (# and following space) commentYamlMapMatcher := regexp.MustCompile(`^(\s*)(#\s*)([^:]+:.*)$`) schemaMatcher := regexp.MustCompile(`^\s*#\s@schema\s*`) var line string var inCode, inSchema bool var unknownYaml interface{} for scanner.Scan() { line = scanner.Text() // If the line is empty and we are parsing a block of potential yaml, // the parsed block of yaml is "finished" and should be added to the // result if line == "" && inCode { appendAndNL(&result, &buff) appendAndNLStr(&result, line) // reset inCode = false buff = make([]byte, 0) continue } // Line contains @schema // The following lines will be added to result if schemaMatcher.Match([]byte(line)) { inSchema = !inSchema appendAndNLStr(&result, line) continue } // Inside a @schema block if inSchema { appendAndNLStr(&result, line) continue } var indentation string var commentMarkerLen int // Havent found a potential yaml block yet if !inCode { if matches := commentYamlMapMatcher.FindStringSubmatch(line); matches != nil { indentation = matches[1] // Just the leading whitespace commentMarkerLen = len(matches[2]) // Just "# " (typically 2 chars) inCode = true } } // Try if this line is valid yaml if inCode { if commentMatcher.Match([]byte(line)) { // Strip the commet away strippedLine := indentation + line[len(indentation)+commentMarkerLen:] // add it to the already parsed valid yaml appendAndNLStr(&buff, strippedLine) // check if the new block is still valid yaml err := yaml.Unmarshal(buff, &unknownYaml) if err != nil { // Invalid yaml found, // Remove the stripped line again buff = buff[:len(buff)-len(strippedLine)-1] // and add the commented line instead appendAndNLStr(&buff, line) } // its still valid yaml continue } // FIX: Line is NOT a comment - we've exited the commented block // Flush the buffer to result and reset state if len(buff) > 0 { appendAndNL(&result, &buff) buff = make([]byte, 0) } inCode = false // If the line is not a comment it must be yaml appendAndNLStr(&buff, line) continue } // line is valid yaml appendAndNLStr(&result, line) } if len(buff) > 0 { appendAndNL(&result, &buff) } return result, nil } // IsRelativeFile checks if the given string is a relative path to a file func IsRelativeFile(root, relPath string) (string, error) { if relPath == "" { return "", errors.New("path is empty") } if !filepath.IsAbs(relPath) { resolvedPath := filepath.Join(filepath.Dir(root), relPath) fileInfo, err := os.Stat(resolvedPath) if err != nil { return resolvedPath, err } if fileInfo.IsDir() { return resolvedPath, fmt.Errorf("path is a directory: %s", resolvedPath) } return resolvedPath, nil } return "", errors.New("path is absolute") } ================================================ FILE: pkg/util/file_test.go ================================================ package util import ( "bytes" "os" "path/filepath" "testing" ) func TestReadFileAndFixNewline(t *testing.T) { tests := []struct { input string output []byte }{ { input: "foo", output: []byte("foo"), }, { input: "foo\r\nbar", output: []byte("foo\nbar"), }, { input: "foo\r\n\r\nbar", output: []byte("foo\n\nbar"), }, } for _, test := range tests { data := []byte(test.input) reader := bytes.NewReader(data) content, err := ReadFileAndFixNewline(reader) if err != nil { t.Errorf("Wasn't expecting an error, but got this: %v", err) } if !bytes.Equal(content, test.output) { t.Errorf("Was expecting %s, but got %s", test.output, content) } } } func TestIsRelativeFile(t *testing.T) { tempDir := t.TempDir() rootFile := filepath.Join(tempDir, "values.yaml") relativeFile := filepath.Join(tempDir, "ref.json") relativeDir := filepath.Join(tempDir, "schemas") absFile := filepath.Join(tempDir, "absolute.json") if err := os.WriteFile(rootFile, []byte("root"), 0o644); err != nil { t.Fatalf("failed to create root file: %v", err) } if err := os.WriteFile(relativeFile, []byte("{}"), 0o644); err != nil { t.Fatalf("failed to create relative file: %v", err) } if err := os.Mkdir(relativeDir, 0o755); err != nil { t.Fatalf("failed to create relative dir: %v", err) } if err := os.WriteFile(absFile, []byte("{}"), 0o644); err != nil { t.Fatalf("failed to create absolute file: %v", err) } tests := []struct { name string root string relPath string expectedPath string wantErr bool }{ { name: "existing relative file", root: rootFile, relPath: "ref.json", expectedPath: relativeFile, }, { name: "empty path", root: rootFile, relPath: "", wantErr: true, }, { name: "relative directory", root: rootFile, relPath: "schemas", expectedPath: relativeDir, wantErr: true, }, { name: "absolute path", root: rootFile, relPath: absFile, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resolvedPath, err := IsRelativeFile(tt.root, tt.relPath) if tt.wantErr { if err == nil { t.Fatalf("expected an error") } } else if err != nil { t.Fatalf("unexpected error: %v", err) } if resolvedPath != tt.expectedPath { t.Fatalf("expected resolved path %q, got %q", tt.expectedPath, resolvedPath) } }) } } ================================================ FILE: plugin.yaml ================================================ --- name: "schema" version: "0.23.2" usage: "generate jsonschemas for your helm charts" description: "generate jsonschemas for your helm charts" command: "$HELM_PLUGIN_DIR/bin/helm-schema" hooks: install: "$HELM_PLUGIN_DIR/install-binary.sh" update: "$HELM_PLUGIN_DIR/install-binary.sh -u" ================================================ FILE: renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:best-practices", "schedule:earlyMondays"], "bumpVersion": "patch", "prHourlyLimit": 4 } ================================================ FILE: sign-plugin.sh ================================================ #!/bin/bash # Script to sign Helm plugin tarballs for Helm v4 verification # This creates .prov (provenance) files using GPG signing set -euo pipefail PLUGIN_NAME="helm-schema" VERSION="${1:-}" TARBALL="${2:-}" GPG_KEY="${GPG_SIGNING_KEY:-}" KEYRING="${GPG_KEYRING:-$HOME/.gnupg/pubring.gpg}" usage() { cat < [gpg-key] Signs a Helm plugin tarball with GPG to create a provenance file (.prov) for Helm v4 plugin verification. Arguments: version Plugin version (e.g., 1.0.0) tarball Path to the plugin tarball to sign gpg-key GPG key name or email (optional, uses GPG_SIGNING_KEY env var) Environment Variables: GPG_SIGNING_KEY GPG key to use for signing GPG_KEYRING Path to GPG keyring (default: ~/.gnupg/pubring.gpg) GPG_PASSPHRASE GPG key passphrase (if needed) Example: $0 1.0.0 dist/helm-schema_1.0.0_Linux_x86_64.tar.gz "John Doe " EOF exit 1 } if [ -z "$VERSION" ] || [ -z "$TARBALL" ]; then usage fi if [ ! -f "$TARBALL" ]; then echo "Error: Tarball not found: $TARBALL" exit 1 fi # If GPG key not provided as argument, try environment variable if [ $# -ge 3 ]; then GPG_KEY="$3" fi if [ -z "$GPG_KEY" ]; then echo "Error: GPG signing key not specified" echo "Provide it as third argument or set GPG_SIGNING_KEY environment variable" exit 1 fi echo "Signing plugin tarball with GPG..." echo " Tarball: $TARBALL" echo " Version: $VERSION" echo " GPG Key: $GPG_KEY" echo " Keyring: $KEYRING" # Export keys to legacy format if needed (for GnuPG v2) if ! [ -f "$KEYRING" ]; then echo "Exporting GPG keys to legacy format..." mkdir -p "$(dirname "$KEYRING")" gpg --export > "$KEYRING" 2>/dev/null || true fi # Create a temporary directory for signing TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT # Save the original directory and convert tarball path to absolute ORIG_DIR="$(pwd)" TARBALL_DIR="$(cd "$(dirname "$TARBALL")" && pwd)" TARBALL_NAME=$(basename "$TARBALL") # Copy tarball to temp directory cp "$TARBALL" "$TEMP_DIR/" cd "$TEMP_DIR" # Create the provenance file # The provenance file contains: # 1. The plugin metadata (from plugin.yaml) # 2. SHA256 hash of the tarball # 3. GPG signature of the above echo "Creating provenance file..." # Extract plugin.yaml from tarball to include in provenance tar -xzf "$TARBALL_NAME" plugin.yaml 2>/dev/null || tar -xzf "$TARBALL_NAME" */plugin.yaml 2>/dev/null || true # Calculate SHA256 hash HASH=$(sha256sum "$TARBALL_NAME" | awk '{print $1}') # Create provenance content cat > "${TARBALL_NAME}.prov.tmp" <> "${TARBALL_NAME}.prov.tmp" echo "plugin.yaml: |" >> "${TARBALL_NAME}.prov.tmp" sed 's/^/ /' plugin.yaml >> "${TARBALL_NAME}.prov.tmp" fi # Sign the provenance file if [ -n "${GPG_PASSPHRASE:-}" ]; then # Use passphrase from environment if available echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \ --clearsign \ --local-user "$GPG_KEY" \ --output "${TARBALL_NAME}.prov" \ "${TARBALL_NAME}.prov.tmp" else # Interactive passphrase prompt gpg --clearsign \ --local-user "$GPG_KEY" \ --output "${TARBALL_NAME}.prov" \ "${TARBALL_NAME}.prov.tmp" fi # Copy back to original location cp "${TARBALL_NAME}.prov" "$TARBALL_DIR/" echo "✓ Successfully created provenance file: ${TARBALL_DIR}/${TARBALL_NAME}.prov" echo "" echo "To verify the signature:" echo " helm plugin verify $(basename "$TARBALL")" echo "" echo "To install with verification:" echo " helm plugin install $(basename "$TARBALL") --verify" ================================================ FILE: signing-key.asc ================================================ -----BEGIN PGP PUBLIC KEY BLOCK----- mDMEaRt56xYJKwYBBAHaRw8BAQdA4RAN5LipEazZbSMV+BLcJh1BHY39WxIxS8tm alFiioi0HGRhZGF2IDxkYWRhdkBwcm90b25tYWlsLmNvbT6IlgQTFgoAPhYhBIBv cNJWZ9Qqrk4HzvWHB5adD7+lBQJpG3nrAhsDBQkFo5qABQsJCAcCBhUKCQgLAgQW AgMBAh4BAheAAAoJEPWHB5adD7+lWmQA/jgCXu/usDsdt0bOeU17FQxb74mkOY/B y8DSFgJrj5RSAQDeh+6JMcK+np0+9S0TRNwG5TfmdvjTlH4y7pRQNDsFCrg4BGkb eesSCisGAQQBl1UBBQEBB0D3VDcQTHbSBW1PcSbxsYukJhmNQ+fuxIje/FYk659j VgMBCAeIfgQYFgoAJhYhBIBvcNJWZ9Qqrk4HzvWHB5adD7+lBQJpG3nrAhsMBQkF o5qAAAoJEPWHB5adD7+lLzMA/0WgksOYuwA4gcCCxzZadfZuwSV9UXOTZu49dSgO l5ozAP9CQnS6vLaNC5nXbct6f2UjXqNcEF0i8Yp1PYlWErcsAw== =QRXR -----END PGP PUBLIC KEY BLOCK----- ================================================ FILE: tests/.gitignore ================================================ *_generated.schema.json helm-schema test_repo_example.yaml test_repo_example_expected.schema.json ================================================ FILE: tests/charts/Chart.yaml ================================================ apiVersion: v2 name: test description: Test chart for helm-schema type: application version: 0.1.0 ================================================ FILE: tests/charts/ref_input.json ================================================ { "foo": { "type": "string", "description": "from ref" }, "bar": { "type": "object", "description": "from different ref", "properties": { "baz": { "type": "string", "description": "from ref" } }, "required": [ "baz" ] } } ================================================ FILE: tests/charts/test_annotate_expected.yaml ================================================ # @schema # type: integer # @schema replicaCount: 1 # @schema # type: object # @schema image: # @schema # type: string # @schema repository: nginx # @schema # type: string # @schema pullPolicy: IfNotPresent # @schema # type: string # @schema # Overrides the image tag whose default is the chart appVersion. tag: "" # @schema # type: array # @schema imagePullSecrets: [] # @schema # type: string # @schema nameOverride: "" # @schema # type: boolean # @schema alreadyAnnotated: true # @schema # type: object # @schema service: # @schema # type: string # @schema type: ClusterIP # @schema # type: integer # @schema port: 80 # @schema # type: boolean # @schema enabled: false # @schema # type: object # @schema config: {} # @schema # type: "null" # @schema key: ================================================ FILE: tests/charts/test_annotate_input.yaml ================================================ replicaCount: 1 image: repository: nginx pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: "" imagePullSecrets: [] nameOverride: "" # @schema # type: boolean # @schema alreadyAnnotated: true service: type: ClusterIP port: 80 enabled: false config: {} key: ================================================ FILE: tests/charts/test_helm_defaults.yaml ================================================ # Default values for foo. # This is a YAML-formatted file. # Declare variables to be passed into your templates. replicaCount: 1 image: repository: nginx pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: "" imagePullSecrets: [] nameOverride: "" fullnameOverride: "" serviceAccount: # Specifies whether a service account should be created create: true # Automatically mount a ServiceAccount's API credentials? automount: true # Annotations to add to the service account annotations: {} # The name of the service account to use. # If not set and create is true, a name is generated using the fullname template name: "" podAnnotations: {} podLabels: {} podSecurityContext: {} # fsGroup: 2000 securityContext: {} # capabilities: # drop: # - ALL # readOnlyRootFilesystem: true # runAsNonRoot: true # runAsUser: 1000 service: type: ClusterIP port: 80 ingress: enabled: false className: "" annotations: {} # kubernetes.io/ingress.class: nginx # kubernetes.io/tls-acme: "true" hosts: - host: chart-example.local paths: - path: / pathType: ImplementationSpecific tls: [] # - secretName: chart-example-tls # hosts: # - chart-example.local resources: {} # We usually recommend not to specify default resources and to leave this as a conscious # choice for the user. This also increases chances charts run on environments with little # resources, such as Minikube. If you do want to specify resources, uncomment the following # lines, adjust them as necessary, and remove the curly braces after 'resources:'. # limits: # cpu: 100m # memory: 128Mi # requests: # cpu: 100m # memory: 128Mi livenessProbe: httpGet: path: / port: http readinessProbe: httpGet: path: / port: http autoscaling: enabled: false minReplicas: 1 maxReplicas: 100 targetCPUUtilizationPercentage: 80 # targetMemoryUtilizationPercentage: 80 # Additional volumes on the output Deployment definition. volumes: [] # - name: foo # secret: # secretName: mysecret # optional: false # Additional volumeMounts on the output Deployment definition. volumeMounts: [] # - name: foo # mountPath: "/etc/foo" # readOnly: true nodeSelector: {} tolerations: [] affinity: {} ================================================ FILE: tests/charts/test_helm_defaults_expected.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "affinity": { "additionalProperties": false, "required": [], "title": "affinity", "type": "object" }, "autoscaling": { "additionalProperties": false, "properties": { "enabled": { "default": false, "title": "enabled", "type": "boolean" }, "maxReplicas": { "default": 100, "title": "maxReplicas", "type": "integer" }, "minReplicas": { "default": 1, "title": "minReplicas", "type": "integer" }, "targetCPUUtilizationPercentage": { "default": 80, "title": "targetCPUUtilizationPercentage", "type": "integer" } }, "required": [ "enabled", "minReplicas", "maxReplicas", "targetCPUUtilizationPercentage" ], "title": "autoscaling", "type": "object" }, "fullnameOverride": { "default": "", "title": "fullnameOverride", "type": "string" }, "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" }, "image": { "additionalProperties": false, "properties": { "pullPolicy": { "default": "IfNotPresent", "title": "pullPolicy", "type": "string" }, "repository": { "default": "nginx", "title": "repository", "type": "string" }, "tag": { "default": "", "description": "Overrides the image tag whose default is the chart appVersion.", "title": "tag", "type": "string" } }, "required": [ "repository", "pullPolicy", "tag" ], "title": "image", "type": "object" }, "imagePullSecrets": { "items": { "required": [] }, "title": "imagePullSecrets", "type": "array" }, "ingress": { "additionalProperties": false, "properties": { "annotations": { "additionalProperties": false, "required": [], "title": "annotations", "type": "object" }, "className": { "default": "", "title": "className", "type": "string" }, "enabled": { "default": false, "title": "enabled", "type": "boolean" }, "hosts": { "description": "kubernetes.io/ingress.class: nginx\nkubernetes.io/tls-acme: \"true\"", "items": { "anyOf": [ { "additionalProperties": false, "properties": { "host": { "default": "chart-example.local", "title": "host", "type": "string" }, "paths": { "items": { "anyOf": [ { "additionalProperties": false, "properties": { "path": { "default": "/", "title": "path", "type": "string" }, "pathType": { "default": "ImplementationSpecific", "title": "pathType", "type": "string" } }, "required": [ "path", "pathType" ], "type": "object" } ], "required": [] }, "title": "paths", "type": "array" } }, "required": [ "host", "paths" ], "type": "object" } ], "required": [] }, "title": "hosts", "type": "array" }, "tls": { "items": { "required": [] }, "title": "tls", "type": "array" } }, "required": [ "enabled", "className", "annotations", "hosts", "tls" ], "title": "ingress", "type": "object" }, "livenessProbe": { "additionalProperties": false, "properties": { "httpGet": { "additionalProperties": false, "properties": { "path": { "default": "/", "title": "path", "type": "string" }, "port": { "default": "http", "title": "port", "type": "string" } }, "required": [ "path", "port" ], "title": "httpGet", "type": "object" } }, "required": [ "httpGet" ], "title": "livenessProbe", "type": "object" }, "nameOverride": { "default": "", "title": "nameOverride", "type": "string" }, "nodeSelector": { "additionalProperties": false, "required": [], "title": "nodeSelector", "type": "object" }, "podAnnotations": { "additionalProperties": false, "required": [], "title": "podAnnotations", "type": "object" }, "podLabels": { "additionalProperties": false, "required": [], "title": "podLabels", "type": "object" }, "podSecurityContext": { "additionalProperties": false, "required": [], "title": "podSecurityContext", "type": "object" }, "readinessProbe": { "additionalProperties": false, "properties": { "httpGet": { "additionalProperties": false, "properties": { "path": { "default": "/", "title": "path", "type": "string" }, "port": { "default": "http", "title": "port", "type": "string" } }, "required": [ "path", "port" ], "title": "httpGet", "type": "object" } }, "required": [ "httpGet" ], "title": "readinessProbe", "type": "object" }, "replicaCount": { "default": 1, "title": "replicaCount", "type": "integer" }, "resources": { "additionalProperties": false, "required": [], "title": "resources", "type": "object" }, "securityContext": { "additionalProperties": false, "required": [], "title": "securityContext", "type": "object" }, "service": { "additionalProperties": false, "properties": { "port": { "default": 80, "title": "port", "type": "integer" }, "type": { "default": "ClusterIP", "title": "type", "type": "string" } }, "required": [ "type", "port" ], "title": "service", "type": "object" }, "serviceAccount": { "additionalProperties": false, "properties": { "annotations": { "additionalProperties": false, "description": "Annotations to add to the service account", "required": [], "title": "annotations", "type": "object" }, "automount": { "default": true, "description": "Automatically mount a ServiceAccount's API credentials?", "title": "automount", "type": "boolean" }, "create": { "default": true, "description": "Specifies whether a service account should be created", "title": "create", "type": "boolean" }, "name": { "default": "", "description": "The name of the service account to use.\nIf not set and create is true, a name is generated using the fullname template", "title": "name", "type": "string" } }, "required": [ "create", "automount", "annotations", "name" ], "title": "serviceAccount", "type": "object" }, "tolerations": { "items": { "required": [] }, "title": "tolerations", "type": "array" }, "volumeMounts": { "description": "Additional volumeMounts on the output Deployment definition.", "items": { "required": [] }, "title": "volumeMounts", "type": "array" }, "volumes": { "description": "Additional volumes on the output Deployment definition.", "items": { "required": [] }, "title": "volumes", "type": "array" } }, "required": [ "replicaCount", "image", "imagePullSecrets", "nameOverride", "fullnameOverride", "serviceAccount", "podAnnotations", "podLabels", "podSecurityContext", "securityContext", "service", "ingress", "resources", "livenessProbe", "readinessProbe", "autoscaling", "volumes", "volumeMounts", "nodeSelector", "tolerations", "affinity" ], "type": "object" } ================================================ FILE: tests/charts/test_ref.yaml ================================================ --- # @schema # $ref: ref_input.json#/foo # @schema # Not from ref foo: bar # @schema # $ref: ref_input.json#/bar # @schema # Not from ref bar: baz: qux ================================================ FILE: tests/charts/test_ref_expected.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "bar": { "additionalProperties": false, "description": "from different ref", "properties": { "baz": { "description": "from ref", "type": "string" } }, "required": [ "baz" ], "title": "bar", "type": "object" }, "foo": { "default": "bar", "description": "from ref", "title": "foo", "type": "string" }, "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" } }, "required": [], "type": "object" } ================================================ FILE: tests/charts/test_ref_properties.yaml ================================================ # @schema # definitions: # port: # type: integer # minimum: 1 # maximum: 65535 # properties: # httpPort: # $ref: "#/definitions/port" # httpsPort: # $ref: "#/definitions/port" # @schema working: httpPort: 80 httpsPort: 443 ================================================ FILE: tests/charts/test_ref_properties_expected.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { "port": { "maximum": 65535, "minimum": 1, "type": "integer" } }, "properties": { "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" }, "working": { "additionalProperties": false, "properties": { "httpPort": { "$ref": "#/definitions/port", "required": [] }, "httpsPort": { "$ref": "#/definitions/port", "required": [] } }, "required": [], "title": "working" } }, "required": [], "type": "object" } ================================================ FILE: tests/charts/test_ref_toplevel.yaml ================================================ # @schema # definitions: # toplevel: # description: "Top Level" # $ref: "#/definitions/toplevel" # @schema toplevel: ================================================ FILE: tests/charts/test_ref_toplevel_expected.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { "toplevel": { "description": "Top Level", "required": [] } }, "properties": { "toplevel": { "$ref": "#/definitions/toplevel", "required": [] }, "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" } }, "required": [], "type": "object" } ================================================ FILE: tests/charts/test_simple.yaml ================================================ --- # @schema # description: foo # @schema foo: bar ================================================ FILE: tests/charts/test_simple_expected.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "foo": { "default": "bar", "description": "foo", "required": [], "title": "foo" }, "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" } }, "required": [], "type": "object" } ================================================ FILE: tests/import-values/child/Chart.yaml ================================================ apiVersion: v2 name: child version: 1.0.0 description: A child chart with exports ================================================ FILE: tests/import-values/child/values.yaml ================================================ # @schema # type: object # @schema exports: # @schema # type: object # @schema defaults: # @schema # type: string # description: The database host # @schema dbHost: localhost # @schema # type: integer # description: The database port # @schema dbPort: 5432 # @schema # type: object # description: Extra configuration options # additionalProperties: true # @schema extraConfig: {} # @schema # type: string # description: Internal config not exported # @schema internalConfig: secret ================================================ FILE: tests/import-values/child-complex/Chart.yaml ================================================ apiVersion: v2 name: child-complex version: 1.0.0 description: A child chart with nested data for complex import-values ================================================ FILE: tests/import-values/child-complex/values.yaml ================================================ # @schema # type: object # @schema data: # @schema # type: object # @schema database: # @schema # type: string # description: Database connection string # @schema connectionString: "postgres://localhost:5432/db" # @schema # type: integer # description: Max connections # @schema maxConnections: 100 ================================================ FILE: tests/import-values/parent/Chart.yaml ================================================ apiVersion: v2 name: parent version: 1.0.0 description: A parent chart using import-values dependencies: - name: child version: 1.0.0 repository: file://../child import-values: - defaults ================================================ FILE: tests/import-values/parent/values.schema.expected.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "appName": { "default": "myapp", "description": "Application name", "title": "appName", "type": "string" }, "dbHost": { "default": "localhost", "description": "The database host", "title": "dbHost", "type": "string" }, "dbPort": { "default": 5432, "description": "The database port", "title": "dbPort", "type": "integer" }, "extraConfig": { "additionalProperties": true, "description": "Extra configuration options", "required": [], "title": "extraConfig", "type": "object" }, "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" } }, "required": [ "extraConfig" ], "type": "object" } ================================================ FILE: tests/import-values/parent/values.yaml ================================================ # @schema # type: string # description: Application name # @schema appName: myapp # No @schema annotation - inferred schema should be replaced by child's explicit annotation extraConfig: someDefault: value ================================================ FILE: tests/import-values/parent-complex/Chart.yaml ================================================ apiVersion: v2 name: parent-complex version: 1.0.0 description: A parent chart using complex import-values form dependencies: - name: child-complex version: 1.0.0 repository: file://../child-complex import-values: - child: data.database parent: db ================================================ FILE: tests/import-values/parent-complex/values.schema.expected.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "db": { "title": "db", "properties": { "connectionString": { "default": "postgres://localhost:5432/db", "description": "Database connection string", "title": "connectionString", "type": "string" }, "maxConnections": { "default": 100, "description": "Max connections", "title": "maxConnections", "type": "integer" } }, "required": [], "type": "object" }, "environment": { "default": "production", "description": "Application environment", "title": "environment", "type": "string" }, "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" } }, "required": [], "type": "object" } ================================================ FILE: tests/import-values/parent-complex/values.yaml ================================================ # @schema # type: string # description: Application environment # @schema environment: production ================================================ FILE: tests/preexisting-schema/dep-with-schema/Chart.yaml ================================================ apiVersion: v2 name: dep-with-schema version: 1.0.0 description: A dependency chart with its own schema ================================================ FILE: tests/preexisting-schema/dep-with-schema/values.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "port": { "type": "integer", "description": "The port to listen on", "minimum": 1, "maximum": 65535 }, "host": { "type": "string", "description": "The hostname to bind to", "format": "hostname", "x-custom-annotation": "preserve-me" } }, "required": [ "host", "port" ] } ================================================ FILE: tests/preexisting-schema/dep-with-schema/values.yaml ================================================ port: 8080 host: localhost ================================================ FILE: tests/preexisting-schema/parent/Chart.yaml ================================================ apiVersion: v2 name: parent version: 1.0.0 description: A parent chart depending on dep-with-schema dependencies: - name: dep-with-schema version: 1.0.0 repository: file://../dep-with-schema ================================================ FILE: tests/preexisting-schema/parent/values.schema.default-expected.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "appName": { "default": "myapp", "title": "appName", "type": "string" }, "dep-with-schema": { "description": "A dependency chart with its own schema", "properties": { "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" }, "host": { "default": "localhost", "title": "host", "type": "string" }, "port": { "default": 8080, "title": "port", "type": "integer" } }, "required": [], "title": "dep-with-schema", "type": "object" }, "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" } }, "required": [ "appName" ], "type": "object" } ================================================ FILE: tests/preexisting-schema/parent/values.schema.expected.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "appName": { "default": "myapp", "title": "appName", "type": "string" }, "dep-with-schema": { "description": "A dependency chart with its own schema", "properties": { "host": { "description": "The hostname to bind to", "format": "hostname", "type": "string", "x-custom-annotation": "preserve-me" }, "port": { "description": "The port to listen on", "maximum": 65535, "minimum": 1, "type": "integer" } }, "required": [], "title": "dep-with-schema", "type": "object" }, "global": { "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", "required": [], "title": "global", "type": "object" } }, "required": [ "appName" ], "type": "object" } ================================================ FILE: tests/preexisting-schema/parent/values.yaml ================================================ appName: myapp ================================================ FILE: tests/run.sh ================================================ #!/usr/bin/env bash rc=0 cd charts cp ../../examples/values.yaml test_repo_example.yaml cp ../../examples/values.schema.json test_repo_example_expected.schema.json for test_file in test_*.yaml; do # Skip annotate test files from normal schema generation tests case "$test_file" in test_annotate_*) continue ;; esac expected_file="${test_file%.yaml}_expected.schema.json" generated_file="${test_file%.yaml}_generated.schema.json" if ! ../helm-schema -f "$test_file" -o "$generated_file"; then echo "❌: $test_file" rc=1 continue fi echo "Testing $test_file" if diff -y --suppress-common-lines <(jq --sort-keys . "$generated_file") <(jq --sort-keys . "$expected_file"); then echo "✅: $test_file" else echo "❌: $test_file" rc=1 fi done # Annotate test echo "Testing annotate mode" annotate_output=$(../helm-schema --annotate -d -f test_annotate_input.yaml 2>/dev/null) if diff -y --suppress-common-lines <(echo "$annotate_output") test_annotate_expected.yaml; then echo "✅: annotate mode" else echo "❌: annotate mode" rc=1 fi # Import-values tests (in separate directory to avoid interference with single-file tests) echo "Testing import-values (simple form)" ../helm-schema -c ../import-values >/dev/null 2>&1 if diff -y --suppress-common-lines <(jq --sort-keys . ../import-values/parent/values.schema.json) <(jq --sort-keys . ../import-values/parent/values.schema.expected.json); then echo "✅: import-values (simple form)" else echo "❌: import-values (simple form)" rc=1 fi echo "Testing import-values (complex form)" if diff -y --suppress-common-lines <(jq --sort-keys . ../import-values/parent-complex/values.schema.json) <(jq --sort-keys . ../import-values/parent-complex/values.schema.expected.json); then echo "✅: import-values (complex form)" else echo "❌: import-values (complex form)" rc=1 fi rm -f ../import-values/parent/values.schema.json ../import-values/child/values.schema.json rm -f ../import-values/parent-complex/values.schema.json ../import-values/child-complex/values.schema.json # Pre-existing schema test (opt-in via --keep-existing-dep-schemas) echo "Testing pre-existing dependency schema (opt-in)" dep_schema_backup=$(mktemp) cp ../preexisting-schema/dep-with-schema/values.schema.json "$dep_schema_backup" dep_schema_before=$(cat "$dep_schema_backup") ../helm-schema -c ../preexisting-schema --keep-existing-dep-schemas >/dev/null 2>&1 if diff -y --suppress-common-lines <(jq --sort-keys . ../preexisting-schema/parent/values.schema.json) <(jq --sort-keys . ../preexisting-schema/parent/values.schema.expected.json); then echo "✅: pre-existing dependency schema (opt-in)" else echo "❌: pre-existing dependency schema (opt-in)" rc=1 fi dep_schema_after=$(cat ../preexisting-schema/dep-with-schema/values.schema.json) if [ "$dep_schema_before" = "$dep_schema_after" ]; then echo "✅: dependency schema not overwritten (opt-in)" else echo "❌: dependency schema was overwritten (opt-in)" rc=1 fi rm -f ../preexisting-schema/parent/values.schema.json # Default behavior: dependency schemas are regenerated (issue #215 regression guard) echo "Testing dependency schema regeneration (default)" ../helm-schema -c ../preexisting-schema >/dev/null 2>&1 if diff -y --suppress-common-lines <(jq --sort-keys . ../preexisting-schema/parent/values.schema.json) <(jq --sort-keys . ../preexisting-schema/parent/values.schema.default-expected.json); then echo "✅: dependency schema regeneration (parent)" else echo "❌: dependency schema regeneration (parent)" rc=1 fi dep_schema_after_default=$(cat ../preexisting-schema/dep-with-schema/values.schema.json) if [ "$dep_schema_before" != "$dep_schema_after_default" ]; then echo "✅: dependency schema regenerated (default)" else echo "❌: dependency schema was NOT regenerated (default)" rc=1 fi # Restore the original pre-existing dep schema so the fixture is stable across runs cp "$dep_schema_backup" ../preexisting-schema/dep-with-schema/values.schema.json rm -f "$dep_schema_backup" rm -f ../preexisting-schema/parent/values.schema.json exit "$rc" ================================================ FILE: tests/test-sign-plugin.sh ================================================ #!/bin/bash # Test script for sign-plugin.sh # Creates an isolated GPG environment, generates a test key, and validates signing set -euo pipefail TEST_DIR=$(mktemp -d) trap 'rm -rf "$TEST_DIR"' EXIT echo "=== Setting up isolated test environment in $TEST_DIR ===" # Create isolated GPG home export GNUPGHOME="$TEST_DIR/gnupg" mkdir -p "$GNUPGHOME" chmod 700 "$GNUPGHOME" # Configure GPG for non-interactive use cat > "$GNUPGHOME/gpg.conf" < "$GNUPGHOME/gpg-agent.conf" </dev/null || true echo "=== Generating test Ed25519 key ===" # Generate an Ed25519 key (the type that was causing issues) gpg --batch --yes --passphrase "testpass" --quick-gen-key "Test User " ed25519 sign 0 # Get the key fingerprint KEY_FPR=$(gpg --list-keys --with-colons | grep fpr | head -1 | cut -d: -f10) echo "Generated key: $KEY_FPR" # Export to legacy format gpg --export > "$GNUPGHOME/pubring.gpg" echo "=== Creating mock plugin tarball ===" PLUGIN_DIR="$TEST_DIR/plugin" mkdir -p "$PLUGIN_DIR/bin" cat > "$PLUGIN_DIR/plugin.yaml" < "$PLUGIN_DIR/bin/test-plugin" <<'EOF' #!/bin/bash echo "Hello from test plugin" EOF chmod +x "$PLUGIN_DIR/bin/test-plugin" # Create tarball TARBALL="$TEST_DIR/test-plugin_1.0.0_Linux_x86_64.tar.gz" tar -czf "$TARBALL" -C "$PLUGIN_DIR" . echo "=== Running sign-plugin.sh ===" export GPG_KEYRING="$GNUPGHOME/pubring.gpg" export GPG_PASSPHRASE="testpass" # Copy the sign-plugin.sh to test dir (assuming it's in current directory or provided) if [ -f "sign-plugin.sh" ]; then cp sign-plugin.sh "$TEST_DIR/" elif [ -f "$1" ]; then cp "$1" "$TEST_DIR/sign-plugin.sh" else echo "Error: sign-plugin.sh not found. Provide path as argument." exit 1 fi chmod +x "$TEST_DIR/sign-plugin.sh" # Run the signing script cd "$TEST_DIR" ./sign-plugin.sh "1.0.0" "$TARBALL" "test@example.com" echo "" echo "=== Checking generated .prov file ===" PROV_FILE="${TARBALL}.prov" if [ ! -f "$PROV_FILE" ]; then echo "FAIL: .prov file not created" exit 1 fi echo "Contents of .prov file:" echo "---" cat "$PROV_FILE" echo "---" echo "" echo "=== Verifying signature with GPG ===" if gpg --verify "$PROV_FILE" 2>&1; then echo "" echo "SUCCESS: GPG verification passed" else echo "" echo "FAIL: GPG verification failed" exit 1 fi echo "=== Verifying plugin with Helm ===" if helm plugin verify "$TARBALL" 2>&1; then echo "" echo "SUCCESS: Helm plugin verification passed" else echo "" echo "FAIL: Helm plugin verification failed" exit 1 fi echo "" echo "=== Checking signature packet details ===" sed -n '/-----BEGIN PGP SIGNATURE-----/,/-----END PGP SIGNATURE-----/p' "$PROV_FILE" | gpg --list-packets 2>&1 | grep -E "(algo|digest)" || true echo "" echo "=== All tests passed ==="