diff --git a/README.md b/README.md
index 138ccf0..1cdfa7a 100644
--- a/README.md
+++ b/README.md
@@ -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)
`~/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.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) |
### Example Editor Configuration (Helix)
@@ -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"
]
@@ -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
+/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 f9819d4..b0949f0 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",
@@ -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,
@@ -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)
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"
diff --git a/internal/detector/kubernetes/crd.go b/internal/detector/kubernetes/crd.go
index 37ec6a8..8b3451d 100644
--- a/internal/detector/kubernetes/crd.go
+++ b/internal/detector/kubernetes/crd.go
@@ -1,7 +1,9 @@
package kubernetes
import (
+ "crypto/sha256"
"encoding/json"
+ "errors"
"fmt"
"log"
"net/url"
@@ -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)
@@ -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
}
@@ -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
@@ -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
}
diff --git a/internal/detector/kubernetes/k8s.go b/internal/detector/kubernetes/k8s.go
index 1963e45..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"
@@ -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)
@@ -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,
@@ -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 ""
}
diff --git a/internal/schemaregistry/registry.go b/internal/schemaregistry/registry.go
index 2530604..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,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)
@@ -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.
@@ -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
@@ -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
}
diff --git a/internal/schemaregistry/registry_test.go b/internal/schemaregistry/registry_test.go
new file mode 100644
index 0000000..f85110b
--- /dev/null
+++ b/internal/schemaregistry/registry_test.go
@@ -0,0 +1,94 @@
+package schemaregistry
+
+import (
+ "errors"
+ "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, false)
+ 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, false)
+ 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)
+ }
+}
+
+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)
+ }
+}