Showing preview only (321K chars total). Download the full file or copy to clipboard to get everything.
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 <tarball> --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
<p align="center">
<img src="images/logo.png" width="400" />
<br />
<a href="https://github.com/dadav/helm-schema/releases"><img src="https://img.shields.io/github/release/dadav/helm-schema.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/github.com/dadav/helm-schema?tab=doc"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="Go Docs"></a>
<a href="https://github.com/dadav/helm-schema/actions"><img src="https://img.shields.io/github/actions/workflow/status/dadav/helm-schema/build_and_test.yml" alt="Build Status"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-green.svg" alt="MIT LICENSE"></a>
<a href="https://github.com/pre-commit/pre-commit"><img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit" alt="pre-commit" style="max-width:100%;"></a>
<a href="https://goreportcard.com/badge/github.com/dadav/helm-schema"><img src="https://goreportcard.com/badge/github.com/dadav/helm-schema" alt="Go Report"></a>
</p>
<p align="center">This tool tries to help you to easily create some nice <a href="https://json-schema.org/" target="_blank"><strong>JSON schema</strong></a> for your helm chart.</p>
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
<!-- prettier-ignore -->
| 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=<path-or-url-to-your-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/<user>/<repo>/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 <chart-name>
# 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 <kbd>CTRL+SPACE</kbd>.
```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.<value> 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.Definition
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
SYMBOL INDEX (160 symbols across 20 files)
FILE: cmd/helm-schema/cli.go
function possibleLogLevels (line 13) | func possibleLogLevels() []string {
function configureLogging (line 23) | func configureLogging() {
function newCommand (line 35) | func newCommand(run func(cmd *cobra.Command, args []string) error) (*cob...
FILE: cmd/helm-schema/main.go
function getDependencyNames (line 24) | func getDependencyNames(dependencies []*chart.Dependency, dependenciesFi...
function mergeSchemaProperties (line 44) | func mergeSchemaProperties(
function processImportValues (line 91) | func processImportValues(
function exec (line 178) | func exec(cmd *cobra.Command, _ []string) error {
function main (line 597) | func main() {
FILE: cmd/helm-schema/main_test.go
type stringOrArray (line 13) | type stringOrArray
method UnmarshalJSON (line 15) | func (s *stringOrArray) UnmarshalJSON(value []byte) error {
type schemaDoc (line 35) | type schemaDoc struct
type schemaProperty (line 39) | type schemaProperty struct
function TestExec_ConditionPatchingAndRootConditions (line 44) | func TestExec_ConditionPatchingAndRootConditions(t *testing.T) {
function TestExec_DependencyFilterSkipsConditionPatching (line 158) | func TestExec_DependencyFilterSkipsConditionPatching(t *testing.T) {
function TestExec_DependencyAliasConditionPatching (line 257) | func TestExec_DependencyAliasConditionPatching(t *testing.T) {
function TestExec_KeepExistingDepSchemasPreservesDependencySchema (line 339) | func TestExec_KeepExistingDepSchemasPreservesDependencySchema(t *testing...
function TestExec_DefaultRegeneratesDependencySchema (line 436) | func TestExec_DefaultRegeneratesDependencySchema(t *testing.T) {
function TestExec_NoDependenciesSkipsDependencyCharts (line 512) | func TestExec_NoDependenciesSkipsDependencyCharts(t *testing.T) {
FILE: pkg/chart/chart.go
type Dependency (line 10) | type Dependency struct
type Maintainer (line 22) | type Maintainer struct
type ChartFile (line 32) | type ChartFile struct
function ReadChart (line 71) | func ReadChart(reader io.Reader) (ChartFile, error) {
FILE: pkg/chart/chart_test.go
function TestReadChartFile (line 10) | func TestReadChartFile(t *testing.T) {
function TestImportValuesParsing (line 36) | func TestImportValuesParsing(t *testing.T) {
function TestChartFileParsing (line 125) | func TestChartFileParsing(t *testing.T) {
FILE: pkg/chart/searching/dependency_charts.go
function extractTGZ (line 15) | func extractTGZ(src, dest string) error {
function SearchFiles (line 88) | func SearchFiles(chartSearchRoot, startPath, fileName string, dependenci...
function SearchArchivesOpenTemp (line 130) | func SearchArchivesOpenTemp(startPath string, errs chan<- error) string {
FILE: pkg/schema/annotate.go
function HasSchemaAnnotation (line 15) | func HasSchemaAnnotation(comment string) bool {
function typeAnnotationFromTag (line 27) | func typeAnnotationFromTag(tag string) string {
type InsertionPoint (line 51) | type InsertionPoint struct
function collectInsertionPoints (line 59) | func collectInsertionPoints(node *yaml.Node) []InsertionPoint {
function collectInsertionPointsRecursive (line 65) | func collectInsertionPointsRecursive(node *yaml.Node, points *[]Insertio...
function AnnotateContent (line 108) | func AnnotateContent(content []byte) ([]byte, error) {
function AnnotateValuesFile (line 165) | func AnnotateValuesFile(valuesPath string, dryRun bool) error {
FILE: pkg/schema/annotate_test.go
function TestHasSchemaAnnotation (line 10) | func TestHasSchemaAnnotation(t *testing.T) {
function TestTypeAnnotationFromTag (line 58) | func TestTypeAnnotationFromTag(t *testing.T) {
function TestCollectInsertionPoints (line 84) | func TestCollectInsertionPoints(t *testing.T) {
function TestAnnotateContent (line 154) | func TestAnnotateContent(t *testing.T) {
FILE: pkg/schema/err.go
type CircularError (line 3) | type CircularError struct
method Error (line 7) | func (e *CircularError) Error() string { return e.msg }
FILE: pkg/schema/err_test.go
function TestCircularError (line 5) | func TestCircularError(t *testing.T) {
FILE: pkg/schema/root_schema_test.go
function TestGetRootSchemaFromComment (line 10) | func TestGetRootSchemaFromComment(t *testing.T) {
function TestYamlToSchemaWithRootAnnotations (line 104) | func TestYamlToSchemaWithRootAnnotations(t *testing.T) {
function TestRootSchemaDoesNotAffectKeyAnnotations (line 224) | func TestRootSchemaDoesNotAffectKeyAnnotations(t *testing.T) {
FILE: pkg/schema/schema.go
constant SchemaPrefix (line 26) | SchemaPrefix = "# @schema"
constant SchemaRootPrefix (line 27) | SchemaRootPrefix = "# @schema.root"
constant CommentPrefix (line 28) | CommentPrefix = "#"
constant CustomAnnotationPrefix (line 33) | CustomAnnotationPrefix = "x-"
constant nullTag (line 38) | nullTag = "!!null"
constant boolTag (line 39) | boolTag = "!!bool"
constant strTag (line 40) | strTag = "!!str"
constant intTag (line 41) | intTag = "!!int"
constant floatTag (line 42) | floatTag = "!!float"
constant timestampTag (line 43) | timestampTag = "!!timestamp"
constant arrayTag (line 44) | arrayTag = "!!seq"
constant mapTag (line 45) | mapTag = "!!map"
type SchemaOrBool (line 56) | type SchemaOrBool interface
type BoolOrArrayOfString (line 60) | type BoolOrArrayOfString struct
method UnmarshalJSON (line 72) | func (s *BoolOrArrayOfString) UnmarshalJSON(value []byte) error {
method MarshalJSON (line 84) | func (s *BoolOrArrayOfString) MarshalJSON() ([]byte, error) {
method UnmarshalYAML (line 93) | func (s *BoolOrArrayOfString) UnmarshalYAML(value *yaml.Node) error {
function NewBoolOrArrayOfString (line 65) | func NewBoolOrArrayOfString(arr []string, b bool) BoolOrArrayOfString {
type StringOrArrayOfString (line 118) | type StringOrArrayOfString
method UnmarshalYAML (line 120) | func (s *StringOrArrayOfString) UnmarshalYAML(value *yaml.Node) error {
method UnmarshalJSON (line 147) | func (s *StringOrArrayOfString) UnmarshalJSON(value []byte) error {
method MarshalJSON (line 159) | func (s *StringOrArrayOfString) MarshalJSON() ([]byte, error) {
method Validate (line 166) | func (s *StringOrArrayOfString) Validate() error {
method IsEmpty (line 183) | func (s *StringOrArrayOfString) IsEmpty() bool {
method canDropRequired (line 192) | func (s *StringOrArrayOfString) canDropRequired() bool {
method Matches (line 200) | func (s *StringOrArrayOfString) Matches(typeString string) bool {
type Schema (line 250) | type Schema struct
method MarshalJSON (line 210) | func (s *Schema) MarshalJSON() ([]byte, error) {
method getJsonKeys (line 318) | func (s Schema) getJsonKeys() []string {
method UnmarshalYAML (line 333) | func (s *Schema) UnmarshalYAML(node *yaml.Node) error {
method UnmarshalJSON (line 384) | func (s *Schema) UnmarshalJSON(data []byte) error {
method HoistDefinitions (line 451) | func (s *Schema) HoistDefinitions() {
method collectAndHoistDefinitions (line 467) | func (s *Schema) collectAndHoistDefinitions(rootDefs map[string]*Schem...
method rewriteDefsRefs (line 612) | func (s *Schema) rewriteDefsRefs() {
method Set (line 685) | func (s *Schema) Set() {
method DisableRequiredProperties (line 695) | func (s *Schema) DisableRequiredProperties() {
method GetPropertyAtPath (line 772) | func (s *Schema) GetPropertyAtPath(path string) *Schema {
method SetPropertyAtPath (line 800) | func (s *Schema) SetPropertyAtPath(path string) *Schema {
method ToJson (line 829) | func (s Schema) ToJson() ([]byte, error) {
method Validate (line 871) | func (s Schema) Validate() error {
method validateSchemaSyntax (line 910) | func (s Schema) validateSchemaSyntax() error {
method validateTypeConstraints (line 924) | func (s Schema) validateTypeConstraints() error {
method validateNumericConstraints (line 928) | func (s Schema) validateNumericConstraints() error {
method validateStringConstraints (line 961) | func (s Schema) validateStringConstraints() error {
method validateArrayConstraints (line 1015) | func (s Schema) validateArrayConstraints() error {
method validateObjectConstraints (line 1095) | func (s Schema) validateObjectConstraints() error {
method validateNestedSchemas (line 1203) | func (s Schema) validateNestedSchemas() error {
method hasNumericConstraints (line 1243) | func (s Schema) hasNumericConstraints() bool {
method applyRootSchemaProperties (line 1417) | func (s *Schema) applyRootSchemaProperties(source *Schema, valuesPath ...
function NewSchema (line 304) | func NewSchema(schemaType string) *Schema {
constant FormatDateTime (line 839) | FormatDateTime = "date-time"
constant FormatTime (line 840) | FormatTime = "time"
constant FormatDate (line 841) | FormatDate = "date"
constant FormatDuration (line 842) | FormatDuration = "duration"
constant FormatEmail (line 843) | FormatEmail = "email"
constant FormatIDNEmail (line 844) | FormatIDNEmail = "idn-email"
constant FormatHostname (line 845) | FormatHostname = "hostname"
constant FormatIDNHostname (line 846) | FormatIDNHostname = "idn-hostname"
constant FormatIPv4 (line 847) | FormatIPv4 = "ipv4"
constant FormatIPv6 (line 848) | FormatIPv6 = "ipv6"
constant FormatUUID (line 849) | FormatUUID = "uuid"
constant FormatURI (line 850) | FormatURI = "uri"
constant FormatURIReference (line 851) | FormatURIReference = "uri-reference"
constant FormatIRI (line 852) | FormatIRI = "iri"
constant FormatIRIReference (line 853) | FormatIRIReference = "iri-reference"
constant FormatURITemplate (line 854) | FormatURITemplate = "uri-template"
constant FormatJSONPointer (line 855) | FormatJSONPointer = "json-pointer"
constant FormatRelJSONPointer (line 856) | FormatRelJSONPointer = "relative-json-pointer"
constant FormatRegex (line 857) | FormatRegex = "regex"
type SkipAutoGenerationConfig (line 1251) | type SkipAutoGenerationConfig struct
function NewSkipAutoGenerationConfig (line 1255) | func NewSkipAutoGenerationConfig(flag []string) (*SkipAutoGenerationConf...
function typeFromTag (line 1291) | func typeFromTag(tag string) ([]string, error) {
function FixRequiredProperties (line 1315) | func FixRequiredProperties(schema *Schema) error {
function GetRootSchemaFromComment (line 1491) | func GetRootSchemaFromComment(comment string) (Schema, string, error) {
function GetSchemaFromComment (line 1531) | func GetSchemaFromComment(comment string) (Schema, string, error) {
function YamlToSchema (line 1578) | func YamlToSchema(
function helmDocsTypeToSchemaType (line 1873) | func helmDocsTypeToSchemaType(helmDocsType string) (string, error) {
function castNodeValueByType (line 1904) | func castNodeValueByType(rawValue string, fieldType StringOrArrayOfStrin...
function decodeNodeValue (line 1937) | func decodeNodeValue(node *yaml.Node) (interface{}, error) {
function handleSchemaRefs (line 1959) | func handleSchemaRefs(schema *Schema, valuesPath string) error {
FILE: pkg/schema/schema_test.go
function TestValidate (line 14) | func TestValidate(t *testing.T) {
function TestUnmarshalYAML (line 217) | func TestUnmarshalYAML(t *testing.T) {
function TestUnmarshalJSON (line 232) | func TestUnmarshalJSON(t *testing.T) {
function TestNewDraft7Keywords (line 263) | func TestNewDraft7Keywords(t *testing.T) {
function TestFloatNumericConstraintsMarshaling (line 550) | func TestFloatNumericConstraintsMarshaling(t *testing.T) {
function TestNewKeywordsMarshaling (line 603) | func TestNewKeywordsMarshaling(t *testing.T) {
function TestDisableRequiredPropertiesWithNewFields (line 681) | func TestDisableRequiredPropertiesWithNewFields(t *testing.T) {
function TestDefsToDefinitionsConversion (line 717) | func TestDefsToDefinitionsConversion(t *testing.T) {
function TestRefPathRewriting (line 781) | func TestRefPathRewriting(t *testing.T) {
function TestConstNullMarshaling (line 839) | func TestConstNullMarshaling(t *testing.T) {
function TestYamlToSchemaConstFromValue (line 903) | func TestYamlToSchemaConstFromValue(t *testing.T) {
function TestYamlToSchemaPreservesDocumentLocalRootRef (line 997) | func TestYamlToSchemaPreservesDocumentLocalRootRef(t *testing.T) {
function TestGetPropertyAtPath (line 1043) | func TestGetPropertyAtPath(t *testing.T) {
function TestSetPropertyAtPath (line 1157) | func TestSetPropertyAtPath(t *testing.T) {
function TestHoistDefinitions (line 1233) | func TestHoistDefinitions(t *testing.T) {
FILE: pkg/schema/toposort.go
function TopoSort (line 9) | func TopoSort(results []*Result, allowCircular bool) ([]*Result, error) {
FILE: pkg/schema/toposort_test.go
function TestTopoSort (line 10) | func TestTopoSort(t *testing.T) {
FILE: pkg/schema/values_merge.go
function mergeValuesDocuments (line 11) | func mergeValuesDocuments(base *yaml.Node, overlay *yaml.Node) (*yaml.No...
function mergeValuesNodes (line 39) | func mergeValuesNodes(base *yaml.Node, overlay *yaml.Node) (*yaml.Node, ...
function mergeMappingNodes (line 65) | func mergeMappingNodes(base *yaml.Node, overlay *yaml.Node) (*yaml.Node,...
function cloneYAMLNode (line 116) | func cloneYAMLNode(node *yaml.Node) *yaml.Node {
function mergeCommentText (line 135) | func mergeCommentText(base string, overlay string) string {
FILE: pkg/schema/worker.go
type Result (line 15) | type Result struct
function Worker (line 24) | func Worker(
FILE: pkg/schema/worker_test.go
function TestWorker (line 11) | func TestWorker(t *testing.T) {
function TestWorker_DryRunDoesNotWriteSchemaReference (line 156) | func TestWorker_DryRunDoesNotWriteSchemaReference(t *testing.T) {
function TestWorker_MergesMultipleValuesFilesWithRightmostPrecedence (line 196) | func TestWorker_MergesMultipleValuesFilesWithRightmostPrecedence(t *test...
FILE: pkg/util/file.go
function ReadFileAndFixNewline (line 17) | func ReadFileAndFixNewline(reader io.Reader) ([]byte, error) {
function appendAndNL (line 25) | func appendAndNL(to, from *[]byte) {
function appendAndNLStr (line 32) | func appendAndNLStr(to *[]byte, from string) {
function PrefixFirstYamlDocument (line 38) | func PrefixFirstYamlDocument(line, file string) error {
function RemoveCommentsFromYaml (line 66) | func RemoveCommentsFromYaml(reader io.Reader) ([]byte, error) {
function IsRelativeFile (line 167) | func IsRelativeFile(root, relPath string) (string, error) {
FILE: pkg/util/file_test.go
function TestReadFileAndFixNewline (line 10) | func TestReadFileAndFixNewline(t *testing.T) {
function TestIsRelativeFile (line 41) | func TestIsRelativeFile(t *testing.T) {
Condensed preview — 77 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (342K chars).
[
{
"path": ".editorconfig",
"chars": 184,
"preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\n\n"
},
{
"path": ".github/workflows/build_and_test.yml",
"chars": 2038,
"preview": "---\nname: build and test\n\non: push\n\njobs:\n goreleaser:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n "
},
{
"path": ".github/workflows/release.yml",
"chars": 4561,
"preview": "---\nname: release\n\non:\n push:\n tags:\n - \"*\"\n\npermissions:\n packages: write\n contents: write\n\njobs:\n goreleas"
},
{
"path": ".gitignore",
"chars": 100,
"preview": "./values.yaml\n./values.schema.json\n\ndist/\nRELEASE_NOTES.md\n.vscode\n\n/helm-schema\n/tests/helm-schema\n"
},
{
"path": ".goreleaser.yaml",
"chars": 2328,
"preview": "---\n# yaml-language-server: $schema=https://goreleaser.com/static/schema.json\n# vim: set ts=2 sw=2 tw=0 fo=cnqoj\n\nversio"
},
{
"path": ".pre-commit-config.yaml",
"chars": 624,
"preview": "---\nrepos:\n - repo: https://github.com/dadav/helm-schema\n rev: 0.22.0\n hooks:\n - id: helm-schema\n # f"
},
{
"path": ".pre-commit-hooks.yaml",
"chars": 784,
"preview": "---\n- id: helm-schema\n description: Uses helm-schema to create a jsonschema.\n entry: helm-schema\n files: (Chart|value"
},
{
"path": "CLAUDE.md",
"chars": 13014,
"preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## "
},
{
"path": "Dockerfile",
"chars": 361,
"preview": "FROM alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11\nARG TARGETPLATFORM\nRUN adduser "
},
{
"path": "LICENSE",
"chars": 1062,
"preview": "MIT License\n\nCopyright (c) 2023 dadav\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof t"
},
{
"path": "README.md",
"chars": 34836,
"preview": "# helm-schema\n\n<p align=\"center\">\n <img src=\"images/logo.png\" width=\"400\" />\n <br />\n <a href=\"https://github.com/dad"
},
{
"path": "cliff.toml",
"chars": 1162,
"preview": "[changelog]\nheader = \"\"\nbody = \"\"\"\n{% if version %}## {{ version }} - {{ timestamp | date(format=\"%Y-%m-%d\") }}\n{% else "
},
{
"path": "cmd/helm-schema/cli.go",
"chars": 3900,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com"
},
{
"path": "cmd/helm-schema/main.go",
"chars": 18021,
"preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n"
},
{
"path": "cmd/helm-schema/main_test.go",
"chars": 15602,
"preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/spf13/viper\"\n\t\"github.com/stretc"
},
{
"path": "cmd/helm-schema/version.go",
"chars": 44,
"preview": "package main\n\nvar version string = \"0.23.2\"\n"
},
{
"path": "examples/Chart.yaml",
"chars": 1143,
"preview": "apiVersion: v2\nname: example\ndescription: A Helm chart for Kubernetes\n\n# A chart can be either an 'application' or a 'li"
},
{
"path": "examples/values.schema.json",
"chars": 4788,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"properties\": {\n \"global"
},
{
"path": "examples/values.yaml",
"chars": 2623,
"preview": "# vim: set ft=yaml:\n# yaml-language-server: $schema=values.schema.json\n\n# This is an example values.yaml file, it aims t"
},
{
"path": "go.mod",
"chars": 1778,
"preview": "module github.com/dadav/helm-schema\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/dadav/go-jsonpointer v0.0.0-20240918181927-335cbee"
},
{
"path": "go.sum",
"chars": 7797,
"preview": "dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=\ndario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMI"
},
{
"path": "install-binary.sh",
"chars": 5403,
"preview": "#!/usr/bin/env sh\n\n# Shamelessly copied from https://github.com/technosophos/helm-template/blob/master/install-binary.sh"
},
{
"path": "pkg/chart/chart.go",
"chars": 3118,
"preview": "package chart\n\nimport (\n\t\"io\"\n\n\t\"github.com/dadav/helm-schema/pkg/util\"\n\tyaml \"gopkg.in/yaml.v3\"\n)\n\ntype Dependency stru"
},
{
"path": "pkg/chart/chart_test.go",
"chars": 4875,
"preview": "package chart\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestReadChartFile(t *testing.T) {\n\tdata := []by"
},
{
"path": "pkg/chart/searching/dependency_charts.go",
"chars": 3764,
"preview": "package searching\n\nimport (\n\t\"archive/tar\"\n\t\"compress/gzip\"\n\t\"fmt\"\n\t\"github.com/dadav/helm-schema/pkg/chart\"\n\t\"gopkg.in/"
},
{
"path": "pkg/schema/annotate.go",
"chars": 5193,
"preview": "package schema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"gopkg.in/yaml.v3\"\n)\n\n// Ha"
},
{
"path": "pkg/schema/annotate_test.go",
"chars": 6139,
"preview": "package schema\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestHasSchemaAnnotation(t *testing.T) {\n\ttes"
},
{
"path": "pkg/schema/err.go",
"chars": 115,
"preview": "package schema\n\ntype CircularError struct {\n\tmsg string\n}\n\nfunc (e *CircularError) Error() string { return e.msg }\n"
},
{
"path": "pkg/schema/err_test.go",
"chars": 587,
"preview": "package schema\n\nimport \"testing\"\n\nfunc TestCircularError(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmessage"
},
{
"path": "pkg/schema/root_schema_test.go",
"chars": 7184,
"preview": "package schema\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestGetRootSchemaFromComment(t *testing.T) {"
},
{
"path": "pkg/schema/schema.go",
"chars": 61970,
"preview": "package schema\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strconv"
},
{
"path": "pkg/schema/schema_test.go",
"chars": 28306,
"preview": "package schema\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/magiconair/properties/as"
},
{
"path": "pkg/schema/toposort.go",
"chars": 2126,
"preview": "package schema\n\nimport (\n\t\"fmt\"\n)\n\n// TopoSort uses topological sorting to sort the results\n// If allowCircular is true,"
},
{
"path": "pkg/schema/toposort_test.go",
"chars": 3249,
"preview": "package schema\n\nimport (\n\t\"testing\"\n\n\t\"github.com/dadav/helm-schema/pkg/chart\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\n"
},
{
"path": "pkg/schema/values_merge.go",
"chars": 4224,
"preview": "package schema\n\nimport (\n\t\"fmt\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\n// mergeValuesDocuments merges YAML documents using Helm-style p"
},
{
"path": "pkg/schema/worker.go",
"chars": 4203,
"preview": "package schema\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/dadav/helm-schema/pkg/chart\"\n\t\""
},
{
"path": "pkg/schema/worker_test.go",
"chars": 6774,
"preview": "package schema\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestWorker(t *"
},
{
"path": "pkg/util/file.go",
"chars": 4769,
"preview": "package util\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"gopkg.in/yaml.v3\"\n"
},
{
"path": "pkg/util/file_test.go",
"chars": 2508,
"preview": "package util\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestReadFileAndFixNewline(t *testing.T) {\n\ttes"
},
{
"path": "plugin.yaml",
"chars": 294,
"preview": "---\nname: \"schema\"\nversion: \"0.23.2\"\nusage: \"generate jsonschemas for your helm charts\"\ndescription: \"generate jsonschem"
},
{
"path": "renovate.json",
"chars": 182,
"preview": "{\n \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n \"extends\": [\"config:best-practices\", \"schedule:ear"
},
{
"path": "sign-plugin.sh",
"chars": 3918,
"preview": "#!/bin/bash\n# Script to sign Helm plugin tarballs for Helm v4 verification\n# This creates .prov (provenance) files using"
},
{
"path": "signing-key.asc",
"chars": 652,
"preview": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmDMEaRt56xYJKwYBBAHaRw8BAQdA4RAN5LipEazZbSMV+BLcJh1BHY39WxIxS8tm\nalFiioi0HGRhZGF2I"
},
{
"path": "tests/.gitignore",
"chars": 98,
"preview": "*_generated.schema.json\nhelm-schema\ntest_repo_example.yaml\ntest_repo_example_expected.schema.json\n"
},
{
"path": "tests/charts/Chart.yaml",
"chars": 99,
"preview": "apiVersion: v2\nname: test\ndescription: Test chart for helm-schema\ntype: application\nversion: 0.1.0\n"
},
{
"path": "tests/charts/ref_input.json",
"chars": 296,
"preview": "{\n \"foo\": {\n \"type\": \"string\",\n \"description\": \"from ref\"\n },\n \"bar\": {\n \"type\": \"object\",\n \"description\""
},
{
"path": "tests/charts/test_annotate_expected.yaml",
"chars": 807,
"preview": "# @schema\n# type: integer\n# @schema\nreplicaCount: 1\n\n# @schema\n# type: object\n# @schema\nimage:\n # @schema\n # type: str"
},
{
"path": "tests/charts/test_annotate_input.yaml",
"chars": 320,
"preview": "replicaCount: 1\n\nimage:\n repository: nginx\n pullPolicy: IfNotPresent\n # Overrides the image tag whose default is the "
},
{
"path": "tests/charts/test_helm_defaults.yaml",
"chars": 2356,
"preview": "# Default values for foo.\n# This is a YAML-formatted file.\n# Declare variables to be passed into your templates.\n\nreplic"
},
{
"path": "tests/charts/test_helm_defaults_expected.schema.json",
"chars": 9863,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"properties\": {\n \"affini"
},
{
"path": "tests/charts/test_ref.yaml",
"chars": 155,
"preview": "---\n# @schema\n# $ref: ref_input.json#/foo\n# @schema\n# Not from ref\nfoo: bar\n# @schema\n# $ref: ref_input.json#/bar\n# @sch"
},
{
"path": "tests/charts/test_ref_expected.schema.json",
"chars": 799,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"properties\": {\n \"bar\": "
},
{
"path": "tests/charts/test_ref_properties.yaml",
"chars": 253,
"preview": "# @schema\n# definitions:\n# port:\n# type: integer\n# minimum: 1\n# maximum: 65535\n# properties:\n# httpPort:"
},
{
"path": "tests/charts/test_ref_properties_expected.schema.json",
"chars": 814,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"definitions\": {\n \"port\""
},
{
"path": "tests/charts/test_ref_toplevel.yaml",
"chars": 124,
"preview": "# @schema\n# definitions:\n# toplevel:\n# description: \"Top Level\"\n# $ref: \"#/definitions/toplevel\"\n# @schema\ntopleve"
},
{
"path": "tests/charts/test_ref_toplevel_expected.schema.json",
"chars": 513,
"preview": "{\n\t\"$schema\": \"http://json-schema.org/draft-07/schema#\",\n\t\"additionalProperties\": false,\n\t\"definitions\": {\n\t\t\"toplevel\":"
},
{
"path": "tests/charts/test_simple.yaml",
"chars": 52,
"preview": "---\n# @schema\n# description: foo\n# @schema\nfoo: bar\n"
},
{
"path": "tests/charts/test_simple_expected.schema.json",
"chars": 482,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"properties\": {\n \"foo\": "
},
{
"path": "tests/import-values/child/Chart.yaml",
"chars": 82,
"preview": "apiVersion: v2\nname: child\nversion: 1.0.0\ndescription: A child chart with exports\n"
},
{
"path": "tests/import-values/child/values.yaml",
"chars": 555,
"preview": "# @schema\n# type: object\n# @schema\nexports:\n # @schema\n # type: object\n # @schema\n defaults:\n # @schema\n # typ"
},
{
"path": "tests/import-values/child-complex/Chart.yaml",
"chars": 120,
"preview": "apiVersion: v2\nname: child-complex\nversion: 1.0.0\ndescription: A child chart with nested data for complex import-values\n"
},
{
"path": "tests/import-values/child-complex/values.yaml",
"chars": 347,
"preview": "# @schema\n# type: object\n# @schema\ndata:\n # @schema\n # type: object\n # @schema\n database:\n # @schema\n # type: "
},
{
"path": "tests/import-values/parent/Chart.yaml",
"chars": 208,
"preview": "apiVersion: v2\nname: parent\nversion: 1.0.0\ndescription: A parent chart using import-values\ndependencies:\n - name: child"
},
{
"path": "tests/import-values/parent/values.schema.expected.json",
"chars": 1000,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"properties\": {\n \"appNam"
},
{
"path": "tests/import-values/parent/values.yaml",
"chars": 209,
"preview": "# @schema\n# type: string\n# description: Application name\n# @schema\nappName: myapp\n\n# No @schema annotation - inferred sc"
},
{
"path": "tests/import-values/parent-complex/Chart.yaml",
"chars": 276,
"preview": "apiVersion: v2\nname: parent-complex\nversion: 1.0.0\ndescription: A parent chart using complex import-values form\ndependen"
},
{
"path": "tests/import-values/parent-complex/values.schema.expected.json",
"chars": 1032,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"properties\": {\n \"db\": {"
},
{
"path": "tests/import-values/parent-complex/values.yaml",
"chars": 98,
"preview": "# @schema\n# type: string\n# description: Application environment\n# @schema\nenvironment: production\n"
},
{
"path": "tests/preexisting-schema/dep-with-schema/Chart.yaml",
"chars": 104,
"preview": "apiVersion: v2\nname: dep-with-schema\nversion: 1.0.0\ndescription: A dependency chart with its own schema\n"
},
{
"path": "tests/preexisting-schema/dep-with-schema/values.schema.json",
"chars": 444,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"type\": \"object\",\n \"properties\": {\n \"port\": {\n \"typ"
},
{
"path": "tests/preexisting-schema/dep-with-schema/values.yaml",
"chars": 27,
"preview": "port: 8080\nhost: localhost\n"
},
{
"path": "tests/preexisting-schema/parent/Chart.yaml",
"chars": 201,
"preview": "apiVersion: v2\nname: parent\nversion: 1.0.0\ndescription: A parent chart depending on dep-with-schema\ndependencies:\n - na"
},
{
"path": "tests/preexisting-schema/parent/values.schema.default-expected.json",
"chars": 1156,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"properties\": {\n \"appNam"
},
{
"path": "tests/preexisting-schema/parent/values.schema.expected.json",
"chars": 1035,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"additionalProperties\": false,\n \"properties\": {\n \"appNam"
},
{
"path": "tests/preexisting-schema/parent/values.yaml",
"chars": 15,
"preview": "appName: myapp\n"
},
{
"path": "tests/run.sh",
"chars": 4066,
"preview": "#!/usr/bin/env bash\n\nrc=0\n\ncd charts\n\ncp ../../examples/values.yaml test_repo_example.yaml\ncp ../../examples/values.sche"
},
{
"path": "tests/test-sign-plugin.sh",
"chars": 3092,
"preview": "#!/bin/bash\n# Test script for sign-plugin.sh\n# Creates an isolated GPG environment, generates a test key, and validates "
}
]
About this extraction
This page contains the full source code of the dadav/helm-schema GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 77 files (300.2 KB), approximately 86.3k tokens, and a symbol index with 160 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.