diff --git a/gateway/gateway-controller/pkg/immutable/loader.go b/gateway/gateway-controller/pkg/immutable/loader.go index 3085e27dd5..092874c1c9 100644 --- a/gateway/gateway-controller/pkg/immutable/loader.go +++ b/gateway/gateway-controller/pkg/immutable/loader.go @@ -21,7 +21,6 @@ package immutable import ( "errors" "fmt" - "io/fs" "log/slog" "net/http" "os" @@ -37,6 +36,13 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" ) +// artifactContentTypes maps supported file extensions to their content types. +var artifactContentTypes = map[string]string{ + ".yaml": "application/x-yaml", + ".yml": "application/x-yaml", + ".json": "application/json", +} + // ImmutableGW manages immutable gateway mode: artifact loading on startup and // read-only enforcement on the management API at runtime. // When cfg.Enabled is false, all methods are no-ops. @@ -91,28 +97,23 @@ func (g *ImmutableGW) LoadArtifacts(log *slog.Logger) error { // pass3: RestApi, WebSubApi, LlmProxy, Mcp — LlmProxy depends on LlmProvider var pass1, pass2, pass3 []artifact - if err := filepath.WalkDir(g.cfg.ArtifactsDir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return fmt.Errorf("error accessing path %s: %w", path, walkErr) - } - if d.IsDir() { - return nil - } + filePaths, err := collectArtifacts(g.cfg.ArtifactsDir) + if err != nil { + return err + } + + for _, path := range filePaths { ext := strings.ToLower(filepath.Ext(path)) - if ext != ".yaml" && ext != ".yml" && ext != ".json" { - return nil + contentType, ok := artifactContentTypes[ext] + if !ok { + log.Warn("Skipping file with unsupported extension", slog.String("path", path)) + continue } - data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("failed to read artifact %s: %w", path, err) } - contentType := "application/x-yaml" - if ext == ".json" { - contentType = "application/json" - } - var envelope struct { Kind string `yaml:"kind" json:"kind"` } @@ -134,9 +135,6 @@ func (g *ImmutableGW) LoadArtifacts(log *slog.Logger) error { default: return fmt.Errorf("artifact %s has unsupported kind %q", path, envelope.Kind) } - return nil - }); err != nil { - return err } total := len(pass1) + len(pass2) + len(pass3) @@ -210,6 +208,35 @@ func (g *ImmutableGW) applyArtifact(path, kind, contentType string, data []byte, return nil } +// collectArtifacts lists files in dir, skipping dot-prefixed entries. +func collectArtifacts(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading artifacts dir %s: %w", dir, err) + } + var paths []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + path := filepath.Join(dir, e.Name()) + fi, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", path, err) + } + if fi.IsDir() { + nested, err := collectArtifacts(path) + if err != nil { + return nil, err + } + paths = append(paths, nested...) + continue + } + paths = append(paths, path) + } + return paths, nil +} + // Middleware returns a handler that rejects POST, PUT, and DELETE with 405 // when immutable mode is enabled. When disabled, it returns a passthrough handler. func (g *ImmutableGW) Middleware() func(http.Handler) http.Handler { diff --git a/gateway/gateway-controller/pkg/immutable/loader_test.go b/gateway/gateway-controller/pkg/immutable/loader_test.go new file mode 100644 index 0000000000..e78fb317a2 --- /dev/null +++ b/gateway/gateway-controller/pkg/immutable/loader_test.go @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package immutable + +import ( + "os" + "path/filepath" + "testing" +) + +const petstoreArtifact = `apiVersion: gateway.api-platform.wso2.com/v1 +kind: RestApi +metadata: + name: petstore-v1 +spec: + displayName: Petstore + version: v1.0 + context: /petstore/$version + upstream: + main: + url: https://petstore3.swagger.io/api/v3 + operations: + - method: GET + path: /pet/{petId} +` + +func TestCollectArtifacts_ConfigMapMountYieldsFileOnce(t *testing.T) { + // Kubernetes ConfigMap mount layout: + // / + // ..2026_07_13_.../petstore.yaml (real file in timestamped dir) + // ..data -> ..2026_07_13_.../ (symlink to current revision) + // petstore.yaml -> ..data/petstore.yaml (per-key symlink) + dir := t.TempDir() + + tsDir := filepath.Join(dir, "..2026_07_13_10_00_00.123456") + if err := os.Mkdir(tsDir, 0o700); err != nil { + t.Fatalf("setup: mkdir %s: %v", tsDir, err) + } + if err := os.WriteFile(filepath.Join(tsDir, "petstore.yaml"), []byte(petstoreArtifact), 0o600); err != nil { + t.Fatalf("setup: write real file: %v", err) + } + + dotData := filepath.Join(dir, "..data") + if err := os.Symlink(tsDir, dotData); err != nil { + t.Fatalf("setup: symlink ..data: %v", err) + } + + topLevel := filepath.Join(dir, "petstore.yaml") + if err := os.Symlink(filepath.Join(dotData, "petstore.yaml"), topLevel); err != nil { + t.Fatalf("setup: symlink petstore.yaml: %v", err) + } + + paths, err := collectArtifacts(dir) + + if err != nil { + t.Fatalf("collectArtifacts: unexpected error: %v", err) + } + if len(paths) != 1 { + t.Fatalf("got %d path(s) %v; want exactly 1", len(paths), paths) + } + if paths[0] != topLevel { + t.Errorf("got %q; want top-level symlink %q", paths[0], topLevel) + } +} + +func TestCollectArtifacts_ConfigMapNestedDirSymlinkYieldsFileOnce(t *testing.T) { + // Kubernetes ConfigMap mount layout with a nested key (rest-apis/petstore.yaml): + // / + // ..2026_07_13_.../rest-apis/petstore.yaml (real file in timestamped dir) + // ..data -> ..2026_07_13_.../ (symlink to current revision) + // rest-apis -> ..data/rest-apis (top-level symlink to DIRECTORY) + dir := t.TempDir() + + tsDir := filepath.Join(dir, "..2026_07_13_10_00_00.123456") + if err := os.MkdirAll(filepath.Join(tsDir, "rest-apis"), 0o700); err != nil { + t.Fatalf("setup: mkdirall: %v", err) + } + if err := os.WriteFile(filepath.Join(tsDir, "rest-apis", "petstore.yaml"), []byte(petstoreArtifact), 0o600); err != nil { + t.Fatalf("setup: write real file: %v", err) + } + + dotData := filepath.Join(dir, "..data") + if err := os.Symlink(tsDir, dotData); err != nil { + t.Fatalf("setup: symlink ..data: %v", err) + } + + // Top-level entry is a symlink to a directory, not a file. + if err := os.Symlink(filepath.Join(dotData, "rest-apis"), filepath.Join(dir, "rest-apis")); err != nil { + t.Fatalf("setup: symlink rest-apis: %v", err) + } + + want := filepath.Join(dir, "rest-apis", "petstore.yaml") + paths, err := collectArtifacts(dir) + + if err != nil { + t.Fatalf("collectArtifacts: unexpected error: %v", err) + } + if len(paths) != 1 { + t.Fatalf("got %d path(s) %v; want exactly 1", len(paths), paths) + } + if paths[0] != want { + t.Errorf("got %q; want %q", paths[0], want) + } +} + +func TestCollectArtifacts_SkipsDotPrefixedEntries(t *testing.T) { + dir := t.TempDir() + hidden := filepath.Join(dir, ".hidden") + if err := os.Mkdir(hidden, 0o700); err != nil { + t.Fatalf("setup: %v", err) + } + if err := os.WriteFile(filepath.Join(hidden, "petstore.yaml"), []byte(petstoreArtifact), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + paths, err := collectArtifacts(dir) + + if err != nil { + t.Fatalf("collectArtifacts: %v", err) + } + if len(paths) != 0 { + t.Fatalf("got %d path(s) %v; want 0 (dot-prefixed entries should be skipped)", len(paths), paths) + } +} +