Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,13 @@ router will seamlessly take over again.

The router accepts the following flags to customize its behavior:

| Flag | Description | Default |
| :----------- | :----------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--lsp-path` | Path to the underlying `yaml-language-server` executable. Use this if the server is not in your systems PATH. | `yaml-language-server` |
| `--log-file` | Path to a file where logs should be written. **Note:** Since the router communicates via Stdio, logs cannot be sent to stdout. | `~/.cache/yaml-schema-router/router.log` (Linux)<br>`~/Library/Caches/yaml-schema-router/router.log` (macOS)<br>`%LocalAppData%\yaml-schema-router\router.log` (Windows) |
| Flag | Description | Default |
| :--------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--kubernetes-version` | Kubernetes schema version to use. The value must match a version directory in the upstream registry, such as `v1.36.0`. | `v1.36.0` |
| `--schema-dir` | Directory containing downloaded and user-provided schemas. Generated composite schemas are cached separately. | The platform user cache directory under `yaml-schema-router/schemas` |
| `--offline` | Use only schemas already present in `--schema-dir`; never make schema download requests. | `false` |
| `--lsp-path` | Path to the underlying `yaml-language-server` executable. Use this if the server is not in your system's PATH. | `yaml-language-server` |
| `--log-file` | Path to a file where logs should be written. **Note:** Since the router communicates via Stdio, logs cannot be sent to stdout. | `~/.cache/yaml-schema-router/router.log` (Linux)<br>`~/Library/Caches/yaml-schema-router/router.log` (macOS)<br>`%LocalAppData%\yaml-schema-router\router.log` (Windows) |

### Example Editor Configuration (Helix)

Expand All @@ -222,6 +225,9 @@ language-servers = [ "yaml-schema-router" ]
[language-server.yaml-schema-router]
command = "yaml-schema-router"
args = [
"--kubernetes-version", "v1.36.0",
"--schema-dir", "/path/to/yaml-schemas",
"--offline",
"--log-file", "/tmp/yaml-router.log",
"--lsp-path", "/usr/bin/yaml-language-server"
]
Expand Down Expand Up @@ -251,6 +257,23 @@ made once per schema. Once a schema is cached locally, no further network
requests are made for that specific version, allowing for completely offline
development.

### Offline Local-Only Mode

Pass `--offline` to disable all schema downloads. The router only uses files
already stored under `--schema-dir`; it may still write generated composite and
CRD wrapper schemas to its separate platform cache.

The local directory mirrors the router's registry paths. For example:

```text
<schema-dir>/kubernetes-builtin/v1.36.0-standalone-strict/deployment-apps-v1.json
<schema-dir>/kubernetes-crd/example.com/widget_v1.json
```

When a required local schema is missing, the router writes a `WARNING` to its
configured log and leaves that resource unmapped. Other resources in the same
file whose schemas are available continue to be mapped.

## Compatibility

This tool is designed to wrap the
Expand Down
36 changes: 33 additions & 3 deletions cmd/yaml-schema-router/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ func run() error {
} else {
defaultLogPath = filepath.Join(os.TempDir(), "yaml-schema-router.log")
}
userCacheDir, err := os.UserCacheDir()
if err != nil {
return fmt.Errorf("could not determine user cache dir: %w", err)
}
defaultCacheDir := filepath.Join(userCacheDir, config.DefaultConfigDirName)

logFile := flag.String(
"log-file",
Expand All @@ -48,6 +53,21 @@ func run() error {
"yaml-language-server",
"Path to the yaml-language-server executable. Defaults to checking the system PATH.",
)
kubernetesVersion := flag.String(
"kubernetes-version",
config.DefaultK8sSchemaVersion,
"Kubernetes schema version to use (for example, v1.36.0).",
)
schemaDir := flag.String(
"schema-dir",
filepath.Join(defaultCacheDir, "schemas"),
"Directory containing downloaded and user-provided schemas.",
)
offline := flag.Bool(
"offline",
false,
"Use only schemas already present in the local schema directory.",
)
_ = flag.Bool(
"stdio",
true,
Expand Down Expand Up @@ -79,13 +99,23 @@ func run() error {

log.Printf("[%s] Starting yaml-schema-router. Using LSP executable: %s", componentName, *lspPath)

registry, err := schemaregistry.NewRegistry()
registry, err := schemaregistry.NewRegistry(
*schemaDir,
filepath.Join(defaultCacheDir, "generated"),
*offline,
)
if err != nil {
return fmt.Errorf("failed to initialize schema registry: %v", err)
}

k8sDetector := &kubernetes.K8sDetector{Registry: registry}
crdDetector := &kubernetes.CRDDetector{Registry: registry}
k8sDetector := &kubernetes.K8sDetector{
Registry: registry,
Version: *kubernetesVersion,
}
crdDetector := &kubernetes.CRDDetector{
Registry: registry,
Version: *kubernetesVersion,
}
chain := detector.NewChain(k8sDetector, crdDetector)

proxy := lspproxy.NewProxy(*lspPath, chain, registry)
Expand Down
2 changes: 1 addition & 1 deletion internal/config/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const (
DefaultK8sSchemaRegistry = "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master"

// DefaultK8sSchemaVersion is the version of the k8s schmeas to fetch.
DefaultK8sSchemaVersion = "v1.33.0"
DefaultK8sSchemaVersion = "v1.36.0"

// DefaultK8sSchemaFlavour is the "-standalone-strict" suffix for self-contained, strict validation.
DefaultK8sSchemaFlavour = "-standalone-strict"
Expand Down
35 changes: 22 additions & 13 deletions internal/detector/kubernetes/crd.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package kubernetes

import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
Expand Down Expand Up @@ -33,6 +35,7 @@ type schemaWrapper struct {
// CRDDetector implements the detector.Detector interface for Kubernetes CRDs.
type CRDDetector struct {
Registry *schemaregistry.Registry
Version string
}

var _ detector.Detector = (*CRDDetector)(nil)
Expand Down Expand Up @@ -65,20 +68,26 @@ func (d *CRDDetector) Detect(_ string, content []byte) ([]string, error) {

kindFormatted := strings.ToLower(meta.Kind)
fileName := fmt.Sprintf("%s_%s.json", kindFormatted, version)
wrapperCachePath := filepath.Join(CRDDetectorName, group, fmt.Sprintf("%s_%s_wrapper.json", kindFormatted, version))

// Fast path: if the wrapper already exists, we don't need to do anything
if _, statErr := os.Stat(d.Registry.GetLocalPath(wrapperCachePath)); statErr == nil {
log.Printf("[%s] Wrapper cache hit for %s", d.Name(), wrapperCachePath)
schemaURLs = append(schemaURLs, d.Registry.GetLocalFileURI(wrapperCachePath))
localBaseCRDURI, localObjectMetaURI, err := d.fetchDependencies(group, fileName)
if err != nil {
if errors.Is(err, schemaregistry.ErrSchemaUnavailableOffline) {
log.Printf("[%s] WARNING: A local dependency for CRD %s is missing; this resource will not be mapped: %v", d.Name(), meta.Kind, err)
} else {
log.Printf("[%s] Failed to fetch dependencies for CRD %s: %v", d.Name(), meta.Kind, err)
}
continue
}

log.Printf("[%s] Wrapper cache miss. Fetching dependencies...", d.Name())
wrapperID := sha256.Sum256([]byte(localBaseCRDURI + "\x00" + localObjectMetaURI))
wrapperCachePath := filepath.Join(
CRDDetectorName,
group,
fmt.Sprintf("%s_%s_wrapper_%x.json", kindFormatted, version, wrapperID[:8]),
)

localBaseCRDURI, localObjectMetaURI, err := d.fetchDependencies(group, fileName)
if err != nil {
log.Printf("[%s] Failed to fetch dependencies for CRD %s: %v", d.Name(), meta.Kind, err)
if _, statErr := os.Stat(d.Registry.GetGeneratedPath(wrapperCachePath)); statErr == nil {
log.Printf("[%s] Wrapper cache hit for %s", d.Name(), wrapperCachePath)
schemaURLs = append(schemaURLs, d.Registry.GetGeneratedFileURI(wrapperCachePath))
continue
}

Expand Down Expand Up @@ -114,7 +123,7 @@ func (d *CRDDetector) fetchDependencies(
}

// Get ObjectMeta remote URL & fetch local URI
versionDir := fmt.Sprintf("%s%s", config.DefaultK8sSchemaVersion, config.DefaultK8sSchemaFlavour)
versionDir := fmt.Sprintf("%s%s", d.Version, config.DefaultK8sSchemaFlavour)
objectMetaURL, err := url.JoinPath(config.DefaultK8sSchemaRegistry, versionDir, config.DefaultK8sMetaSchemaFileName)
if err != nil {
return "", "", err
Expand Down Expand Up @@ -156,9 +165,9 @@ func (d *CRDDetector) generateAndSaveWrapper(
}

// Write the generated schema to the persistent cache directory
if err := d.Registry.SaveLocalSchema(wrapperCachePath, wrapperBytes); err != nil {
if err := d.Registry.SaveGeneratedSchema(wrapperCachePath, wrapperBytes); err != nil {
return "", err
}

return d.Registry.GetLocalFileURI(wrapperCachePath), nil
return d.Registry.GetGeneratedFileURI(wrapperCachePath), nil
}
10 changes: 8 additions & 2 deletions internal/detector/kubernetes/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package kubernetes

import (
"errors"
"fmt"
"log"
"net/url"
Expand All @@ -16,6 +17,7 @@ import (
// K8sDetector implements the detector.Detector interface for Kubernetes manifests.
type K8sDetector struct {
Registry *schemaregistry.Registry
Version string
}

var _ detector.Detector = (*K8sDetector)(nil)
Expand Down Expand Up @@ -90,7 +92,7 @@ func (d *K8sDetector) resolveSchemaURL(meta typeMeta) string {

kindFormatted := strings.ToLower(meta.Kind)
fileName := fmt.Sprintf("%s-%s.json", kindFormatted, apiVersionFormatted)
versionDir := fmt.Sprintf("%s%s", config.DefaultK8sSchemaVersion, config.DefaultK8sSchemaFlavour)
versionDir := fmt.Sprintf("%s%s", d.Version, config.DefaultK8sSchemaFlavour)

remoteSchemaURL, err := url.JoinPath(
config.DefaultK8sSchemaRegistry,
Expand All @@ -105,7 +107,11 @@ func (d *K8sDetector) resolveSchemaURL(meta typeMeta) string {
cachePath := filepath.Join(d.Name(), versionDir, fileName)
localURI, err := d.Registry.GetSchemaURI(remoteSchemaURL, cachePath)
if err != nil {
log.Printf("[%s] Failed to fetch schema for %s: %v", d.Name(), meta.Kind, err)
if errors.Is(err, schemaregistry.ErrSchemaUnavailableOffline) {
log.Printf("[%s] WARNING: No local schema for %s; this resource will not be mapped: %v", d.Name(), meta.Kind, err)
} else {
log.Printf("[%s] Failed to fetch schema for %s: %v", d.Name(), meta.Kind, err)
}
return ""
}

Expand Down
81 changes: 50 additions & 31 deletions internal/schemaregistry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"os"
Expand All @@ -15,39 +16,50 @@ import (

const componentName = "Registry"

// Registry manages a persistent disk cache for JSON schemas.
// ErrSchemaUnavailableOffline indicates that offline mode prevented a missing schema download.
var ErrSchemaUnavailableOffline = errors.New("schema is unavailable in the local schema directory")

// Registry manages local schemas and a separate cache for generated schemas.
type Registry struct {
baseDir string
schemaDir string
cacheDir string
offline bool
}

type compositeSchema struct {
AnyOf []map[string]string `json:"anyOf"`
}

// NewRegistry initializes the user's cache directory.
func NewRegistry() (*Registry, error) {
userCache, err := os.UserCacheDir()
if err != nil {
return nil, fmt.Errorf("could not determine user cache dir: %w", err)
// NewRegistry initializes the local schema and generated-schema directories.
func NewRegistry(schemaDir, cacheDir string, offline bool) (*Registry, error) {
if err := os.MkdirAll(schemaDir, config.DefaultDirPerm); err != nil {
return nil, fmt.Errorf("could not create schema dir: %w", err)
}

baseDir := filepath.Join(userCache, config.DefaultConfigDirName, "schemas")
if err := os.MkdirAll(baseDir, config.DefaultDirPerm); err != nil {
return nil, fmt.Errorf("could not create cache dir: %w", err)
if err := os.MkdirAll(cacheDir, config.DefaultDirPerm); err != nil {
return nil, fmt.Errorf("could not create generated schema cache dir: %w", err)
}

return &Registry{baseDir: baseDir}, nil
return &Registry{
schemaDir: schemaDir,
cacheDir: cacheDir,
offline: offline,
}, nil
}

// GetSchemaURI checks if the schema exists on disk. If not, it attempts to
// download it. Returns a file:// URI on success, or an error if it fails.
func (r *Registry) GetSchemaURI(remoteURL, cachePath string) (string, error) {
fullPath := filepath.Join(r.baseDir, cachePath)
fullPath := filepath.Join(r.schemaDir, cachePath)

// Fast path: check if file already exists in cache
if _, err := os.Stat(fullPath); err == nil {
log.Printf("[%s] Cache hit: %s", componentName, cachePath)
return fmt.Sprintf("file://%s", fullPath), nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("failed to inspect local schema %s: %w", fullPath, err)
}

if r.offline {
return "", fmt.Errorf("%w: %s", ErrSchemaUnavailableOffline, fullPath)
}

log.Printf("[%s] Cache miss: %s. Downloading from %s ...", componentName, cachePath, remoteURL)
Expand All @@ -62,34 +74,41 @@ func (r *Registry) GetSchemaURI(remoteURL, cachePath string) (string, error) {
log.Printf("[%s] Download successful. Saving to %s", componentName, fullPath)

// Save to cache
if err := r.SaveLocalSchema(cachePath, data); err != nil {
if err := r.saveSchema(cachePath, data); err != nil {
return "", fmt.Errorf("failed to save %s: %w", fullPath, err)
}

return fmt.Sprintf("file://%s", fullPath), nil
}

// GetLocalPath returns the absolute local path for a cache path.
func (r *Registry) GetLocalPath(cachePath string) string {
return filepath.Join(r.baseDir, cachePath)
// saveSchema writes a downloaded schema to the local schema directory.
func (r *Registry) saveSchema(schemaPath string, data []byte) error {
fullPath := filepath.Join(r.schemaDir, schemaPath)
if err := os.MkdirAll(filepath.Dir(fullPath), config.DefaultDirPerm); err != nil {
return err
}

return os.WriteFile(fullPath, data, config.DefaultFilePerm)
}

// SaveLocalSchema writes raw byte data directly to the cache. Useful for generated wrappers.
func (r *Registry) SaveLocalSchema(cachePath string, data []byte) error {
fullPath := filepath.Join(r.baseDir, cachePath)
dir := filepath.Dir(fullPath)
// GetGeneratedPath returns the absolute path for a generated schema cache path.
func (r *Registry) GetGeneratedPath(cachePath string) string {
return filepath.Join(r.cacheDir, cachePath)
}

if err := os.MkdirAll(dir, config.DefaultDirPerm); err != nil {
// SaveGeneratedSchema writes a generated schema to the generated-schema cache.
func (r *Registry) SaveGeneratedSchema(cachePath string, data []byte) error {
fullPath := r.GetGeneratedPath(cachePath)
if err := os.MkdirAll(filepath.Dir(fullPath), config.DefaultDirPerm); err != nil {
return err
}

return os.WriteFile(fullPath, data, config.DefaultFilePerm)
}

// GetLocalFileURI returns the formatted file:// URI for a known local cache path, without downloading.
func (r *Registry) GetLocalFileURI(cachePath string) string {
fullPath := filepath.Join(r.baseDir, cachePath)
return fmt.Sprintf("file://%s", fullPath)
// GetGeneratedFileURI returns the file URI for a generated schema cache path.
func (r *Registry) GetGeneratedFileURI(cachePath string) string {
return fmt.Sprintf("file://%s", r.GetGeneratedPath(cachePath))
}

// GenerateCompositeSchema creates a single schema using 'anyOf' to aggregate multiple schemas.
Expand Down Expand Up @@ -123,8 +142,8 @@ func (r *Registry) GenerateCompositeSchema(schemaURIs []string) (string, error)
cachePath := filepath.Join("composite", fmt.Sprintf("composite_%s.json", hashStr))

// Fast path: check if this exact composite combination already exists
if _, err := os.Stat(r.GetLocalPath(cachePath)); err == nil {
return r.GetLocalFileURI(cachePath), nil
if _, err := os.Stat(r.GetGeneratedPath(cachePath)); err == nil {
return r.GetGeneratedFileURI(cachePath), nil
}

// Build the wrapper
Expand All @@ -140,9 +159,9 @@ func (r *Registry) GenerateCompositeSchema(schemaURIs []string) (string, error)
}

// Save the dynamically generated wrapper to disk
if err := r.SaveLocalSchema(cachePath, data); err != nil {
if err := r.SaveGeneratedSchema(cachePath, data); err != nil {
return "", err
}

return r.GetLocalFileURI(cachePath), nil
return r.GetGeneratedFileURI(cachePath), nil
}
Loading