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) + } +}