From 988396351fce7160494afa162e70121ed9b0a536 Mon Sep 17 00:00:00 2001 From: Alex Wicks Date: Thu, 10 Sep 2026 23:38:23 +0100 Subject: [PATCH 1/4] feat: make Kubernetes schema version configurable Add a --kubernetes-version command-line option and pass the selected version to both built-in and CRD detectors. Document the option and its required upstream registry format so editor configurations can target a specific Kubernetes release. --- README.md | 10 ++++++---- cmd/yaml-schema-router/main.go | 15 +++++++++++++-- internal/detector/kubernetes/crd.go | 3 ++- internal/detector/kubernetes/k8s.go | 3 ++- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 138ccf0..6d9c830 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,11 @@ 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)
`~/Library/Caches/yaml-schema-router/router.log` (macOS)
`%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.33.0`. | `v1.33.0` | +| `--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)
`~/Library/Caches/yaml-schema-router/router.log` (macOS)
`%LocalAppData%\yaml-schema-router\router.log` (Windows) | ### Example Editor Configuration (Helix) @@ -222,6 +223,7 @@ language-servers = [ "yaml-schema-router" ] [language-server.yaml-schema-router] command = "yaml-schema-router" args = [ + "--kubernetes-version", "v1.33.0", "--log-file", "/tmp/yaml-router.log", "--lsp-path", "/usr/bin/yaml-language-server" ] diff --git a/cmd/yaml-schema-router/main.go b/cmd/yaml-schema-router/main.go index f9819d4..0797aea 100644 --- a/cmd/yaml-schema-router/main.go +++ b/cmd/yaml-schema-router/main.go @@ -48,6 +48,11 @@ 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.33.0).", + ) _ = flag.Bool( "stdio", true, @@ -84,8 +89,14 @@ func run() error { 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) diff --git a/internal/detector/kubernetes/crd.go b/internal/detector/kubernetes/crd.go index 37ec6a8..da621d1 100644 --- a/internal/detector/kubernetes/crd.go +++ b/internal/detector/kubernetes/crd.go @@ -33,6 +33,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) @@ -114,7 +115,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 diff --git a/internal/detector/kubernetes/k8s.go b/internal/detector/kubernetes/k8s.go index 1963e45..8c85549 100644 --- a/internal/detector/kubernetes/k8s.go +++ b/internal/detector/kubernetes/k8s.go @@ -16,6 +16,7 @@ import ( // K8sDetector implements the detector.Detector interface for Kubernetes manifests. type K8sDetector struct { Registry *schemaregistry.Registry + Version string } var _ detector.Detector = (*K8sDetector)(nil) @@ -90,7 +91,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, From 50a9b0bf9419055844ec290f34fd6cce686e497a Mon Sep 17 00:00:00 2001 From: Alex Wicks Date: Thu, 10 Sep 2026 23:42:58 +0100 Subject: [PATCH 2/4] feat: default to Kubernetes 1.36 schemas Update the built-in Kubernetes schema release to v1.36.0. Keep the CLI help and usage examples aligned with the default, which is available in the upstream strict standalone schema registry. --- README.md | 4 ++-- cmd/yaml-schema-router/main.go | 2 +- internal/config/constants.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6d9c830..cb783ef 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ The router accepts the following flags to customize its behavior: | Flag | Description | Default | | :--------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--kubernetes-version` | Kubernetes schema version to use. The value must match a version directory in the upstream registry, such as `v1.33.0`. | `v1.33.0` | +| `--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` | | `--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)
`~/Library/Caches/yaml-schema-router/router.log` (macOS)
`%LocalAppData%\yaml-schema-router\router.log` (Windows) | @@ -223,7 +223,7 @@ language-servers = [ "yaml-schema-router" ] [language-server.yaml-schema-router] command = "yaml-schema-router" args = [ - "--kubernetes-version", "v1.33.0", + "--kubernetes-version", "v1.36.0", "--log-file", "/tmp/yaml-router.log", "--lsp-path", "/usr/bin/yaml-language-server" ] diff --git a/cmd/yaml-schema-router/main.go b/cmd/yaml-schema-router/main.go index 0797aea..12380a8 100644 --- a/cmd/yaml-schema-router/main.go +++ b/cmd/yaml-schema-router/main.go @@ -51,7 +51,7 @@ func run() error { kubernetesVersion := flag.String( "kubernetes-version", config.DefaultK8sSchemaVersion, - "Kubernetes schema version to use (for example, v1.33.0).", + "Kubernetes schema version to use (for example, v1.36.0).", ) _ = flag.Bool( "stdio", diff --git a/internal/config/constants.go b/internal/config/constants.go index 56170ab..7d743f0 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -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" From 2b4f90a9ee722fe5bbe2488102307cdbcc148bdf Mon Sep 17 00:00:00 2001 From: Alex Wicks Date: Thu, 10 Sep 2026 23:44:41 +0100 Subject: [PATCH 3/4] feat: separate local schemas from generated cache Add --schema-dir for downloaded and user-provided schema files while keeping generated composite and CRD wrapper files in the platform cache. Cover both lookup and composite generation so local schemas remain reusable without generated artifacts being written beside them. --- README.md | 2 + cmd/yaml-schema-router/main.go | 15 ++++- internal/detector/kubernetes/crd.go | 27 +++++---- internal/schemaregistry/registry.go | 68 +++++++++++++---------- internal/schemaregistry/registry_test.go | 71 ++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 43 deletions(-) create mode 100644 internal/schemaregistry/registry_test.go diff --git a/README.md b/README.md index cb783ef..dc617ad 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ The router accepts the following flags to customize its behavior: | 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` | | `--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)
`~/Library/Caches/yaml-schema-router/router.log` (macOS)
`%LocalAppData%\yaml-schema-router\router.log` (Windows) | @@ -224,6 +225,7 @@ language-servers = [ "yaml-schema-router" ] command = "yaml-schema-router" args = [ "--kubernetes-version", "v1.36.0", + "--schema-dir", "/path/to/yaml-schemas", "--log-file", "/tmp/yaml-router.log", "--lsp-path", "/usr/bin/yaml-language-server" ] diff --git a/cmd/yaml-schema-router/main.go b/cmd/yaml-schema-router/main.go index 12380a8..3e8ebd9 100644 --- a/cmd/yaml-schema-router/main.go +++ b/cmd/yaml-schema-router/main.go @@ -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", @@ -53,6 +58,11 @@ func run() error { 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.", + ) _ = flag.Bool( "stdio", true, @@ -84,7 +94,10 @@ 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"), + ) if err != nil { return fmt.Errorf("failed to initialize schema registry: %v", err) } diff --git a/internal/detector/kubernetes/crd.go b/internal/detector/kubernetes/crd.go index da621d1..026c5a3 100644 --- a/internal/detector/kubernetes/crd.go +++ b/internal/detector/kubernetes/crd.go @@ -1,6 +1,7 @@ package kubernetes import ( + "crypto/sha256" "encoding/json" "fmt" "log" @@ -66,20 +67,22 @@ 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 { + 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 } @@ -157,9 +160,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 } diff --git a/internal/schemaregistry/registry.go b/internal/schemaregistry/registry.go index 2530604..e703fd0 100644 --- a/internal/schemaregistry/registry.go +++ b/internal/schemaregistry/registry.go @@ -15,34 +15,35 @@ import ( const componentName = "Registry" -// Registry manages a persistent disk cache for JSON schemas. +// Registry manages local schemas and a separate cache for generated schemas. type Registry struct { - baseDir string + schemaDir string + cacheDir string } 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) (*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, + }, 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 { @@ -62,34 +63,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. @@ -123,8 +131,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 @@ -140,9 +148,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 } diff --git a/internal/schemaregistry/registry_test.go b/internal/schemaregistry/registry_test.go new file mode 100644 index 0000000..ec17420 --- /dev/null +++ b/internal/schemaregistry/registry_test.go @@ -0,0 +1,71 @@ +package schemaregistry + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGetSchemaURIUsesSchemaDirectory(t *testing.T) { + t.Parallel() + + root := t.TempDir() + schemaDir := filepath.Join(root, "schemas") + cacheDir := filepath.Join(root, "generated") + registry, err := NewRegistry(schemaDir, cacheDir) + if err != nil { + t.Fatal(err) + } + + schemaPath := filepath.Join("kubernetes-builtin", "v1.36.0-standalone-strict", "service-v1.json") + fullPath := filepath.Join(schemaDir, schemaPath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(`{"type":"object"}`), 0o600); err != nil { + t.Fatal(err) + } + + uri, err := registry.GetSchemaURI("http://127.0.0.1:1/should-not-be-requested", schemaPath) + if err != nil { + t.Fatal(err) + } + if want := "file://" + fullPath; uri != want { + t.Fatalf("GetSchemaURI() = %q, want %q", uri, want) + } +} + +func TestGenerateCompositeSchemaUsesSeparateCacheDirectory(t *testing.T) { + t.Parallel() + + root := t.TempDir() + schemaDir := filepath.Join(root, "schemas") + cacheDir := filepath.Join(root, "generated") + registry, err := NewRegistry(schemaDir, cacheDir) + if err != nil { + t.Fatal(err) + } + + uri, err := registry.GenerateCompositeSchema([]string{ + "file:///schemas/deployment.json", + "file:///schemas/service.json", + }) + if err != nil { + t.Fatal(err) + } + + if wantPrefix := "file://" + filepath.Join(cacheDir, "composite") + string(filepath.Separator); !strings.HasPrefix(uri, wantPrefix) { + t.Fatalf("GenerateCompositeSchema() = %q, want prefix %q", uri, wantPrefix) + } + if _, err := os.Stat(strings.TrimPrefix(uri, "file://")); err != nil { + t.Fatalf("generated composite does not exist: %v", err) + } + entries, err := os.ReadDir(schemaDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("schema directory contains generated entries: %v", entries) + } +} From 2a7ca2c6c5ce5511a4587db8e44dc19a5665e3df Mon Sep 17 00:00:00 2001 From: Alex Wicks Date: Thu, 10 Sep 2026 23:49:01 +0100 Subject: [PATCH 4/4] feat: add offline local-only schema mode Add --offline to prohibit schema downloads while continuing to use local schemas and generated composite cache files. Return a distinct missing-local-schema error so Kubernetes detectors can emit explicit warnings and leave only unavailable resources unmapped. Document the expected local registry layout and warning behavior. --- README.md | 19 +++++++++++++++++ cmd/yaml-schema-router/main.go | 6 ++++++ internal/detector/kubernetes/crd.go | 7 +++++- internal/detector/kubernetes/k8s.go | 7 +++++- internal/schemaregistry/registry.go | 15 +++++++++++-- internal/schemaregistry/registry_test.go | 27 ++++++++++++++++++++++-- 6 files changed, 75 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dc617ad..1cdfa7a 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ The router accepts the following flags to customize its behavior: | :--------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--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)
`~/Library/Caches/yaml-schema-router/router.log` (macOS)
`%LocalAppData%\yaml-schema-router\router.log` (Windows) | @@ -226,6 +227,7 @@ 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" ] @@ -255,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 +/kubernetes-builtin/v1.36.0-standalone-strict/deployment-apps-v1.json +/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 diff --git a/cmd/yaml-schema-router/main.go b/cmd/yaml-schema-router/main.go index 3e8ebd9..b0949f0 100644 --- a/cmd/yaml-schema-router/main.go +++ b/cmd/yaml-schema-router/main.go @@ -63,6 +63,11 @@ func run() error { 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, @@ -97,6 +102,7 @@ func run() error { registry, err := schemaregistry.NewRegistry( *schemaDir, filepath.Join(defaultCacheDir, "generated"), + *offline, ) if err != nil { return fmt.Errorf("failed to initialize schema registry: %v", err) diff --git a/internal/detector/kubernetes/crd.go b/internal/detector/kubernetes/crd.go index 026c5a3..8b3451d 100644 --- a/internal/detector/kubernetes/crd.go +++ b/internal/detector/kubernetes/crd.go @@ -3,6 +3,7 @@ package kubernetes import ( "crypto/sha256" "encoding/json" + "errors" "fmt" "log" "net/url" @@ -69,7 +70,11 @@ func (d *CRDDetector) Detect(_ string, content []byte) ([]string, error) { fileName := fmt.Sprintf("%s_%s.json", kindFormatted, version) 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 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 } diff --git a/internal/detector/kubernetes/k8s.go b/internal/detector/kubernetes/k8s.go index 8c85549..9e99749 100644 --- a/internal/detector/kubernetes/k8s.go +++ b/internal/detector/kubernetes/k8s.go @@ -2,6 +2,7 @@ package kubernetes import ( + "errors" "fmt" "log" "net/url" @@ -106,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 "" } diff --git a/internal/schemaregistry/registry.go b/internal/schemaregistry/registry.go index e703fd0..a23c777 100644 --- a/internal/schemaregistry/registry.go +++ b/internal/schemaregistry/registry.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "os" @@ -15,10 +16,14 @@ import ( const componentName = "Registry" +// 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 { schemaDir string cacheDir string + offline bool } type compositeSchema struct { @@ -26,7 +31,7 @@ type compositeSchema struct { } // NewRegistry initializes the local schema and generated-schema directories. -func NewRegistry(schemaDir, cacheDir string) (*Registry, error) { +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) } @@ -37,6 +42,7 @@ func NewRegistry(schemaDir, cacheDir string) (*Registry, error) { return &Registry{ schemaDir: schemaDir, cacheDir: cacheDir, + offline: offline, }, nil } @@ -45,10 +51,15 @@ func NewRegistry(schemaDir, cacheDir string) (*Registry, error) { func (r *Registry) GetSchemaURI(remoteURL, cachePath string) (string, error) { 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) diff --git a/internal/schemaregistry/registry_test.go b/internal/schemaregistry/registry_test.go index ec17420..f85110b 100644 --- a/internal/schemaregistry/registry_test.go +++ b/internal/schemaregistry/registry_test.go @@ -1,6 +1,7 @@ package schemaregistry import ( + "errors" "os" "path/filepath" "strings" @@ -13,7 +14,7 @@ func TestGetSchemaURIUsesSchemaDirectory(t *testing.T) { root := t.TempDir() schemaDir := filepath.Join(root, "schemas") cacheDir := filepath.Join(root, "generated") - registry, err := NewRegistry(schemaDir, cacheDir) + registry, err := NewRegistry(schemaDir, cacheDir, false) if err != nil { t.Fatal(err) } @@ -42,7 +43,7 @@ func TestGenerateCompositeSchemaUsesSeparateCacheDirectory(t *testing.T) { root := t.TempDir() schemaDir := filepath.Join(root, "schemas") cacheDir := filepath.Join(root, "generated") - registry, err := NewRegistry(schemaDir, cacheDir) + registry, err := NewRegistry(schemaDir, cacheDir, false) if err != nil { t.Fatal(err) } @@ -69,3 +70,25 @@ func TestGenerateCompositeSchemaUsesSeparateCacheDirectory(t *testing.T) { t.Fatalf("schema directory contains generated entries: %v", entries) } } + +func TestOfflineRegistryDoesNotDownloadMissingSchema(t *testing.T) { + t.Parallel() + + root := t.TempDir() + registry, err := NewRegistry( + filepath.Join(root, "schemas"), + filepath.Join(root, "generated"), + true, + ) + if err != nil { + t.Fatal(err) + } + + _, err = registry.GetSchemaURI( + "http://127.0.0.1:1/should-not-be-requested", + filepath.Join("kubernetes-builtin", "missing.json"), + ) + if !errors.Is(err, ErrSchemaUnavailableOffline) { + t.Fatalf("GetSchemaURI() error = %v, want ErrSchemaUnavailableOffline", err) + } +}